diff --git a/README.md b/README.md index a25f8ee..ca3b7b6 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,9 @@ Push SafeDep malware findings to external security tools. JFrog XRay is supporte | [`safedep integration jfrog run`](./docs/cmd/integration-jfrog-run.md) | Push SafeDep malware findings to JFrog XRay | | [`safedep integration jfrog cursor set`](./docs/cmd/integration-jfrog-cursor-set.md) | Set the JFrog feed cursor to a timestamp for the active profile | | [`safedep integration jfrog cursor remove`](./docs/cmd/integration-jfrog-cursor-remove.md) | Remove the saved JFrog feed cursor for the active profile | +| [`safedep integration crowdstrike run`](./docs/cmd/integration-crowdstrike-run.md) | Log SafeDep endpoint malicious-package and cooldown block events | +| [`safedep integration crowdstrike cursor set`](./docs/cmd/integration-crowdstrike-cursor-set.md) | Set the CrowdStrike sync cursor to a timestamp for the active profile | +| [`safedep integration crowdstrike cursor remove`](./docs/cmd/integration-crowdstrike-cursor-remove.md) | Remove the saved CrowdStrike sync cursor for the active profile | | [`safedep setup mcp install`](./docs/cmd/setup-mcp-install.md) | Guided onboarding: authenticate and configure AI agents | | [`safedep version`](./docs/cmd/version.md) | Print CLI version | diff --git a/docs/cmd/integration-crowdstrike-cursor-remove.md b/docs/cmd/integration-crowdstrike-cursor-remove.md new file mode 100644 index 0000000..9ba2ac3 --- /dev/null +++ b/docs/cmd/integration-crowdstrike-cursor-remove.md @@ -0,0 +1,31 @@ +# safedep integration crowdstrike cursor remove + +Remove the saved sync cursor for the active SafeDep profile so the next run starts fresh. + +## Synopsis + +``` +safedep integration crowdstrike cursor remove +``` + +## Description + +`run` stores a cursor to resume where it stopped. Remove it to start fresh: the +next run starts from now, or from `--backfill`. + +The cursor is per profile. Use `--profile` to target a different one. + +## Examples + +```bash +# Active profile +safedep integration crowdstrike cursor remove + +# A named profile +safedep --profile customer-a integration crowdstrike cursor remove +``` + +## Notes + +The command prints the SQLite path it uses. Editing that file by hand is not +supported. Use `cursor remove` or `cursor set` instead. diff --git a/docs/cmd/integration-crowdstrike-cursor-set.md b/docs/cmd/integration-crowdstrike-cursor-set.md new file mode 100644 index 0000000..ad7ff88 --- /dev/null +++ b/docs/cmd/integration-crowdstrike-cursor-set.md @@ -0,0 +1,31 @@ +# safedep integration crowdstrike cursor set + +Set the saved sync cursor to a timestamp for the active SafeDep profile. + +## Synopsis + +``` +safedep integration crowdstrike cursor set +``` + +## Description + +The next run processes events after ``. Use it to re-process from a +chosen point, a more precise alternative to `--backfill`. + +`` is RFC3339, for example `2026-08-25T10:00:00Z`. The cursor is per +profile. Use `--profile` to target a different one. + +## Examples + +```bash +# Re-process everything since 1 August +safedep integration crowdstrike cursor set 2026-08-01T00:00:00Z + +# A named profile +safedep --profile customer-a integration crowdstrike cursor set 2026-08-01T00:00:00Z +``` + +## See also + +- [`cursor remove`](./integration-crowdstrike-cursor-remove.md) clears the cursor. diff --git a/docs/cmd/integration-crowdstrike-run.md b/docs/cmd/integration-crowdstrike-run.md new file mode 100644 index 0000000..2729b79 --- /dev/null +++ b/docs/cmd/integration-crowdstrike-run.md @@ -0,0 +1,102 @@ +# safedep integration crowdstrike run + +Long-running daemon that streams endpoint package-guard **block events** from +SafeDep and logs them. Two signals are covered, treated as one event stream: + +- **Malicious package blocks** (a package identified as malware and blocked). +- **Dependency cooldown blocks** (a package blocked for being published too + recently). + +Events are pulled incrementally by cursor, so each run resumes where it stopped. +This is Stage 1: the events are logged. A later stage will push them to +CrowdStrike SIEM. + +## Synopsis + +``` +safedep integration crowdstrike run +``` + +## Quick start + +```bash +# 1. Authenticate with SafeDep (once) +safedep auth login + +# 2. Stream and log new block events +safedep integration crowdstrike run +``` + +## Flags + +| Flag | Required | Default | Description | +|---|---|---|---| +| `--poll-interval` | no | `5m` | Sleep duration between sync cycles (`30s`, `5m`, `1h`). | +| `--backfill` | no | `0` | First-run window used to seed the cursor. `0` starts fresh from now. | +| `--profile` | no | `"default"` | SafeDep credential profile (inherited from root). | + +`--backfill` takes a Go duration, so use hours for multi-day windows (e.g. +`--backfill 168h` for 7 days). It only affects the **first** run on a fresh +install: with no stored cursor, the command requests events after +`now - backfill`. Once a cursor exists, `--backfill` is ignored and the command +resumes from the last processed event. At startup the command logs which mode it +uses: resuming from the saved cursor, or starting fresh (with the backfill +window when set). + +## Output and logs + +The command keeps output and logs separate. + +- **Output** is the result: one block event. +- **Logs** are operational: sync cycle, startup mode, and errors. + +`-o json` (or `--output json`) is a request for machine output. In this mode the +command prints only the event records, as JSONL on stdout, one object per line. +It suppresses every log. Without `-o json`, the command prints results and logs +to stderr for people. + +```bash +safedep integration crowdstrike run -o json +``` + +```json +{"event":"endpoint_package_blocked","action":"blocked","event_id":"evt-1","timestamp":"2026-09-15T10:00:00Z","endpoint_id":"ep-1","endpoint_name":"web-01","tool_name":"pmg","package":"left-pad","ecosystem":"npm","version":"1.0.0","is_malware":true,"is_verified":true,"analysis_id":"an-1"} +{"event":"endpoint_package_blocked","action":"cooldown_blocked","event_id":"evt-2","timestamp":"2026-09-15T10:01:00Z","endpoint_name":"web-01","tool_name":"pmg","package":"shiny-pkg","ecosystem":"npm","version":"0.0.1","cooldown":{"cooldown_days":7,"days_since_publish":4,"days_remaining":3}} +``` + +Every record is `event: endpoint_package_blocked`, distinguished by `action` +(`blocked` or `cooldown_blocked`). The `cooldown` object is present only on +cooldown events. + +## Behaviour + +- **First run.** With no stored cursor the command starts fresh from now + (`--backfill 0`). It does not pull historical events unless you set + `--backfill`. +- **Both actions.** Malicious blocks and cooldown blocks are pulled together and + logged the same way, tagged by `action`. +- **Resume.** The cursor is stored per SafeDep profile. Restarting resumes from + the last processed event. Switching `--profile` switches the cursor. + Re-process from a chosen point with + [`cursor set`](./integration-crowdstrike-cursor-set.md), or from scratch with + [`cursor remove`](./integration-crowdstrike-cursor-remove.md). + +## SafeDep authentication + +The command reads the SafeDep control plane (`cloud.safedep.io`), the same +service as the `safedep endpoint` commands. It authenticates with an **OAuth +session**, so log in with the device flow: + +```bash +safedep auth login +``` + +Confirm the code in your browser to complete login. The session is stored in the +keychain per profile and refreshed automatically. An API-key-only login does not +work for this control-plane service, so use `safedep auth login` (OAuth). + +## Subscription + +This integration requires the **endpoint management add-on**. If the tenant does +not have it, the command stops with a message pointing to the +[pricing page](https://safedep.io/pricing). diff --git a/docs/integration-crowdstrike-spec.md b/docs/integration-crowdstrike-spec.md new file mode 100644 index 0000000..e1d28e7 --- /dev/null +++ b/docs/integration-crowdstrike-spec.md @@ -0,0 +1,235 @@ +# Spec: `integration crowdstrike run` (PMG endpoint block events poller) + +Status: Stage 1 implemented. This is the design record; Stage 2 (CrowdStrike SIEM +push) and Stage 3 remain future work. + +## Goal + +A cursor-based poller, modeled on `integration jfrog`, that incrementally pulls +new **PMG endpoint package-block events** from SafeDep and, for now, **logs** +them. The two signals we care about, treated uniformly (they are just events): + +- **Malicious package blocks** — `PmgPackageAction.BLOCKED (1)`. +- **Cooldown blocks** — `PmgPackageAction.COOLDOWN_BLOCKED (4)`. + +## Staged delivery + +| Stage | Scope | State | +|---|---|---| +| **Stage 1** | Poll `ListEndpointPackageGuardEvents` by cursor, filter to the two block actions, send each new event to the **`printClient`** sink adapter (logs it: JSONL on stdout under `-o json`, human line on stderr). | **this spec** | +| **Stage 2** | Implement a **`crowdstrikeClient`** sink adapter (POST to the CrowdStrike SIEM ingest API) and swap it in behind the same port. `--dry-run` selects `printClient` for a log-only preview. | future | + +The command is named `crowdstrike` now (not `endpoint`/`pmg`) so Stage 2 lands +under the same noun without a rename. The sink is a **port** from day one +(exactly like jfrog's `xrayClient`), so Stage 2 only adds one adapter and a +wiring line, changing nothing in the source, service, or cursor. + +## Source (settled) + +- **RPC:** `EndpointManagementService.ListEndpointPackageGuardEvents` + (`controltowerv1grpc.NewEndpointManagementServiceClient`). +- **Transport / auth:** `a.ControlPlane().Connection()` with an **OAuth session** + (`safedep auth login` device flow, token refresh handled by the app). This is a + control-plane service (`cloud.safedep.io`), the same one the `safedep endpoint` + commands use. Verified: the data plane (api.safedep.io, API key) returns + `Unimplemented / 404` for it, so it is not plane-interchangeable. +- **Request per page:** + - `Filter.Pmg.EventTypes = [PACKAGE_DECISION]`, + `Filter.Pmg.PackageActions = [BLOCKED, COOLDOWN_BLOCKED]`. + `EndpointIds` left empty (all endpoints), `InvocationId` unset. + - `TimeRange.Start = cursor watermark` (see Cursor). `End = now`, captured once + per drain. Both are required and the API rejects `start >= end`, so a fresh + `start == now` is nudged below `end`. + - `Pagination.PageSize = 100` (service-capped), `SortOrder = ASCENDING`, + `PageToken` advances within a drain. +- **Response:** `Events []PackageGuardEvent`, `Pagination.NextPageToken`. +- **Event fields used:** `EventId`, `Timestamp`, `EndpointId`, `EndpointName`, + `ToolName`, and `PmgEvent.PackageDecision`: + `PackageVersion` (`Package.Name`, `Package.Ecosystem`, version), `Action`, + `IsMalware`, `IsVerified`, `AnalysisId`, `Cooldown` (`PublishDate`, + `CooldownDays`, `DaysSincePublish`, `DaysRemaining`). + +## Cursor semantics + +Profile-scoped KV, namespace `integration-crowdstrike`, key `cursor`. Unlike the +jfrog feed (mutable reports keyed on `updated_at`), these events are **immutable +and append-only**, so a `Timestamp` watermark is correct and there is no +re-delivery-on-change concern. + +### Two "cursors": page token vs time window + +The API exposes no durable, cross-cycle resume token. There are two distinct +notions of cursor, only one of which persists across poll cycles: + +- **`Pagination.next_page_token`** is a within-cycle paging cursor only. It walks + the remaining pages of *one* query result set. It is an opaque snapshot token: + it expires and does not include events created after the query started, so it + **must not** be stored and reused on the next cycle. The source uses it to + drain one cycle, then discards it. +- **`TimeRange.Start` is our cross-cycle cursor.** To pull only fresh data next + cycle we send `TimeRange.Start = `. The + freshness cursor is client-maintained (persisted in KV as `LastSeenAt`), not a + token the server returns. + +`TimeRange.Start` is a correct cursor **because the events are immutable**: an +event never changes after emission, so its `Timestamp` is a stable ordering key +and advancing `Start` can never skip an event before it "finalizes" (nothing +finalizes). This is exactly the property the jfrog *reports* feed lacked, which +is why that feed had to cursor on `updated_at` rather than `created_at`. A real +server-side `after_event_id` cursor would only remove our need to care about +timestamp ties (handled below by `EventId` dedup); it is not a correctness gap. +If the API adds one, switch to it and drop `LastSeenEventIDs`. + +```go +type cursorState struct { + LastSeenAt time.Time `json:"last_seen_at"` + LastSeenEventIDs []string `json:"last_seen_event_ids"` // event ids at exactly LastSeenAt +} +``` + +Rules: + +- `TimeRange.Start = LastSeenAt`. First run: `now - backfill` (default backfill + `0` => `now`, fresh). Never omitted. +- **Boundary dedup:** the API does not document whether `Start` is inclusive. + We skip any event whose `EventId` is in `LastSeenEventIDs`, so an inclusive + `Start` (or two events sharing a timestamp across a page boundary) never + re-logs. This matters for the future SIEM ingest where duplicates are costly; + the cost now is one `[]string` field, normally tiny. + `ponytail:` if the API guarantees exclusive `Start`, drop `LastSeenEventIDs`. +- Ascending order + fixed `Start` for the whole drain (only `PageToken` moves), + so an interrupted drain resumes gap-free. +- Save once, after the full drain: `LastSeenAt = max event Timestamp`, + `LastSeenEventIDs = ids at that max`. Forward-only. +- First run that saw nothing: anchor `LastSeenAt = Start` so the window does not + slide forward by `poll-interval` each cycle (same fix as jfrog). +- Decode failure => reset + warn; DB error => propagate + retry next cycle. + +## Command surface + +``` +safedep integration crowdstrike run + --poll-interval duration sleep between drains (default 5m) + --backfill duration first-run window; 0 = fresh from now (default 0) +safedep integration crowdstrike cursor set +safedep integration crowdstrike cursor remove +``` + +No JFrog-style URL/token flags (no external sink in Stage 1). SafeDep auth is the +active profile / `SAFEDEP_API_KEY` + `SAFEDEP_TENANT_ID`, same as every +data-plane command. No `--dry-run` in Stage 1 (logging is the behavior; dry-run +arrives with the Stage 2 push). `cursor set/remove` mirror jfrog for re-processing +a window. + +## Sink port and adapters (mirrors jfrog `xrayClient`) + +The service never knows where events go. It pushes each event through a port: + +```go +type eventSink interface { + // validate proves the sink is reachable/authorized, once at startup. + validate(ctx context.Context) error + // send delivers one event. The adapter owns mapping + emitting its own + // result line. Errors are best-effort (logged, non-fatal) in the service. + send(ctx context.Context, event *controltowerv1.ListEndpointPackageGuardEventsResponse_PackageGuardEvent) error +} +``` + +Adapters (same shape as jfrog's `jfrogClient` + `printClient`): + +| Adapter | Stage | `validate` | `send` | +|---|---|---|---| +| `printClient` | **1 (now)** | logs "logging mode, nothing is sent" | maps the event and emits it via the reporter (see Output) | +| `crowdstrikeClient` | 2 (future) | checks CrowdStrike ingest connectivity/creds | POSTs the event to the CrowdStrike SIEM ingest API, emits a result on success | + +`run.go` wires `printClient` now; Stage 2 swaps `crowdstrikeClient` (e.g. when +CrowdStrike creds are configured, `--dry-run` forces `printClient`), exactly like +jfrog's `buildSourceAndClient`. Following jfrog, **no neutral DTO**: the port +takes the raw `*PackageGuardEvent` and each adapter maps it (a translation layer +for one wire type is dead weight). Ecosystem mapping and event->fields helpers +live beside the port in `client.go`. + +## Output (logging via `printClient`) + +`printClient.send` reuses the reporter output/log split. Each new block event is +a **result**: + +- Human (default): one line on stderr, e.g. + `Blocked: left-pad@1.0.0 (npm) malware on host web-01 [evt_...]` + `Cooldown: left-pad@1.0.0 (npm) 3d remaining on host web-01 [evt_...]` +- `-o json`: one JSONL record on stdout: + +```json +{"event":"endpoint_package_blocked","action":"blocked","event_id":"...", + "timestamp":"...","endpoint_id":"...","endpoint_name":"web-01", + "tool_name":"pmg","package":"left-pad","ecosystem":"npm","version":"1.0.0", + "is_malware":true,"is_verified":true,"analysis_id":"...", + "cooldown":{"publish_date":"...","cooldown_days":7,"days_since_publish":4,"days_remaining":3}} +``` + +One `event` name (`endpoint_package_blocked`) with an `action` field +(`blocked` / `cooldown_blocked`); `cooldown` object present only for cooldown +events. Poll-cycle lines, "starting", resume/backfill notices are logs (stderr in +human mode, suppressed under `-o json`), exactly as jfrog. + +## Error handling + +Mirror jfrog's transient-vs-fatal split in the poll loop: + +- `PermissionDenied` => fatal, friendly "endpoint management / PMG not enabled for + this tenant" message; stop, don't loop. +- Unauthenticated / API-key errors => fatal auth message (how to log in / set env). +- Any other error => log + retry next cycle. +- `ctx.Done()` => clean stop. + +## Architecture and reuse + +**Chosen: Option B** — copy the ~small generic pieces into the new `crowdstrike` +package, leave the shipped `jfrog` untouched (zero risk to a live feature). The +duplicated pieces are small and stable: the reporter (output/log split), the +cursor store over `*storage.KV`, and the `callbackError`/source seam. A shared +kit (Option A: extract to `internal/.../streamkit` and refactor jfrog onto it) +was considered and declined for now to avoid churning a shipped integration; it +stays available as a later cleanup if a third consumer appears. + +Files, new package `internal/cmd/integration/crowdstrike/`: + +| File | Responsibility | +|---|---| +| `cmd.go` | Register `crowdstrike` under `integration`; `run` + `cursor` verbs. | +| `run.go` | Flag/env resolve; build source + sink; hand to service. | +| `source.go` | `ListEndpointPackageGuardEvents` paging + cursor; source seam (copied). | +| `service.go` | Route each delivered event to the sink (best-effort). | +| `client.go` | `eventSink` port + mapping helpers (ecosystem, event->fields). | +| `printclient.go` | `printClient` sink adapter: maps + emits via the reporter. | +| `reporter.go` | Output/log split (copied from jfrog, own `jsonEvent`). | +| `cursor.go` | Cursor store over `*storage.KV[cursorState]` (copied). | +| `types.go` | Config DTO. | + +`crowdstrikeClient` (the real sink adapter) is a Stage 2 file, not built now. + +`integration/cmd.go` gains `crowdstrike.Register(cmd, a)` next to `jfrog.Register`. + +## Out of scope (Stage 1) + +- Any push to CrowdStrike (Stage 2). +- `--dry-run` (meaningless with no external sink). +- Session-summary / other PMG event types; only `PACKAGE_DECISION` with the two + block actions. + +## Open items / assumptions to verify at build + +1. ~~Data-plane serves the service~~ RESOLVED: it does not. This is a + control-plane service; use `a.ControlPlane()` (OAuth), matching `safedep endpoint`. +2. `TimeRange.Start` inclusivity — the `EventId` dedup makes us correct either + way; confirm to decide whether `LastSeenEventIDs` can be dropped. +3. Service page-size cap (assume 100). +4. Shape/stability of `EventId` (used as the dedup key and in output). + +## Tests + +Mirror jfrog: table-driven, fake grpc `EndpointManagementServiceClient`, real +temp-DB `*storage.KV` for the cursor. Cover: filter/request construction, +ascending paging across a page boundary, cursor advance + boundary `EventId` +dedup, backfill first-run, transient-retry vs fatal (PermissionDenied / +Unauthenticated), and JSONL vs human output. diff --git a/internal/cmd/integration/cmd.go b/internal/cmd/integration/cmd.go index 79955f0..4537a19 100644 --- a/internal/cmd/integration/cmd.go +++ b/internal/cmd/integration/cmd.go @@ -5,6 +5,7 @@ package integration import ( "github.com/safedep/cli/internal/app" + "github.com/safedep/cli/internal/cmd/integration/crowdstrike" "github.com/safedep/cli/internal/cmd/integration/jfrog" "github.com/spf13/cobra" ) @@ -19,5 +20,6 @@ func Register(root *cobra.Command, a *app.App) { } jfrog.Register(cmd, a) + crowdstrike.Register(cmd, a) root.AddCommand(cmd) } diff --git a/internal/cmd/integration/crowdstrike/client.go b/internal/cmd/integration/crowdstrike/client.go new file mode 100644 index 0000000..7e2f98f --- /dev/null +++ b/internal/cmd/integration/crowdstrike/client.go @@ -0,0 +1,95 @@ +package crowdstrike + +import ( + "context" + "strings" + "time" + + ctmsgv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// eventSink is the port each new endpoint block event is sent through. The +// service never knows the destination. printClient (Stage 1) logs the event; a +// future crowdstrikeClient (Stage 2) will POST it to the CrowdStrike SIEM +// ingest API. Swapping the adapter is the only change needed, exactly like +// jfrog's xrayClient. +type eventSink interface { + // validate proves the sink is reachable/authorized, once at startup. + validate(ctx context.Context) error + // send delivers one event. The adapter owns mapping and emitting its own + // result line. Errors are best-effort (logged, non-fatal) in the service. + send(ctx context.Context, event *packageGuardEvent) error +} + +const ( + actionBlocked = "blocked" + actionCooldownBlocked = "cooldown_blocked" + actionUnknown = "unknown" +) + +// toJSONEvent maps a package-guard event to the neutral record shared by every +// sink adapter. ok is false when the event carries no package decision +// (defensive: the server-side filter should guarantee one). +func toJSONEvent(ev *packageGuardEvent) (jsonEvent, bool) { + dec := ev.GetPmgEvent().GetPackageDecision() + if dec == nil { + return jsonEvent{}, false + } + + pkg := dec.GetPackageVersion() + out := jsonEvent{ + Event: eventPackageBlocked, + Action: actionString(dec.GetAction()), + EventID: ev.GetEventId(), + Timestamp: formatTime(ev.GetTimestamp()), + EndpointID: ev.GetEndpointId(), + Endpoint: ev.GetEndpointName(), + Tool: ev.GetToolName(), + Package: pkg.GetPackage().GetName(), + Ecosystem: ecosystemString(pkg.GetPackage().GetEcosystem()), + Version: pkg.GetVersion(), + IsMalware: dec.GetIsMalware(), + IsVerified: dec.GetIsVerified(), + AnalysisID: dec.GetAnalysisId(), + } + if cd := dec.GetCooldown(); cd != nil { + out.Cooldown = &cooldownJSON{ + PublishDate: formatTime(cd.GetPublishDate()), + CooldownDays: cd.GetCooldownDays(), + DaysSincePublish: cd.GetDaysSincePublish(), + DaysRemaining: cd.GetDaysRemaining(), + } + } + return out, true +} + +// actionString maps the PMG package action to the stable string used in output. +// Only the two block actions are requested from the server, so anything else is +// unexpected and tagged unknown rather than dropped. +func actionString(a ctmsgv1.PmgPackageAction) string { + switch a { + case ctmsgv1.PmgPackageAction_PMG_PACKAGE_ACTION_BLOCKED: + return actionBlocked + case ctmsgv1.PmgPackageAction_PMG_PACKAGE_ACTION_COOLDOWN_BLOCKED: + return actionCooldownBlocked + default: + return actionUnknown + } +} + +// ecosystemString renders a SafeDep ecosystem as a lowercase name (npm, pypi, +// ...). It derives the name from the enum so a new SafeDep ecosystem needs no +// code change here. +func ecosystemString(e packagev1.Ecosystem) string { + return strings.ToLower(strings.TrimPrefix(e.String(), "ECOSYSTEM_")) +} + +// formatTime renders a timestamp as RFC3339 UTC, or "" when absent. +func formatTime(ts *timestamppb.Timestamp) string { + if ts == nil { + return "" + } + return ts.AsTime().UTC().Format(time.RFC3339) +} diff --git a/internal/cmd/integration/crowdstrike/client_test.go b/internal/cmd/integration/crowdstrike/client_test.go new file mode 100644 index 0000000..55672b6 --- /dev/null +++ b/internal/cmd/integration/crowdstrike/client_test.go @@ -0,0 +1,56 @@ +package crowdstrike + +import ( + "testing" + "time" + + ctmsgv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToJSONEvent_MaliciousBlock(t *testing.T) { + base := time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) + ev := newTestEvent(base, blocked("evt-1", "left-pad", "1.0.0", 0)) + + rec, ok := toJSONEvent(ev) + require.True(t, ok) + assert.Equal(t, eventPackageBlocked, rec.Event) + assert.Equal(t, actionBlocked, rec.Action) + assert.Equal(t, "evt-1", rec.EventID) + assert.Equal(t, "left-pad", rec.Package) + assert.Equal(t, "npm", rec.Ecosystem) + assert.Equal(t, "1.0.0", rec.Version) + assert.True(t, rec.IsMalware) + assert.Equal(t, "2026-09-15T10:00:00Z", rec.Timestamp) + assert.Nil(t, rec.Cooldown, "a malicious block carries no cooldown detail") + + assert.Equal(t, "Malicious block: left-pad@1.0.0 (npm) malware on host-1 [evt-1]", humanLine(rec)) +} + +func TestToJSONEvent_CooldownBlock(t *testing.T) { + base := time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) + ev := newTestEvent(base, eventSpec{ + id: "evt-2", + name: "shiny-pkg", + version: "0.0.1", + action: ctmsgv1.PmgPackageAction_PMG_PACKAGE_ACTION_COOLDOWN_BLOCKED, + cooldownDays: 3, + }) + + rec, ok := toJSONEvent(ev) + require.True(t, ok) + assert.Equal(t, actionCooldownBlocked, rec.Action) + require.NotNil(t, rec.Cooldown) + assert.Equal(t, uint32(3), rec.Cooldown.DaysRemaining) + + assert.Equal(t, "Cooldown block: shiny-pkg@0.0.1 (npm) cooldown, 3d remaining on host-1 [evt-2]", humanLine(rec)) +} + +func TestToJSONEvent_NoPackageDecision_Skipped(t *testing.T) { + ev := &packageGuardEvent{} + ev.SetEventId("evt-3") + + _, ok := toJSONEvent(ev) + assert.False(t, ok, "an event with no package decision is skipped") +} diff --git a/internal/cmd/integration/crowdstrike/cmd.go b/internal/cmd/integration/crowdstrike/cmd.go new file mode 100644 index 0000000..4cf5ae3 --- /dev/null +++ b/internal/cmd/integration/crowdstrike/cmd.go @@ -0,0 +1,32 @@ +// Package crowdstrike implements the SafeDep -> CrowdStrike endpoint block event +// integration. It polls SafeDep endpoint package-guard block events (malicious +// package blocks and dependency cooldown blocks) on an interval, resuming from a +// KV-backed cursor, and routes each event to a sink. Stage 1 logs the events; a +// later stage will push them to CrowdStrike SIEM. +package crowdstrike + +import ( + "github.com/safedep/cli/internal/app" + "github.com/spf13/cobra" +) + +// Register attaches the `crowdstrike` sub-command (and its verbs) under the +// supplied parent. Called by the `integration` package during root command +// assembly. +func Register(parent *cobra.Command, a *app.App) { + cmd := &cobra.Command{ + Use: "crowdstrike", + Short: "CrowdStrike endpoint integration commands", + Long: `Stream SafeDep endpoint package-guard block events for delivery to CrowdStrike. + +The integration pulls malicious-package block events and dependency-cooldown +block events from SafeDep endpoint telemetry, incrementally by cursor. Stage 1 +logs the events; a later stage will push them to CrowdStrike SIEM. + +Authentication uses the active SafeDep profile (see 'safedep auth login') for the +SafeDep API.`, + } + + cmd.AddCommand(runCmd(a), cursorCmd(a)) + parent.AddCommand(cmd) +} diff --git a/internal/cmd/integration/crowdstrike/cursor.go b/internal/cmd/integration/crowdstrike/cursor.go new file mode 100644 index 0000000..6f3561d --- /dev/null +++ b/internal/cmd/integration/crowdstrike/cursor.go @@ -0,0 +1,113 @@ +package crowdstrike + +import ( + "fmt" + "time" + + "github.com/safedep/cli/internal/app" + "github.com/safedep/cli/internal/config" + drytui "github.com/safedep/dry/tui" + "github.com/spf13/cobra" +) + +// cursorCmd groups maintenance of the saved sync cursor. The cursor lets `run` +// resume where it stopped; the verbs here let an operator move or clear it +// without editing the SQLite state file by hand. +func cursorCmd(a *app.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "cursor", + Short: "Manage the saved sync cursor for the active SafeDep profile", + Long: `Manage the saved sync cursor for the active SafeDep profile. + +The 'run' command stores a cursor so it resumes where it stopped. These verbs +let you move it or clear it, the supported alternative to editing the SQLite +state file by hand. The cursor is per profile, so a change affects only the +profile selected with --profile.`, + } + + cmd.AddCommand(cursorSetCmd(a), cursorRemoveCmd(a)) + return cmd +} + +// cursorSetCmd sets the cursor to a given timestamp so the next run processes +// events after it. +func cursorSetCmd(a *app.App) *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Set the sync cursor to an RFC3339 timestamp", + Long: `Set the saved sync cursor to an RFC3339 timestamp. + +The next run processes events after this timestamp. Use it to re-process from a +chosen point. Only the profile selected with --profile is affected. + +The timestamp is RFC3339, for example 2026-08-25T10:00:00Z.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ts, err := time.Parse(time.RFC3339, args[0]) + if err != nil { + return fmt.Errorf("cursor set: %q is not an RFC3339 timestamp (e.g. 2026-08-25T10:00:00Z): %w", args[0], err) + } + + store, err := openCursorStore(a) + if err != nil { + return err + } + if err := store.save(cmd.Context(), cursorState{LastSeenAt: ts.UTC()}); err != nil { + return fmt.Errorf("cursor set: %w", err) + } + + drytui.Success("Cursor set for profile %q to %s", a.Profile(), ts.UTC().Format(time.RFC3339)) + drytui.Info("The next run processes events after this time") + return nil + }, + } +} + +// cursorRemoveCmd deletes the saved cursor so the next run starts fresh (or from +// --backfill). +func cursorRemoveCmd(a *app.App) *cobra.Command { + return &cobra.Command{ + Use: "remove", + Short: "Remove the saved sync cursor so the next run starts fresh", + Long: `Remove the saved sync cursor for the active SafeDep profile. + +The next run starts fresh from now, or from the window given by --backfill. Only +the profile selected with --profile is affected.`, + RunE: func(cmd *cobra.Command, _ []string) error { + store, err := openCursorStore(a) + if err != nil { + return err + } + + prev, err := store.load(cmd.Context()) + if err != nil { + return fmt.Errorf("cursor remove: load cursor: %w", err) + } + if prev.LastSeenAt.IsZero() { + drytui.Info("No saved cursor for profile %q. Nothing to remove.", a.Profile()) + return nil + } + + if err := store.remove(cmd.Context()); err != nil { + return fmt.Errorf("cursor remove: %w", err) + } + + drytui.Success("Cursor removed for profile %q (was %s)", a.Profile(), prev.LastSeenAt.UTC().Format(time.RFC3339)) + drytui.Info("The next run starts fresh from now, or from --backfill") + if path, err := config.DBPath(); err == nil { + drytui.Info("Cursor storage: %s", path) + } + return nil + }, + } +} + +// openCursorStore opens the profile-scoped cursor store shared by the cursor +// verbs. +func openCursorStore(a *app.App) (*cursorStore, error) { + kv, err := app.ProfileKV[cursorState](a, kvNamespace) + if err != nil { + return nil, fmt.Errorf("cursor: open cursor store: %w", err) + } + return newCursorStore(kv), nil +} diff --git a/internal/cmd/integration/crowdstrike/printclient.go b/internal/cmd/integration/crowdstrike/printclient.go new file mode 100644 index 0000000..43d9923 --- /dev/null +++ b/internal/cmd/integration/crowdstrike/printclient.go @@ -0,0 +1,66 @@ +package crowdstrike + +import ( + "context" + "fmt" + + drytui "github.com/safedep/dry/tui" +) + +var _ eventSink = (*printClient)(nil) + +// printClient is the Stage 1 sink adapter. It maps each event with the shared +// toJSONEvent and logs it via the reporter instead of sending it anywhere. When +// the CrowdStrike sink lands (Stage 2) it implements the same eventSink and is +// swapped in, changing nothing in the source or service. +type printClient struct { + rep *reporter +} + +func newPrintClient(rep *reporter) *printClient { return &printClient{rep: rep} } + +func (c *printClient) validate(_ context.Context) error { + c.rep.logInfo("Logging mode: printing endpoint block events, nothing is sent to an external system") + return nil +} + +func (c *printClient) send(_ context.Context, ev *packageGuardEvent) error { + rec, ok := toJSONEvent(ev) + if !ok { + c.rep.logWarn("Skipping event %s: no package decision", ev.GetEventId()) + return nil + } + + c.rep.result(func() { drytui.Warning("%s", humanLine(rec)) }, rec) + return nil +} + +// humanLine renders one event for the human log stream. +func humanLine(rec jsonEvent) string { + pkg := rec.Package + if rec.Version != "" { + pkg = rec.Package + "@" + rec.Version + } + + if rec.Action == actionCooldownBlocked { + detail := "cooldown" + if rec.Cooldown != nil { + detail = fmt.Sprintf("cooldown, %dd remaining", rec.Cooldown.DaysRemaining) + } + return fmt.Sprintf("Cooldown block: %s (%s) %s on %s [%s]", pkg, rec.Ecosystem, detail, endpointLabel(rec), rec.EventID) + } + + kind := "blocked" + if rec.IsMalware { + kind = "malware" + } + return fmt.Sprintf("Malicious block: %s (%s) %s on %s [%s]", pkg, rec.Ecosystem, kind, endpointLabel(rec), rec.EventID) +} + +// endpointLabel prefers the human endpoint name, falling back to its id. +func endpointLabel(rec jsonEvent) string { + if rec.Endpoint != "" { + return rec.Endpoint + } + return rec.EndpointID +} diff --git a/internal/cmd/integration/crowdstrike/reporter.go b/internal/cmd/integration/crowdstrike/reporter.go new file mode 100644 index 0000000..e88437a --- /dev/null +++ b/internal/cmd/integration/crowdstrike/reporter.go @@ -0,0 +1,87 @@ +package crowdstrike + +import ( + "encoding/json" + + "github.com/safedep/cli/internal/tui" + "github.com/safedep/dry/log" + drytui "github.com/safedep/dry/tui" +) + +// eventPackageBlocked is the single -o json event name. Both block actions +// (malicious and cooldown) share it, distinguished by the action field. +const eventPackageBlocked = "endpoint_package_blocked" + +// cooldownJSON is the nested cooldown detail, present only on cooldown events. +type cooldownJSON struct { + PublishDate string `json:"publish_date,omitempty"` + CooldownDays uint32 `json:"cooldown_days,omitempty"` + DaysSincePublish uint32 `json:"days_since_publish,omitempty"` + DaysRemaining uint32 `json:"days_remaining,omitempty"` +} + +// jsonEvent is one JSONL record on stdout under -o json. Fields are omitempty so +// each record carries only what it has. +type jsonEvent struct { + Event string `json:"event"` + Action string `json:"action,omitempty"` + EventID string `json:"event_id,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + EndpointID string `json:"endpoint_id,omitempty"` + Endpoint string `json:"endpoint_name,omitempty"` + Tool string `json:"tool_name,omitempty"` + Package string `json:"package,omitempty"` + Ecosystem string `json:"ecosystem,omitempty"` + Version string `json:"version,omitempty"` + IsMalware bool `json:"is_malware,omitempty"` + IsVerified bool `json:"is_verified,omitempty"` + AnalysisID string `json:"analysis_id,omitempty"` + Cooldown *cooldownJSON `json:"cooldown,omitempty"` +} + +func (e jsonEvent) RenderJSON() ([]byte, error) { return json.Marshal(e) } +func (e jsonEvent) RenderTable() string { return e.Event } +func (e jsonEvent) RenderPlain() string { return e.Event } + +// reporter sends daemon activity to the right stream for the active output +// mode. With -o json the user asked for machine output, so it writes only +// result events as JSONL on stdout and drops every log line. In any other mode +// it writes nothing to stdout and sends results and logs to stderr as drytui +// lines, the same as the rest of the CLI. +type reporter struct { + out *tui.Printer + json bool +} + +func newReporter(out *tui.Printer) *reporter { + return &reporter{out: out, json: out != nil && out.Mode() == tui.ModeJSON} +} + +// result reports one user-facing result. With -o json it writes a JSONL record +// to stdout. In any other mode it runs the human drytui line. +func (r *reporter) result(human func(), ev jsonEvent) { + if r.json { + if err := r.out.Print(ev); err != nil { + log.Warnf("integration crowdstrike: emit json event: %v", err) + } + return + } + human() +} + +// The log* methods are for operational messages. They print to stderr in human +// modes and print nothing under -o json. + +func (r *reporter) logInfo(format string, a ...any) { + if r.json { + return + } + drytui.Info(format, a...) +} + +func (r *reporter) logWarn(format string, a ...any) { + if r.json { + return + } + drytui.Warning(format, a...) +} diff --git a/internal/cmd/integration/crowdstrike/run.go b/internal/cmd/integration/crowdstrike/run.go new file mode 100644 index 0000000..45982da --- /dev/null +++ b/internal/cmd/integration/crowdstrike/run.go @@ -0,0 +1,103 @@ +package crowdstrike + +import ( + "fmt" + "time" + + controltowerv1grpc "buf.build/gen/go/safedep/api/grpc/go/safedep/services/controltower/v1/controltowerv1grpc" + "github.com/safedep/cli/internal/app" + "github.com/spf13/cobra" +) + +const ( + // kvNamespace is the profile-scoped KV namespace for this integration. + // Must match ^[a-z][a-z0-9_-]{0,63}$. + kvNamespace = "integration-crowdstrike" + + // kvCursorKey is the single KV key used to store the sync cursor. + kvCursorKey = "cursor" +) + +// runInput is the raw, unresolved CLI input. Defaults are applied by +// resolveConfig so RunE stays free of business logic. +type runInput struct { + PollInterval time.Duration + Backfill time.Duration +} + +func runCmd(a *app.App) *cobra.Command { + var in runInput + + cmd := &cobra.Command{ + Use: "run", + Short: "Run the CrowdStrike endpoint block event sync", + Long: `Stream malicious-package and dependency-cooldown block events from SafeDep +endpoint telemetry and log them. + +The events are pulled incrementally by cursor so each run resumes where it +stopped. A future stage will push them to CrowdStrike SIEM; for now they are +logged (use -o json for machine-readable output).`, + RunE: func(cmd *cobra.Command, _ []string) error { + // EndpointManagementService is a control-plane service + // (cloud.safedep.io, OAuth), the same client the `endpoint` commands + // use. The data plane (api.safedep.io, API key) does not serve it. + client, err := a.ControlPlane() + if err != nil { + return err + } + + cfg, err := resolveConfig(in) + if err != nil { + return err + } + + svc := controltowerv1grpc.NewEndpointManagementServiceClient(client.Connection()) + + rep := newReporter(a.Output) + source, sink, err := buildSourceAndSink(a, svc, cfg, rep) + if err != nil { + return err + } + + return newEventService(source, sink, rep).run(cmd.Context()) + }, + } + + cmd.Flags().DurationVar(&in.PollInterval, "poll-interval", 5*time.Minute, "sleep duration between sync cycles") + cmd.Flags().DurationVar(&in.Backfill, "backfill", 0, "first-run window to seed the cursor (e.g. 24h, 168h); 0 starts fresh from now") + + return cmd +} + +// buildSourceAndSink wires the event source and the sink. Stage 1 always uses +// the print (logging) sink; Stage 2 will select the CrowdStrike sink here, the +// only wiring change needed to start pushing. +func buildSourceAndSink(a *app.App, svc controltowerv1grpc.EndpointManagementServiceClient, cfg cmdConfig, rep *reporter) (*eventSource, eventSink, error) { + // Cursor is stored in the profile-scoped KV store so each SafeDep credential + // profile has an independent cursor. Switching --profile switches the cursor. + kv, err := app.ProfileKV[cursorState](a, kvNamespace) + if err != nil { + return nil, nil, fmt.Errorf("run: open cursor store: %w", err) + } + + source := newEventSource(svc, kv, cfg.source.pollInterval, cfg.source.backfillWindow, rep) + return source, newPrintClient(rep), nil +} + +// resolveConfig collapses CLI flags into a runtime config, applying defaults and +// rejecting invalid values fast. +func resolveConfig(in runInput) (cmdConfig, error) { + // time.After(<= 0) fires immediately, turning the loop into a tight hammer on + // the SafeDep API with no backoff. Refuse rather than silently DoS upstream. + if in.PollInterval <= 0 { + return cmdConfig{}, fmt.Errorf("run: --poll-interval must be positive, got %s", in.PollInterval) + } + if in.Backfill < 0 { + return cmdConfig{}, fmt.Errorf("run: --backfill must be >= 0, got %s", in.Backfill) + } + + return cmdConfig{source: sourceConfig{ + pollInterval: in.PollInterval, + backfillWindow: in.Backfill, + }}, nil +} diff --git a/internal/cmd/integration/crowdstrike/service.go b/internal/cmd/integration/crowdstrike/service.go new file mode 100644 index 0000000..fdbf156 --- /dev/null +++ b/internal/cmd/integration/crowdstrike/service.go @@ -0,0 +1,37 @@ +package crowdstrike + +import "context" + +// eventService bridges the event source to an eventSink: validate the sink once, +// then route each event the source delivers. The sink is a port (client.go), so +// Stage 2's CrowdStrike push and Stage 1's logging share this file unchanged, +// differing only in which sink is wired in. +type eventService struct { + source *eventSource + sink eventSink + rep *reporter +} + +func newEventService(source *eventSource, sink eventSink, rep *reporter) *eventService { + return &eventService{source: source, sink: sink, rep: rep} +} + +// run validates the sink once, then blocks in the source until ctx is cancelled. +func (s *eventService) run(ctx context.Context) error { + if err := s.sink.validate(ctx); err != nil { + return err + } + + return s.source.subscribe(ctx, func(ev *packageGuardEvent) error { + return s.handleEvent(ctx, ev) + }) +} + +// handleEvent routes one event to the sink best-effort: a send error is logged, +// never fatal, so one bad event does not stop the stream or strand the cursor. +func (s *eventService) handleEvent(ctx context.Context, ev *packageGuardEvent) error { + if err := s.sink.send(ctx, ev); err != nil { + s.rep.logWarn("Send failed for event %s: %v", ev.GetEventId(), err) + } + return nil +} diff --git a/internal/cmd/integration/crowdstrike/source.go b/internal/cmd/integration/crowdstrike/source.go new file mode 100644 index 0000000..8fd3417 --- /dev/null +++ b/internal/cmd/integration/crowdstrike/source.go @@ -0,0 +1,264 @@ +package crowdstrike + +import ( + "context" + "errors" + "fmt" + "time" + + controltowerv1grpc "buf.build/gen/go/safedep/api/grpc/go/safedep/services/controltower/v1/controltowerv1grpc" + ctmsgv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" + ctsvcv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/cli/internal/storage" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// packageGuardEvent aliases the deeply-nested generated event type so the rest +// of the package reads cleanly. +type packageGuardEvent = ctsvcv1.ListEndpointPackageGuardEventsResponse_PackageGuardEvent + +// recordHandler handles one event. A non-nil error stops delivery and surfaces +// from subscribe. +type recordHandler func(*packageGuardEvent) error + +// callbackError marks a handler error so the source can tell it apart from a +// transient infra error: the former surfaces, the latter is retried. Wrapped +// inside the source, unwrapped at the subscribe boundary. +type callbackError struct{ err error } + +func (e *callbackError) Error() string { return e.err.Error() } +func (e *callbackError) Unwrap() error { return e.err } + +func isCallbackError(err error) bool { + var cb *callbackError + return errors.As(err, &cb) +} + +// errNotEntitled marks the server rejecting the tenant for lack of the endpoint +// management add-on. Not transient, so subscribe stops instead of retrying. +var errNotEntitled = errors.New( + "the SafeDep CrowdStrike endpoint integration requires the endpoint management add-on, " + + "which is not enabled for this tenant. See the pricing page: https://safedep.io/pricing") + +// errAuth marks the control plane rejecting the SafeDep credential (missing or +// expired OAuth session). Not transient, so subscribe stops with how to +// authenticate. +var errAuth = errors.New( + "SafeDep authentication failed: this integration reads the control plane (cloud.safedep.io), " + + "so log in with 'safedep auth login' (OAuth device login) and retry") + +// isAuthError reports whether the server rejected our SafeDep credential. The +// control plane returns Unauthenticated for a missing or expired token. +func isAuthError(err error) bool { + return status.Code(err) == codes.Unauthenticated +} + +// eventPageSize matches the server's per-page cap. +const eventPageSize = 100 + +// eventSource pulls endpoint package-guard block events on an interval, +// resuming from a profile-scoped cursor (see store.go). Events are immutable and +// append-only, so a Timestamp watermark never skips an event before it is seen. +type eventSource struct { + svc controltowerv1grpc.EndpointManagementServiceClient + cursor *cursorStore + pollInterval time.Duration + backfillWindow time.Duration + rep *reporter +} + +func newEventSource(svc controltowerv1grpc.EndpointManagementServiceClient, kv *storage.KV[cursorState], pollInterval, backfillWindow time.Duration, rep *reporter) *eventSource { + return &eventSource{ + svc: svc, + cursor: newCursorStore(kv), + pollInterval: pollInterval, + backfillWindow: backfillWindow, + rep: rep, + } +} + +// subscribe drives the sync loop until ctx is cancelled. A bad cycle is logged +// and retried, never fatal, except an add-on/auth failure or a handler error. +func (s *eventSource) subscribe(ctx context.Context, onRecord recordHandler) error { + s.rep.logInfo("Starting CrowdStrike endpoint block event sync with SafeDep") + s.logStartMode(ctx) + + for { + err := s.syncOnce(ctx, onRecord) + switch { + case err == nil: + s.rep.logInfo("Sync cycle complete at %s, next in %s", time.Now().UTC().Format(time.RFC3339), s.pollInterval) + case ctx.Err() != nil: + return nil + case isCallbackError(err): + return errors.Unwrap(err) + case errors.Is(err, errNotEntitled), errors.Is(err, errAuth): + return err + default: + s.rep.logWarn("Sync cycle error: %v", err) + } + + select { + case <-ctx.Done(): + return nil + case <-time.After(s.pollInterval): + } + } +} + +// logStartMode tells the operator, once at startup, whether we resume or start +// fresh. A load error here is ignored: syncOnce surfaces the real one. +func (s *eventSource) logStartMode(ctx context.Context) { + state, err := s.cursor.load(ctx) + if err != nil { + return + } + switch { + case !state.LastSeenAt.IsZero(): + s.rep.logInfo("Resuming from saved cursor (last event %s)", state.LastSeenAt.UTC().Format(time.RFC3339)) + case s.backfillWindow > 0: + s.rep.logInfo("No saved cursor: backfilling events from the last %s", s.backfillWindow) + default: + s.rep.logInfo("No saved cursor: starting fresh from now") + } +} + +// syncOnce pages through every block event newer than the cursor, delivering +// each and advancing the cursor. +// +// TimeRange.Start is the watermark, fixed for the whole drain (only the page +// token moves). First run uses now - backfill; it is never omitted. Ascending +// order lets an interrupted drain resume without gaps. Events whose id was at +// exactly the cursor timestamp are skipped, so an inclusive Start never +// re-delivers the boundary. +func (s *eventSource) syncOnce(ctx context.Context, onRecord recordHandler) error { + state, err := s.cursor.load(ctx) + if err != nil { + return fmt.Errorf("crowdstrike: load cursor: %w", err) + } + + since := state.LastSeenAt + if since.IsZero() { + since = time.Now().UTC().Add(-s.backfillWindow) + } + + // The API requires an end and rejects start >= end. Cap the window at now, + // fixed for the whole drain (only the page token moves). The guard covers a + // fresh start where since == now: end is nudged past it. Fetching up to a + // moment in the future is harmless, no events exist there. + end := time.Now().UTC() + if !end.After(since) { + end = since.Add(time.Second) + } + + skip := make(map[string]struct{}, len(state.LastSeenEventIDs)) + for _, id := range state.LastSeenEventIDs { + skip[id] = struct{}{} + } + + // Watermark starts at the cursor so it only moves forward. newMaxIDs collects + // the ids at newMax, to become the next boundary skip set. + newMax := state.LastSeenAt + var newMaxIDs []string + + var pageToken string + for { + resp, err := s.svc.ListEndpointPackageGuardEvents(ctx, buildRequest(since, end, pageToken)) + if err != nil { + switch { + case status.Code(err) == codes.PermissionDenied: + return errNotEntitled + case isAuthError(err): + return errAuth + default: + return fmt.Errorf("crowdstrike: list events: %w", err) + } + } + + for _, ev := range resp.GetEvents() { + id := ev.GetEventId() + if _, dup := skip[id]; dup { + continue // boundary dedup: already processed at the cursor timestamp + } + if err := onRecord(ev); err != nil { + return &callbackError{err: err} + } + + ts := ev.GetTimestamp() + if ts == nil { + continue + } + switch t := ts.AsTime(); { + case t.After(newMax): + newMax = t + newMaxIDs = []string{id} + case t.Equal(newMax): + newMaxIDs = append(newMaxIDs, id) + } + } + + nextToken := resp.GetPagination().GetNextPageToken() + if nextToken == "" { + break + } + pageToken = nextToken + } + + // Persist once, after the full drain. Saving per page would strand events + // that share a timestamp across a page boundary. + switch { + case newMax.After(state.LastSeenAt): + if err := s.cursor.save(ctx, cursorState{LastSeenAt: newMax, LastSeenEventIDs: newMaxIDs}); err != nil { + return fmt.Errorf("crowdstrike: save cursor: %w", err) + } + case newMax.Equal(state.LastSeenAt) && len(newMaxIDs) > 0: + // New events at the same boundary timestamp: extend the skip set so they + // are not re-delivered next cycle. Rare with strictly increasing event + // times. ponytail: bounded by real events at one timestamp. + merged := append(append([]string{}, state.LastSeenEventIDs...), newMaxIDs...) + if err := s.cursor.save(ctx, cursorState{LastSeenAt: newMax, LastSeenEventIDs: merged}); err != nil { + return fmt.Errorf("crowdstrike: save cursor: %w", err) + } + case state.LastSeenAt.IsZero(): + // First run that saw nothing new: anchor since so the next cycle resumes + // from it instead of sliding forward by pollInterval each cycle. + if err := s.cursor.save(ctx, cursorState{LastSeenAt: since}); err != nil { + return fmt.Errorf("crowdstrike: save cursor: %w", err) + } + } + + return nil +} + +// buildRequest constructs one page request: the two block actions, ascending by +// event time, over the [since, end] window. +func buildRequest(since, end time.Time, pageToken string) *ctsvcv1.ListEndpointPackageGuardEventsRequest { + req := &ctsvcv1.ListEndpointPackageGuardEventsRequest{} + + tr := &ctsvcv1.EndpointManagementTimeRange{} + tr.SetStart(timestamppb.New(since)) + tr.SetEnd(timestamppb.New(end)) + req.SetTimeRange(tr) + + pmg := &ctsvcv1.ListEndpointPackageGuardEventsRequest_Filter_PmgFilter{} + pmg.SetEventTypes([]ctmsgv1.PmgEventType{ctmsgv1.PmgEventType_PMG_EVENT_TYPE_PACKAGE_DECISION}) + pmg.SetPackageActions([]ctmsgv1.PmgPackageAction{ + ctmsgv1.PmgPackageAction_PMG_PACKAGE_ACTION_BLOCKED, + ctmsgv1.PmgPackageAction_PMG_PACKAGE_ACTION_COOLDOWN_BLOCKED, + }) + filter := &ctsvcv1.ListEndpointPackageGuardEventsRequest_Filter{} + filter.SetPmg(pmg) + req.SetFilter(filter) + + pagination := &ctmsgv1.PaginationRequest{} + pagination.SetPageSize(eventPageSize) + pagination.SetSortOrder(ctmsgv1.PaginationRequest_SORT_ORDER_ASCENDING) + if pageToken != "" { + pagination.SetPageToken(pageToken) + } + req.SetPagination(pagination) + + return req +} diff --git a/internal/cmd/integration/crowdstrike/source_test.go b/internal/cmd/integration/crowdstrike/source_test.go new file mode 100644 index 0000000..521be2c --- /dev/null +++ b/internal/cmd/integration/crowdstrike/source_test.go @@ -0,0 +1,399 @@ +package crowdstrike + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + controltowerv1grpc "buf.build/gen/go/safedep/api/grpc/go/safedep/services/controltower/v1/controltowerv1grpc" + ctmsgv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + ctsvcv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/cli/internal/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// newTestKV opens a temp SQLite-backed KV used by cursorStore, isolated per test. +func newTestKV(t *testing.T) *storage.KV[cursorState] { + t.Helper() + s, err := storage.Open(context.Background(), storage.Options{ + Backend: storage.BackendSqlite, + Path: filepath.Join(t.TempDir(), "test.db"), + }) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + kv, err := storage.NewProfileKV[cursorState](s, "default", "test-cursor") + require.NoError(t, err) + return kv +} + +// fakeEndpointClient is a hand-rolled stand-in for the gRPC client. Tests queue +// per-call responses and inspect the captured requests after syncOnce returns. +type fakeEndpointClient struct { + queue []fakeEventsResp + captured []*ctsvcv1.ListEndpointPackageGuardEventsRequest +} + +type fakeEventsResp struct { + resp *ctsvcv1.ListEndpointPackageGuardEventsResponse + err error +} + +var _ controltowerv1grpc.EndpointManagementServiceClient = (*fakeEndpointClient)(nil) + +func (f *fakeEndpointClient) ListEndpointPackageGuardEvents(_ context.Context, in *ctsvcv1.ListEndpointPackageGuardEventsRequest, _ ...grpc.CallOption) (*ctsvcv1.ListEndpointPackageGuardEventsResponse, error) { + f.captured = append(f.captured, in) + if len(f.queue) == 0 { + return nil, errors.New("fake: no more queued responses") + } + r := f.queue[0] + f.queue = f.queue[1:] + return r.resp, r.err +} + +// The remaining methods exist only so the fake satisfies the interface. +func (f *fakeEndpointClient) GetEndpointsStats(_ context.Context, _ *ctsvcv1.GetEndpointsStatsRequest, _ ...grpc.CallOption) (*ctsvcv1.GetEndpointsStatsResponse, error) { + return nil, errors.New("not implemented in fake") +} + +func (f *fakeEndpointClient) ListEndpoints(_ context.Context, _ *ctsvcv1.ListEndpointsRequest, _ ...grpc.CallOption) (*ctsvcv1.ListEndpointsResponse, error) { + return nil, errors.New("not implemented in fake") +} + +func (f *fakeEndpointClient) GetEndpoint(_ context.Context, _ *ctsvcv1.GetEndpointRequest, _ ...grpc.CallOption) (*ctsvcv1.GetEndpointResponse, error) { + return nil, errors.New("not implemented in fake") +} + +func (f *fakeEndpointClient) ListEndpointInventoryEvents(_ context.Context, _ *ctsvcv1.ListEndpointInventoryEventsRequest, _ ...grpc.CallOption) (*ctsvcv1.ListEndpointInventoryEventsResponse, error) { + return nil, errors.New("not implemented in fake") +} + +func (f *fakeEndpointClient) ListEndpointAdvisorEvents(_ context.Context, _ *ctsvcv1.ListEndpointAdvisorEventsRequest, _ ...grpc.CallOption) (*ctsvcv1.ListEndpointAdvisorEventsResponse, error) { + return nil, errors.New("not implemented in fake") +} + +func (f *fakeEndpointClient) GetEndpointInventorySnapshot(_ context.Context, _ *ctsvcv1.GetEndpointInventorySnapshotRequest, _ ...grpc.CallOption) (*ctsvcv1.GetEndpointInventorySnapshotResponse, error) { + return nil, errors.New("not implemented in fake") +} + +// eventSpec describes one event in a queued page. +type eventSpec struct { + id string + name string + version string + action ctmsgv1.PmgPackageAction + isMalware bool + tsOffset time.Duration // timestamp = base + tsOffset + skipTS bool + cooldownDays uint32 +} + +func blocked(id, name, version string, tsOffset time.Duration) eventSpec { + return eventSpec{id: id, name: name, version: version, action: ctmsgv1.PmgPackageAction_PMG_PACKAGE_ACTION_BLOCKED, isMalware: true, tsOffset: tsOffset} +} + +func newTestEvent(base time.Time, s eventSpec) *packageGuardEvent { + pkg := &packagev1.Package{} + pkg.SetName(s.name) + pkg.SetEcosystem(packagev1.Ecosystem_ECOSYSTEM_NPM) + + pv := &packagev1.PackageVersion{} + pv.SetPackage(pkg) + pv.SetVersion(s.version) + + dec := &ctmsgv1.PmgPackageDecision{} + dec.SetPackageVersion(pv) + dec.SetAction(s.action) + dec.SetIsMalware(s.isMalware) + if s.action == ctmsgv1.PmgPackageAction_PMG_PACKAGE_ACTION_COOLDOWN_BLOCKED { + cd := &ctmsgv1.PmgDependencyCooldown{} + cd.SetDaysRemaining(s.cooldownDays) + dec.SetCooldown(cd) + } + + pe := &ctmsgv1.PmgEvent{} + pe.SetEventType(ctmsgv1.PmgEventType_PMG_EVENT_TYPE_PACKAGE_DECISION) + pe.SetPackageDecision(dec) + + ev := &packageGuardEvent{} + ev.SetEventId(s.id) + ev.SetEndpointName("host-1") + ev.SetToolName("pmg") + ev.SetPmgEvent(pe) + if !s.skipTS { + ev.SetTimestamp(timestamppb.New(base.Add(s.tsOffset))) + } + return ev +} + +func makeEventsPage(base time.Time, nextToken string, specs ...eventSpec) *ctsvcv1.ListEndpointPackageGuardEventsResponse { + resp := &ctsvcv1.ListEndpointPackageGuardEventsResponse{} + events := make([]*packageGuardEvent, 0, len(specs)) + for _, s := range specs { + events = append(events, newTestEvent(base, s)) + } + resp.SetEvents(events) + + pag := &ctmsgv1.PaginationResponse{} + pag.SetNextPageToken(nextToken) + resp.SetPagination(pag) + return resp +} + +func drainHandler() (recordHandler, *[]string) { + got := &[]string{} + return func(ev *packageGuardEvent) error { + *got = append(*got, ev.GetEventId()) + return nil + }, got +} + +func startTime(req *ctsvcv1.ListEndpointPackageGuardEventsRequest) time.Time { + t := req.GetTimeRange().GetStart() + if t == nil { + return time.Time{} + } + return t.AsTime() +} + +func TestSyncOnce_FirstRun_StartIsNowWithZeroBackfill(t *testing.T) { + fake := &fakeEndpointClient{queue: []fakeEventsResp{{resp: makeEventsPage(time.Now().UTC(), "")}}} + src := newEventSource(fake, newTestKV(t), time.Minute, 0, newReporter(nil)) + + before := time.Now().UTC() + handler, _ := drainHandler() + require.NoError(t, src.syncOnce(context.Background(), handler)) + after := time.Now().UTC() + + require.Len(t, fake.captured, 1) + start := startTime(fake.captured[0]) + require.False(t, start.IsZero(), "start must never be omitted; that would pull full history") + assert.False(t, start.Before(before.Add(-time.Second)), "fresh start uses now") + assert.False(t, start.After(after.Add(time.Second)), "fresh start uses now") +} + +func TestSyncOnce_FirstRun_BackfillSeedsStart(t *testing.T) { + backfill := 24 * time.Hour + fake := &fakeEndpointClient{queue: []fakeEventsResp{{resp: makeEventsPage(time.Now().UTC(), "")}}} + src := newEventSource(fake, newTestKV(t), time.Minute, backfill, newReporter(nil)) + + handler, _ := drainHandler() + require.NoError(t, src.syncOnce(context.Background(), handler)) + + require.Len(t, fake.captured, 1) + start := startTime(fake.captured[0]) + want := time.Now().UTC().Add(-backfill) + delta := start.Sub(want) + if delta < 0 { + delta = -delta + } + assert.Less(t, delta, 5*time.Second, "start must be ~ now - backfill; got %v want ~%v", start, want) +} + +func TestSyncOnce_RequestShape_BlockActionsAscendingPaged(t *testing.T) { + fake := &fakeEndpointClient{queue: []fakeEventsResp{{resp: makeEventsPage(time.Now().UTC(), "")}}} + src := newEventSource(fake, newTestKV(t), time.Minute, 0, newReporter(nil)) + + handler, _ := drainHandler() + require.NoError(t, src.syncOnce(context.Background(), handler)) + + require.Len(t, fake.captured, 1) + req := fake.captured[0] + pmg := req.GetFilter().GetPmg() + assert.Equal(t, []ctmsgv1.PmgEventType{ctmsgv1.PmgEventType_PMG_EVENT_TYPE_PACKAGE_DECISION}, pmg.GetEventTypes()) + assert.Equal(t, []ctmsgv1.PmgPackageAction{ + ctmsgv1.PmgPackageAction_PMG_PACKAGE_ACTION_BLOCKED, + ctmsgv1.PmgPackageAction_PMG_PACKAGE_ACTION_COOLDOWN_BLOCKED, + }, pmg.GetPackageActions(), "only the two block actions are requested server-side") + assert.Equal(t, uint32(eventPageSize), req.GetPagination().GetPageSize()) + assert.Equal(t, ctmsgv1.PaginationRequest_SORT_ORDER_ASCENDING, req.GetPagination().GetSortOrder(), + "ascending order is required for reliable incremental sync") + assert.Empty(t, req.GetPagination().GetPageToken(), "first page has no token") + + // The API requires end and rejects start >= end. + require.NotNil(t, req.GetTimeRange().GetEnd(), "end is required by the API") + assert.True(t, req.GetTimeRange().GetEnd().AsTime().After(startTime(req)), "start must be before end") +} + +func TestSyncOnce_DeliversEvents_AdvancesCursorToMaxTimestamp(t *testing.T) { + base := time.Now().UTC().Add(-30 * time.Minute).Truncate(time.Second) + fake := &fakeEndpointClient{queue: []fakeEventsResp{{resp: makeEventsPage(base, "", + blocked("a", "pkg-a", "1.0.0", 0), + blocked("b", "pkg-b", "2.0.0", time.Second), + blocked("c", "pkg-c", "3.0.0", 2*time.Second), + )}}} + + store := newCursorStore(newTestKV(t)) + src := &eventSource{svc: fake, cursor: store, pollInterval: time.Minute, rep: newReporter(nil)} + + handler, got := drainHandler() + require.NoError(t, src.syncOnce(context.Background(), handler)) + + assert.Equal(t, []string{"a", "b", "c"}, *got, "all events delivered in order") + + saved, err := store.load(context.Background()) + require.NoError(t, err) + want := base.Add(2 * time.Second) + assert.True(t, saved.LastSeenAt.Equal(want), "cursor advanced to max timestamp; got %v want %v", saved.LastSeenAt, want) + assert.Equal(t, []string{"c"}, saved.LastSeenEventIDs, "boundary ids are the events at the max timestamp") +} + +func TestSyncOnce_MultiPage_StartConstantTokensAdvance(t *testing.T) { + kv := newTestKV(t) + cursor := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Microsecond) + store := newCursorStore(kv) + require.NoError(t, store.save(context.Background(), cursorState{LastSeenAt: cursor})) + + base := time.Now().UTC().Add(-30 * time.Minute).Truncate(time.Second) + fake := &fakeEndpointClient{queue: []fakeEventsResp{ + {resp: makeEventsPage(base, "tok1", blocked("a", "pkg-a", "1.0", 0))}, + {resp: makeEventsPage(base, "tok2", blocked("b", "pkg-b", "2.0", time.Hour))}, + {resp: makeEventsPage(base, "", blocked("c", "pkg-c", "3.0", 2*time.Hour))}, + }} + + src := &eventSource{svc: fake, cursor: store, pollInterval: time.Minute, rep: newReporter(nil)} + + handler, got := drainHandler() + require.NoError(t, src.syncOnce(context.Background(), handler)) + + assert.Equal(t, []string{"a", "b", "c"}, *got, "events delivered across all 3 pages") + require.Len(t, fake.captured, 3, "exactly 3 page requests were made") + + for i, req := range fake.captured { + assert.True(t, startTime(req).Equal(cursor), + "page %d start drifted: got %v want %v", i, startTime(req), cursor) + } + assert.Empty(t, fake.captured[0].GetPagination().GetPageToken()) + assert.Equal(t, "tok1", fake.captured[1].GetPagination().GetPageToken()) + assert.Equal(t, "tok2", fake.captured[2].GetPagination().GetPageToken()) +} + +func TestSyncOnce_BoundaryDedup_SkipsSeenIDs(t *testing.T) { + base := time.Now().UTC().Add(-30 * time.Minute).Truncate(time.Second) + + store := newCursorStore(newTestKV(t)) + // Cursor sits at base with event "a" already processed at that timestamp. + require.NoError(t, store.save(context.Background(), cursorState{LastSeenAt: base, LastSeenEventIDs: []string{"a"}})) + + // The server re-delivers "a" at base (inclusive Start) plus a new "b" at base+1s. + fake := &fakeEndpointClient{queue: []fakeEventsResp{{resp: makeEventsPage(base, "", + blocked("a", "pkg-a", "1.0.0", 0), + blocked("b", "pkg-b", "2.0.0", time.Second), + )}}} + src := &eventSource{svc: fake, cursor: store, pollInterval: time.Minute, rep: newReporter(nil)} + + handler, got := drainHandler() + require.NoError(t, src.syncOnce(context.Background(), handler)) + + assert.Equal(t, []string{"b"}, *got, "the already-seen boundary event is skipped, not re-delivered") + + saved, err := store.load(context.Background()) + require.NoError(t, err) + assert.True(t, saved.LastSeenAt.Equal(base.Add(time.Second)), "cursor advanced past the new event") + assert.Equal(t, []string{"b"}, saved.LastSeenEventIDs) +} + +func TestSyncOnce_FirstRunNoEvents_AnchorsCursor(t *testing.T) { + fake := &fakeEndpointClient{queue: []fakeEventsResp{ + {resp: makeEventsPage(time.Now().UTC(), "")}, + {resp: makeEventsPage(time.Now().UTC(), "")}, + }} + store := newCursorStore(newTestKV(t)) + src := &eventSource{svc: fake, cursor: store, pollInterval: time.Minute, backfillWindow: 24 * time.Hour, rep: newReporter(nil)} + + handler, _ := drainHandler() + require.NoError(t, src.syncOnce(context.Background(), handler)) + + saved, err := store.load(context.Background()) + require.NoError(t, err) + require.False(t, saved.LastSeenAt.IsZero(), "first-run anchor must persist even with zero events") + anchor := saved.LastSeenAt + + require.NoError(t, src.syncOnce(context.Background(), handler)) + require.Len(t, fake.captured, 2) + assert.True(t, startTime(fake.captured[1]).Equal(anchor), + "start must stay anchored across empty cycles; got %v want %v", startTime(fake.captured[1]), anchor) +} + +func TestSyncOnce_PermissionDenied_ReturnsNotEntitled(t *testing.T) { + entErr := status.Error(codes.PermissionDenied, "required entitlement is not available for tenant") + fake := &fakeEndpointClient{queue: []fakeEventsResp{{err: entErr}}} + src := newEventSource(fake, newTestKV(t), time.Minute, 0, newReporter(nil)) + + handler, _ := drainHandler() + err := src.syncOnce(context.Background(), handler) + require.ErrorIs(t, err, errNotEntitled, "PermissionDenied maps to the friendly add-on error") +} + +func TestSyncOnce_AuthFailure_ReturnsAuthError(t *testing.T) { + fake := &fakeEndpointClient{queue: []fakeEventsResp{{err: status.Error(codes.Unauthenticated, "missing or expired credentials")}}} + src := newEventSource(fake, newTestKV(t), time.Minute, 0, newReporter(nil)) + + handler, _ := drainHandler() + err := src.syncOnce(context.Background(), handler) + require.ErrorIs(t, err, errAuth, "an Unauthenticated status maps to the friendly auth error") +} + +func TestSyncOnce_CallbackError_StopsAndWraps(t *testing.T) { + base := time.Now().UTC().Add(-30 * time.Minute).Truncate(time.Second) + fake := &fakeEndpointClient{queue: []fakeEventsResp{{resp: makeEventsPage(base, "", + blocked("a", "pkg-a", "1.0", 0), + blocked("b", "pkg-b", "2.0", time.Second), + )}}} + + src := &eventSource{svc: fake, cursor: newCursorStore(newTestKV(t)), pollInterval: time.Minute, rep: newReporter(nil)} + + stop := errors.New("callback bailed") + delivered := 0 + err := src.syncOnce(context.Background(), func(*packageGuardEvent) error { + delivered++ + return stop + }) + + require.Error(t, err) + assert.True(t, isCallbackError(err), "handler error must be wrapped as a callbackError") + require.ErrorIs(t, err, stop) + assert.Equal(t, 1, delivered, "callback error stops delivery immediately") +} + +func TestSubscribe_NotEntitled_StopsImmediately(t *testing.T) { + entErr := status.Error(codes.PermissionDenied, "required entitlement is not available for tenant") + fake := &fakeEndpointClient{queue: []fakeEventsResp{{err: entErr}}} + // Long interval so a wrongly-retrying implementation would hang the test. + src := newEventSource(fake, newTestKV(t), time.Hour, 0, newReporter(nil)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + handler, _ := drainHandler() + err := src.subscribe(ctx, handler) + + require.ErrorIs(t, err, errNotEntitled, "the add-on error must surface from subscribe") + require.NoError(t, ctx.Err(), "subscribe must stop immediately, not wait for the interval") + assert.Len(t, fake.captured, 1, "must not retry after an entitlement failure") +} + +func TestSubscribe_InfraError_LoggedAndRetried(t *testing.T) { + fake := &fakeEndpointClient{queue: []fakeEventsResp{ + {err: errors.New("grpc unavailable")}, + {resp: makeEventsPage(time.Now().UTC(), "")}, + }} + src := newEventSource(fake, newTestKV(t), 10*time.Millisecond, 0, newReporter(nil)) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + handler, _ := drainHandler() + err := src.subscribe(ctx, handler) + + require.NoError(t, err, "infra errors must NOT surface; they are logged and retried") + assert.GreaterOrEqual(t, len(fake.captured), 2, "loop must continue after the first cycle's infra error") +} diff --git a/internal/cmd/integration/crowdstrike/store.go b/internal/cmd/integration/crowdstrike/store.go new file mode 100644 index 0000000..559c261 --- /dev/null +++ b/internal/cmd/integration/crowdstrike/store.go @@ -0,0 +1,75 @@ +package crowdstrike + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/safedep/cli/internal/storage" + drytui "github.com/safedep/dry/tui" +) + +// cursorStore wraps the typed KV store so the source does not need to know +// about KV internals. It persists the watermark of the last processed event so +// the sync can resume where it left off across restarts. +// +// The underlying KV store is profile-scoped (obtained via app.ProfileKV), so +// each SafeDep credential profile has an independent cursor. Switching +// --profile automatically switches the cursor. +type cursorStore struct { + kv *storage.KV[cursorState] +} + +// cursorState is the value stored per key. Endpoint block events are immutable +// and append-only, so a Timestamp watermark is the natural cursor. LastSeenEventIDs +// holds the ids of every event at exactly LastSeenAt: the API does not document +// whether TimeRange.Start is inclusive, so those ids are skipped on the next +// cycle to avoid re-delivering the boundary. +type cursorState struct { + LastSeenAt time.Time `json:"last_seen_at"` + LastSeenEventIDs []string `json:"last_seen_event_ids"` +} + +func newCursorStore(kv *storage.KV[cursorState]) *cursorStore { + return &cursorStore{kv: kv} +} + +// load returns the persisted cursor, or the zero value on first run. +// +// Only JSON decode failures (storage.ErrKVDecode) are treated as an +// incompatible format: the stale key is deleted so the next write starts clean. +// DB-level errors (locked file, permission denied) are propagated so the caller +// can retry on the next cycle rather than silently destroying a valid cursor. +func (s *cursorStore) load(ctx context.Context) (cursorState, error) { + t, err := s.kv.Get(ctx, kvCursorKey) + if errors.Is(err, storage.ErrNotFound) { + return cursorState{}, nil + } + if errors.Is(err, storage.ErrKVDecode) { + drytui.Warning("Cursor value incompatible, resetting to beginning: %v", err) + _ = s.kv.Delete(ctx, kvCursorKey) + return cursorState{}, nil + } + if err != nil { + return cursorState{}, fmt.Errorf("cursor: get: %w", err) + } + return t, nil +} + +// save persists the cursor. KV Put is an upsert. +func (s *cursorStore) save(ctx context.Context, state cursorState) error { + if err := s.kv.Put(ctx, kvCursorKey, state); err != nil { + return fmt.Errorf("cursor: put: %w", err) + } + return nil +} + +// remove deletes the stored cursor so the next run starts fresh. Deleting a +// missing key is a no-op. +func (s *cursorStore) remove(ctx context.Context) error { + if err := s.kv.Delete(ctx, kvCursorKey); err != nil { + return fmt.Errorf("cursor: delete: %w", err) + } + return nil +} diff --git a/internal/cmd/integration/crowdstrike/types.go b/internal/cmd/integration/crowdstrike/types.go new file mode 100644 index 0000000..badc302 --- /dev/null +++ b/internal/cmd/integration/crowdstrike/types.go @@ -0,0 +1,20 @@ +package crowdstrike + +import "time" + +// cmdConfig is the resolved runtime state passed between the package's +// components. Constructed once by resolveConfig from CLI flags. It has no +// schema and is never read from or written to disk. +type cmdConfig struct { + source sourceConfig +} + +// sourceConfig groups the SafeDep-side sync parameters. +type sourceConfig struct { + // pollInterval is the sleep duration between sync cycles. + pollInterval time.Duration + + // backfillWindow seeds the first-run cursor: since = now - backfillWindow. + // 0 (default) starts fresh from now. + backfillWindow time.Duration +}