diff --git a/CHANGELOG.md b/CHANGELOG.md index b25ec138..b2c7aa83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. +## [Unreleased] + +### Added + +- **Server-driven scan cadence (run gating)**: on every invocation the agent asks the backend's new `run-directive` endpoint whether a full scan is due and exits quietly when it isn't, with no run-status row, no phases, and a single log line. The scan frequency lives in the StepSecurity dashboard (per tenant, minutes granularity, with a temporary override that auto-reverts at a set time and a per-device "re-scan now" request), so fleets deployed via an external MDM (for example JAMF's hourly cadence) get their real cadence from the backend with no MDM scheduling changes. Run gating is controlled entirely from the backend: the agent always makes the check-in call, and the backend decides. It is gated by a per-environment backend flag so it can be enabled for one environment at a time; while the flag is off the backend answers "scan" every time and the agent behaves exactly as before. Once enabled, gating applies to every device (a 4-hour default when a tenant has not set its own cadence). Any check-in failure fails open to a scan (with a cached-interval fallback so offline machines don't scan every wakeup), and an invocation that lands while another scan is running backs off quietly instead of reporting a failed run. Bypass for debugging: `--force-scan` / `STEPSEC_FORCE_SCAN=1`; per-device kill switch: `STEPSEC_DISABLE_RUN_GATE=1`. + ## [1.14.0] - 2026-07-17 ### Added diff --git a/README.md b/README.md index 7bcc9b2d..620e7a4d 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ stepsecurity-dev-machine-guard [COMMAND] [OPTIONS] | `--include-bundled-plugins` | Include bundled/platform IDE plugins in output | | `--log-level=LEVEL` | Log level: `error` \| `warn` \| `info` \| `debug` | | `--verbose` | Shortcut for `--log-level=debug` | +| `--force-scan` | Bypass the server-driven run gate and scan now (enterprise) | | `--color=WHEN` | Color mode: `auto` \| `always` \| `never` (default: `auto`) | | `-v`, `--version` | Show version | | `-h`, `--help` | Show help | @@ -214,6 +215,10 @@ count=$(./stepsecurity-dev-machine-guard --json | jq '.summary.mcp_configs_count # Enterprise: one-time telemetry upload ./stepsecurity-dev-machine-guard send-telemetry +# Enterprise: scan even when the dashboard-managed scan cadence says "not due" +# (equivalent env var: STEPSEC_FORCE_SCAN=1; disable gating: STEPSEC_DISABLE_RUN_GATE=1) +./stepsecurity-dev-machine-guard send-telemetry --force-scan + # Enterprise: remove scheduled scanning ./stepsecurity-dev-machine-guard uninstall ``` diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 93d4f866..acbcf18c 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -29,6 +29,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/paths" "github.com/step-security/dev-machine-guard/internal/progress" "github.com/step-security/dev-machine-guard/internal/progress/filelog" + "github.com/step-security/dev-machine-guard/internal/rungate" "github.com/step-security/dev-machine-guard/internal/scan" "github.com/step-security/dev-machine-guard/internal/schtasks" "github.com/step-security/dev-machine-guard/internal/systemd" @@ -261,6 +262,14 @@ func main() { log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0]) os.Exit(1) } + // Server-driven run gate: exit 0 quietly when the backend says this + // invocation isn't due (or another instance is mid-scan). Sits before + // the watchdog and telemetry.Run so a skipped wakeup posts no beacon, + // creates no run-status row, and also skips the post-run hook + // reconcile + policy enforce below. Bypass: --force-scan. + if gateSkipsRun(exec, log, cfg) { + return + } armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log) telemetryErr := telemetry.Run(exec, log, cfg) // Package-config enforcement runs on every cycle, even one where telemetry @@ -339,10 +348,18 @@ func main() { return } - log.Progress("Sending initial telemetry...") - fmt.Println() - armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log) - telemetryErr := telemetry.Run(exec, log, cfg) + // Run gate on the inline install scan too. A fresh install is + // unregistered (or long stale) so the backend answers "full" and + // nothing changes; the skip only fires when a re-install lands on a + // freshly-scanned device — where skipping the inline scan is exactly + // right. The scheduler setup above already happened either way. + var telemetryErr error + if !gateSkipsRun(exec, log, cfg) { + log.Progress("Sending initial telemetry...") + fmt.Println() + armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log) + telemetryErr = telemetry.Run(exec, log, cfg) + } // On Linux, systemd.Install enabled the timer but did not start it. // Start it now that the inline scan above has released the singleton @@ -489,6 +506,9 @@ func main() { } case config.IsEnterpriseMode(): log.Debug("dispatch: enterprise telemetry (auto-detected)") + if gateSkipsRun(exec, log, cfg) { + return + } armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log) telemetryErr := telemetry.Run(exec, log, cfg) // Package-config enforcement runs on every enterprise cycle — including a @@ -646,6 +666,29 @@ func findLegacyLeftovers(legacy string) []string { // state and reconciles local hook installation to match. Silent no-op // in community mode (enterprise config missing) — the existing scan // path stays unaffected. Failures are logged but never crash main. +// gateSkipsRun consults the server-driven run gate. True means this +// invocation must exit 0 without scanning — the backend says the device isn't +// due yet, or another instance is already mid-scan. One Progress line is the +// skip's entire footprint: no beacon, no run-status row, no phases. Every +// gate failure returns false (fail-open), so this can never suppress a scan +// on error. +func gateSkipsRun(exec executor.Executor, log *progress.Logger, cfg *cli.Config) bool { + res := rungate.Evaluate(context.Background(), exec, log, cfg.ForceScan) + if !res.Skip { + log.Progress("Run gate: proceeding with this run (%s)", res.Reason) + // Carry the decision into telemetry.Run so it echoes a line inside the + // captured execution log (the gate runs before log capture starts). + cfg.GateProceedReason = res.Reason + return false + } + if res.Detail != "" { + log.Progress("Run gate: skipping this run (%s) — %s", res.Reason, res.Detail) + } else { + log.Progress("Run gate: skipping this run (%s)", res.Reason) + } + return true +} + // writeHeartbeat stamps last-run.json with this run's start metadata. Wholly // best-effort: a write failure (read-only home, disabled install dir) is // logged at debug and never affects the run. The invocation method reuses the diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e175ba55..37daf9da 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -46,6 +46,13 @@ type Config struct { YarnRCOnly bool // --yarnrc: run only the yarn config audit (both flavors) and render verbose pretty output SearchDirs []string // defaults to ["$HOME"] + // GateProceedReason is populated at runtime (not a CLI flag) by the run + // gate when it lets a run proceed, so telemetry.Run can echo the gate + // decision into the captured execution log (what "Download logs" shows). + // The gate itself runs before log capture starts, so its live lines never + // reach the downloadable log without this. + GateProceedReason string + // HooksAgent is the --agent value on `hooks install` / `hooks uninstall`; // "" means "every detected agent". HooksAgent string @@ -76,6 +83,14 @@ type Config struct { // STEPSECURITY_OVERRIDE_GATE=1. OverrideGate bool + // ForceScan bypasses the server-driven run gate for this invocation: the + // scan proceeds even when the backend's run-directive says "skip". The + // documented debug/support escape — manual runs are otherwise gated + // exactly like scheduler-fired ones (an MDM-launched run detects as + // one_time too, so invocation method deliberately can't exempt manual + // use). Equivalent env var: STEPSEC_FORCE_SCAN=1. + ForceScan bool + // RulesFile makes the malicious-file detection engine load its RuleSet // from a local JSON file instead of fetching it from the backend. // Dev-only — not advertised in --help. Equivalent env var: @@ -283,6 +298,8 @@ func Parse(args []string) (*Config, error) { cfg.Verbose = true case arg == "--override-gate": cfg.OverrideGate = true + case arg == "--force-scan": + cfg.ForceScan = true case strings.HasPrefix(arg, "--rules-file="): cfg.RulesFile = strings.TrimPrefix(arg, "--rules-file=") case arg == "--rules-file": diff --git a/internal/device/device.go b/internal/device/device.go index 58f76b15..058afb87 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -37,6 +37,23 @@ func Gather(ctx context.Context, exec executor.Executor) model.Device { } } +// SerialNumber resolves just the hardware serial (the device_id used by every +// backend API), skipping the rest of Gather. The run gate calls this before +// the device_info phase — and before any network beacon — so it must stay +// self-contained and tolerate every failure by returning "unknown" (the same +// contract as the per-OS getters). Callers cache the result on disk; only a +// device's first gated invocation pays the probe (ioreg on macOS). +func SerialNumber(ctx context.Context, exec executor.Executor) string { + switch exec.GOOS() { + case model.PlatformWindows: + return getSerialNumberWindows(ctx, exec) + case model.PlatformDarwin: + return getSerialNumber(ctx, exec) + default: // linux and other unix + return getSerialNumberLinux(ctx, exec) + } +} + // getSerialNumberWindows and getOSVersionWindows are implemented in // device_windows.go (native API) and device_other.go (stub). diff --git a/internal/heartbeat/heartbeat.go b/internal/heartbeat/heartbeat.go index 46a9bd65..99ed6ab7 100644 --- a/internal/heartbeat/heartbeat.go +++ b/internal/heartbeat/heartbeat.go @@ -20,9 +20,11 @@ package heartbeat import ( "encoding/json" + "errors" "os" "path/filepath" "runtime" + "sync" "time" "github.com/step-security/dev-machine-guard/internal/buildinfo" @@ -37,9 +39,13 @@ const SchemaVersion = 1 // and diagnostics can reference it without duplicating the literal. const Filename = "last-run.json" -// Record is the last-run.json envelope: a point-in-time stamp that a run -// began. It deliberately carries only start-of-run facts — outcome lives in -// scan-state.json (LastSuccessfulExecutionID) and agent.error.log. +// Record is the last-run.json envelope. The top-level fields are a +// point-in-time stamp that a run began — start-of-run facts only; the scan +// outcome lives in scan-state.json (LastSuccessfulExecutionID) and +// agent.error.log. RunGate is the run gate's small cache, folded into this +// same file rather than a sibling run-gate-state.json: it is the same "last +// run" kind of data written to the same install dir, and is consulted only on +// the offline fallback path. type Record struct { SchemaVersion int `json:"schema_version"` WrittenAt time.Time `json:"written_at"` @@ -48,29 +54,91 @@ type Record struct { Command string `json:"command"` // subcommand that started the run, e.g. "send-telemetry" InvocationMethod string `json:"invocation_method"` // scheduler footprint vs manual; see telemetry.DetectInvocationMethod OS string `json:"os"` + RunGate *RunGate `json:"run_gate,omitempty"` } +// RunGate is the run gate's cached memory: the resolved device id (so skipped +// wakeups never re-probe the serial), the last completed full run (stamped +// after upload), and the last directive's gating fields. Nil until the first +// successful check-in. Everything here is advisory — a missing, corrupt, or +// future-schema file only costs one serial probe and one fail-open run, never +// a wrong skip. Fields mirror the wire directive; see internal/rungate. +type RunGate struct { + DeviceID string `json:"device_id,omitempty"` + LastFullRunAt int64 `json:"last_full_run_at,omitempty"` // unix sec; stamped on upload success + GatingEnabled bool `json:"gating_enabled,omitempty"` + EffectiveIntervalMinutes int `json:"effective_interval_minutes,omitempty"` + DirectiveFetchedAt int64 `json:"directive_fetched_at,omitempty"` // unix sec of the last successful check-in +} + +// mu serializes the read-modify-write writers (Write for the breadcrumb, +// UpdateRunGate for the gate cache). Within a single run they execute +// sequentially on the main goroutine; the mutex is cheap insurance for any +// future concurrent caller. Cross-process safety is atomic-rename +// last-writer-wins, same as internal/state. +var mu sync.Mutex + // Write stamps last-run.json at path with this run's start metadata. An empty // path is a no-op returning nil — callers pass paths.HeartbeatFile(), which is // "" when the install dir is disabled (--install-dir=""), and treat the // heartbeat as off in that case. Best-effort: callers should log a write error // at debug/warn and continue, never fail the run on it. +// +// It read-modify-writes so the RunGate cache survives: the heartbeat is +// stamped at the very top of every run, before the gate reads that cache on +// the offline path, so a blind overwrite here would erase it every wakeup. func Write(path, command, invocationMethod string) error { if path == "" { return nil } - rec := Record{ - SchemaVersion: SchemaVersion, - WrittenAt: time.Now().UTC(), - PID: os.Getpid(), - AgentVersion: buildinfo.Version, - Command: command, - InvocationMethod: invocationMethod, - OS: runtime.GOOS, + mu.Lock() + defer mu.Unlock() + rec := loadForUpdate(path) + rec.SchemaVersion = SchemaVersion + rec.WrittenAt = time.Now().UTC() + rec.PID = os.Getpid() + rec.AgentVersion = buildinfo.Version + rec.Command = command + rec.InvocationMethod = invocationMethod + rec.OS = runtime.GOOS + return writeRecord(path, rec) +} + +// UpdateRunGate applies a read-modify-write to the RunGate cache in the file +// at path, preserving the breadcrumb fields and any RunGate fields the apply +// func leaves untouched. internal/rungate uses it to stamp the last full run +// and record each check-in. Best-effort by contract: on failure the next +// gated invocation simply re-probes / re-checks. Empty path errors so the +// caller can log it. +func UpdateRunGate(path string, apply func(*RunGate)) error { + if path == "" { + return errors.New("heartbeat: no path for run-gate state") + } + mu.Lock() + defer mu.Unlock() + rec := loadForUpdate(path) + rec.SchemaVersion = SchemaVersion + if rec.RunGate == nil { + rec.RunGate = &RunGate{} } + apply(rec.RunGate) return writeRecord(path, rec) } +// loadForUpdate returns the record at path for a read-modify-write, or a zero +// Record when the file is absent, unreadable, corrupt, or a schema mismatch — +// all treated as "start fresh". The breadcrumb must always write, and the gate +// cache is fail-open-safe, so (unlike a versioned config) a newer-schema file +// is overwritten rather than preserved; only a rare downgrade hits that, and +// it costs at most one fail-open run. UNLOCKED — callers hold mu. +func loadForUpdate(path string) Record { + rec, err := Load(path) + if err != nil || rec == nil { + return Record{} + } + return *rec +} + // Load reads last-run.json. A missing file, parse error, or schema mismatch // returns (nil, err) with err nil for the missing/mismatch cases (expected // fall-throughs) so callers can treat a nil record as "no usable heartbeat" diff --git a/internal/heartbeat/heartbeat_test.go b/internal/heartbeat/heartbeat_test.go index ef3405ee..49eb0926 100644 --- a/internal/heartbeat/heartbeat_test.go +++ b/internal/heartbeat/heartbeat_test.go @@ -115,3 +115,39 @@ func TestLoadEmptyPathReturnsNilNil(t *testing.T) { t.Errorf("Load(\"\") = (%+v, %v), want (nil, nil)", rec, err) } } + +func TestUpdateRunGateEmptyPathErrors(t *testing.T) { + if err := UpdateRunGate("", func(*RunGate) {}); err == nil { + t.Error("UpdateRunGate(\"\") must error so the gate caller logs it") + } +} + +// TestWriteAndUpdateRunGatePreserveEachOther is the merge invariant: the +// breadcrumb write keeps the RunGate cache, and the RunGate write keeps the +// breadcrumb (both read-modify-write the one last-run.json). +func TestWriteAndUpdateRunGatePreserveEachOther(t *testing.T) { + path := filepath.Join(t.TempDir(), "last-run.json") + + if err := UpdateRunGate(path, func(rg *RunGate) { + rg.DeviceID = "SER123" + rg.LastFullRunAt = 42 + rg.GatingEnabled = true + rg.EffectiveIntervalMinutes = 240 + }); err != nil { + t.Fatalf("UpdateRunGate: %v", err) + } + if err := Write(path, "send-telemetry", "one_time"); err != nil { + t.Fatalf("Write: %v", err) + } + + rec, err := Load(path) + if err != nil || rec == nil { + t.Fatalf("Load: rec=%+v err=%v", rec, err) + } + if rec.Command != "send-telemetry" { + t.Errorf("Write did not stamp the breadcrumb: %+v", rec) + } + if rec.RunGate == nil || rec.RunGate.DeviceID != "SER123" || rec.RunGate.LastFullRunAt != 42 || rec.RunGate.EffectiveIntervalMinutes != 240 { + t.Errorf("Write erased the run-gate cache: %+v", rec.RunGate) + } +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go index fef66482..b2a77be5 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -153,7 +153,10 @@ func ScanStateFile() string { // HeartbeatFile returns the absolute path to last-run.json, or "" when // Home() is disabled. Callers must treat "" as "heartbeat unavailable" and -// skip writing it (same contract as ScanStateFile). +// skip writing it (same contract as ScanStateFile). Besides the start-of-run +// breadcrumb, this file also holds the run gate's offline cache (see +// heartbeat.RunGate / internal/rungate) — one file for the same "last run" +// data rather than a separate run-gate-state.json. func HeartbeatFile() string { home := Home() if home == "" { diff --git a/internal/rungate/client.go b/internal/rungate/client.go new file mode 100644 index 00000000..86a0062d --- /dev/null +++ b/internal/rungate/client.go @@ -0,0 +1,174 @@ +package rungate + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "runtime" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/step-security/dev-machine-guard/internal/aiagents/redact" + "github.com/step-security/dev-machine-guard/internal/buildinfo" +) + +// checkinTimeout caps the whole check-in round-trip. The gate runs before any +// beacon on every scheduler wakeup, so it must give up fast and fail open — +// an offline laptop pays this once per wakeup. +const checkinTimeout = 5 * time.Second + +// maxDirectiveBytes bounds the response read. A directive is ~200 bytes; +// anything near the cap is not our backend. +const maxDirectiveBytes = 64 << 10 + +// Checkin asks the backend whether this device is due for a full run. The +// gating decision rides the existing run-config response (its scan_directive +// block), so there is no dedicated endpoint: +// GET /v1/{customer}/developer-mdm-agent/run-config?device_id=…[&last_run_at=…] +// lastRunAt (unix seconds, 0 = unknown) is the agent's own last successful +// upload stamp, sent as insurance against lost or laggy ingest on the backend +// side. Only scan_directive is read here; detection_rules/policy in the same +// response are ignored (the scan path fetches run-config for those in its own +// phase). Errors are redacted (the URL embeds the customer id and the header +// carries the tenant key). A near-verbatim sibling of rules/fetch.go. +func Checkin(ctx context.Context, endpoint, apiKey, customerID, deviceID string, lastRunAt int64) (Directive, error) { + endpoint = strings.TrimSpace(endpoint) + apiKey = strings.TrimSpace(apiKey) + if endpoint == "" || apiKey == "" { + return Directive{}, errors.New("rungate: missing endpoint or api key") + } + if strings.TrimSpace(customerID) == "" { + return Directive{}, errors.New("rungate: empty customer_id") + } + if strings.TrimSpace(deviceID) == "" { + return Directive{}, errors.New("rungate: empty device_id") + } + + target := strings.TrimRight(endpoint, "/") + + "/v1/" + url.PathEscape(customerID) + + "/developer-mdm-agent/run-config?device_id=" + url.QueryEscape(deviceID) + if lastRunAt > 0 { + target += "&last_run_at=" + strconv.FormatInt(lastRunAt, 10) + } + + ctx, cancel := context.WithTimeout(ctx, checkinTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return Directive{}, fmt.Errorf("rungate: build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "dmg/"+buildinfo.Version) + + resp, err := (&http.Client{Timeout: checkinTimeout}).Do(req) + if err != nil { + return Directive{}, fmt.Errorf("rungate: transport: %s", redact.String(err.Error())) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxDirectiveBytes)) + return Directive{}, fmt.Errorf("rungate: unexpected status %d: %s", + resp.StatusCode, redact.String(strings.TrimSpace(string(snippet)))) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxDirectiveBytes)) + if err != nil { + return Directive{}, fmt.Errorf("rungate: read body: %w", err) + } + var env runConfigEnvelope + if err := json.Unmarshal(body, &env); err != nil { + return Directive{}, fmt.Errorf("rungate: decode body: %w", err) + } + // A 200 with no scan_directive is an unknown shape (or an older backend + // that predates run gating) — surface it as an error so the caller fails + // open rather than trusting a zero value. + if env.ScanDirective == nil || env.ScanDirective.Mode == "" { + return Directive{}, errors.New("rungate: response carried no scan_directive") + } + return *env.ScanDirective, nil +} + +// skipBeaconTimeout bounds the gated-skip heartbeat POST. Kept short: it is +// best-effort and must never delay the fast exit of a skipped wakeup. +const skipBeaconTimeout = 5 * time.Second + +// skipBeaconBody is the run-status POST for a gated skip. It mirrors the +// backend's APIV2SubmitTelemetryRunStatus request (the status="skipped" +// branch): a standalone check-in row, no started/terminal lifecycle. +type skipBeaconBody struct { + ExecutionID string `json:"execution_id"` + DeviceID string `json:"device_id"` + Status string `json:"status"` + AgentVersion string `json:"agent_version"` + Platform string `json:"platform"` + SkipReason string `json:"skip_reason,omitempty"` + NextEligibleAt int64 `json:"next_eligible_at,omitempty"` +} + +// PostSkipBeacon best-effort reports a gated-skip heartbeat to the backend so +// the console can show that the agent checked in and was told not to scan (a +// gated skip otherwise leaves no server-side trace). Fire-and-forget by +// contract: any error (offline, non-200) is returned for a debug log only and +// never affects the run — the gate has already decided to skip. A fresh UUID +// per skip makes each a standalone row; the backend gives them a short TTL. +func PostSkipBeacon(ctx context.Context, endpoint, apiKey, customerID, deviceID, reason string, nextEligibleAt int64) error { + endpoint = strings.TrimSpace(endpoint) + apiKey = strings.TrimSpace(apiKey) + if endpoint == "" || apiKey == "" || strings.TrimSpace(customerID) == "" || strings.TrimSpace(deviceID) == "" { + return errors.New("rungate: missing endpoint/key/customer/device for skip beacon") + } + + execID, err := uuid.NewRandom() + if err != nil { + return fmt.Errorf("rungate: skip beacon uuid: %w", err) + } + payload, err := json.Marshal(skipBeaconBody{ + ExecutionID: execID.String(), + DeviceID: deviceID, + Status: "skipped", // backend runStatusSkipped; distinct from the directive Mode "skip" + AgentVersion: buildinfo.Version, + Platform: runtime.GOOS, + SkipReason: reason, + NextEligibleAt: nextEligibleAt, + }) + if err != nil { + return fmt.Errorf("rungate: marshal skip beacon: %w", err) + } + + target := strings.TrimRight(endpoint, "/") + + "/v1/" + url.PathEscape(customerID) + + "/developer-mdm-agent/telemetry/run-status" + + ctx, cancel := context.WithTimeout(ctx, skipBeaconTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("rungate: build skip beacon request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "dmg/"+buildinfo.Version) + + resp, err := (&http.Client{Timeout: skipBeaconTimeout}).Do(req) + if err != nil { + return fmt.Errorf("rungate: skip beacon transport: %s", redact.String(err.Error())) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxDirectiveBytes)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("rungate: skip beacon unexpected status %d", resp.StatusCode) + } + return nil +} diff --git a/internal/rungate/client_test.go b/internal/rungate/client_test.go new file mode 100644 index 00000000..c790b625 --- /dev/null +++ b/internal/rungate/client_test.go @@ -0,0 +1,123 @@ +package rungate + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestCheckinParsesDirectiveAndSendsParams(t *testing.T) { + var gotPath, gotQuery, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"detection_rules":{"rules":[]},"scan_directive":{"mode":"skip","reason":"not_due","gating_enabled":true,"effective_interval_minutes":240,"next_eligible_at":1753164400,"checked_at":1753160800}}`)) + })) + defer srv.Close() + + d, err := Checkin(context.Background(), srv.URL, "tenant-key", "acme corp", "SER 123", 1753150000) + if err != nil { + t.Fatalf("Checkin: %v", err) + } + if !d.ShouldSkip() || d.Reason != "not_due" || d.EffectiveIntervalMinutes != 240 || d.NextEligibleAt != 1753164400 { + t.Fatalf("directive = %+v", d) + } + if gotPath != "/v1/acme%20corp/developer-mdm-agent/run-config" && gotPath != "/v1/acme corp/developer-mdm-agent/run-config" { + t.Errorf("path = %q (customer id must be path-escaped)", gotPath) + } + if !strings.Contains(gotQuery, "device_id=SER+123") && !strings.Contains(gotQuery, "device_id=SER%20123") { + t.Errorf("query = %q, want escaped device_id", gotQuery) + } + if !strings.Contains(gotQuery, "last_run_at=1753150000") { + t.Errorf("query = %q, want last_run_at", gotQuery) + } + if gotAuth != "Bearer tenant-key" { + t.Errorf("Authorization = %q", gotAuth) + } +} + +func TestCheckinOmitsZeroLastRunAt(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"scan_directive":{"mode":"full","reason":"gating_disabled"}}`)) + })) + defer srv.Close() + + if _, err := Checkin(context.Background(), srv.URL, "k", "acme", "SER1", 0); err != nil { + t.Fatalf("Checkin: %v", err) + } + if strings.Contains(gotQuery, "last_run_at") { + t.Errorf("query %q must omit last_run_at when unknown", gotQuery) + } +} + +func TestCheckinErrorPaths(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + }{ + {name: "401", handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) }}, + {name: "404 old backend", handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }}, + {name: "500", handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }}, + {name: "garbage body", handler: func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("nope")) }}, + {name: "no scan_directive (rules-only / old backend)", handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"detection_rules":{"rules":[]}}`)) + }}, + {name: "empty mode", handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"scan_directive":{"reason":"x"}}`)) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + if _, err := Checkin(context.Background(), srv.URL, "k", "acme", "SER1", 0); err == nil { + t.Fatal("Checkin must error so the gate fails open") + } + }) + } +} + +func TestCheckinRespectsContextDeadline(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + })) + defer srv.Close() + defer close(release) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + start := time.Now() + _, err := Checkin(ctx, srv.URL, "k", "acme", "SER1", 0) + if err == nil { + t.Fatal("Checkin must error on deadline") + } + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("Checkin took %v; the caller's deadline must bound it", elapsed) + } +} + +func TestCheckinValidatesInputs(t *testing.T) { + for _, tt := range []struct { + name string + endpoint, key, customerID, deviceID string + }{ + {name: "no endpoint", endpoint: "", key: "k", customerID: "c", deviceID: "d"}, + {name: "no key", endpoint: "http://x", key: "", customerID: "c", deviceID: "d"}, + {name: "no customer", endpoint: "http://x", key: "k", customerID: " ", deviceID: "d"}, + {name: "no device", endpoint: "http://x", key: "k", customerID: "c", deviceID: ""}, + } { + t.Run(tt.name, func(t *testing.T) { + if _, err := Checkin(context.Background(), tt.endpoint, tt.key, tt.customerID, tt.deviceID, 0); err == nil { + t.Fatal("want validation error") + } + }) + } +} diff --git a/internal/rungate/decide.go b/internal/rungate/decide.go new file mode 100644 index 00000000..213b2d28 --- /dev/null +++ b/internal/rungate/decide.go @@ -0,0 +1,93 @@ +package rungate + +import ( + "time" + + "github.com/step-security/dev-machine-guard/internal/heartbeat" +) + +// offlineCacheMinFloor is the minimum freshness window for the cached +// directive when the backend is unreachable. The window is +// max(offlineCacheMinFloor, 2×interval): generous because the cache stores an +// interval (recomputed against LastFullRunAt every wakeup), never a literal +// skip — so even a week-old cache can only delay one interval, and expiring +// it merely reverts to today's scan-every-wakeup behavior. +const offlineCacheMinFloor = 7 * 24 * time.Hour + +// Inputs is everything Decide consumes. Pure data — the orchestration in +// Evaluate gathers it; tests construct it directly. +// +// There is deliberately no agent-side feature flag here: the feature is +// controlled entirely by the backend's run-directive response (dormant when +// the backend answers "always scan"), so it can be turned on or off from the +// backend without shipping a new agent. +type Inputs struct { + // ForceScan: --force-scan / STEPSEC_FORCE_SCAN=1. + ForceScan bool + // KillSwitch: STEPSEC_DISABLE_RUN_GATE=1 (per-device local escape). + KillSwitch bool + // Directive is the backend's answer; nil when the check-in failed. + Directive *Directive + // State is the on-disk cache (in last-run.json); nil when + // absent/corrupt/future-schema/never-checked-in. + State *heartbeat.RunGate + Now time.Time +} + +// Decision is the gate's verdict. Reason values are log-facing: +// forced | kill_switch | directive (server answer, subreason attached by the +// caller) | offline_cache_skip | offline_fail_open. +type Decision struct { + Skip bool + Reason string + NextEligibleAt int64 // unix sec; informational, set on skips when known +} + +// Decide applies the gate's precedence. Pure. +// +// Order matters: the local escapes (force, kill switch) win over everything; +// the server directive decides due-vs-not; the cache only speaks when the +// backend didn't. There is deliberately no lock check here — a not-due wakeup +// skips on the directive before the run ever tries to acquire the lock, and a +// DUE wakeup that collides with a still-running scan is allowed through to +// lock.Acquire so it reports the contention (a scan overrunning its own +// interval is worth surfacing), consistent with the pre-gating behavior. +func Decide(in Inputs) Decision { + if in.ForceScan { + return Decision{Skip: false, Reason: "forced"} + } + if in.KillSwitch { + return Decision{Skip: false, Reason: "kill_switch"} + } + if d := in.Directive; d != nil { + if d.ShouldSkip() { + return Decision{Skip: true, Reason: "directive:" + d.Reason, NextEligibleAt: d.NextEligibleAt} + } + return Decision{Skip: false, Reason: "directive:" + d.Reason} + } + return decideOffline(in.State, in.Now) +} + +// decideOffline is the check-in-failed path: replay the cached interval +// against the local last-full-run stamp. Every missing precondition fails +// open — no cache, gating off at last contact, no interval, never completed a +// run, or a cache older than max(floor, 2×interval). +func decideOffline(st *heartbeat.RunGate, now time.Time) Decision { + if st == nil || !st.GatingEnabled || st.EffectiveIntervalMinutes <= 0 || st.LastFullRunAt <= 0 { + return Decision{Skip: false, Reason: "offline_fail_open"} + } + interval := time.Duration(st.EffectiveIntervalMinutes) * time.Minute + staleness := max(2*interval, offlineCacheMinFloor) + if st.DirectiveFetchedAt <= 0 || now.Sub(time.Unix(st.DirectiveFetchedAt, 0)) >= staleness { + return Decision{Skip: false, Reason: "offline_fail_open"} + } + sinceLast := now.Sub(time.Unix(st.LastFullRunAt, 0)) + if sinceLast >= interval { + return Decision{Skip: false, Reason: "offline_fail_open"} + } + return Decision{ + Skip: true, + Reason: "offline_cache_skip", + NextEligibleAt: st.LastFullRunAt + int64(st.EffectiveIntervalMinutes)*60, + } +} diff --git a/internal/rungate/decide_test.go b/internal/rungate/decide_test.go new file mode 100644 index 00000000..f996c588 --- /dev/null +++ b/internal/rungate/decide_test.go @@ -0,0 +1,134 @@ +package rungate + +import ( + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/heartbeat" +) + +func TestDecidePrecedence(t *testing.T) { + now := time.Unix(1_753_160_800, 0) + skipDirective := &Directive{Mode: ModeSkip, Reason: "not_due", GatingEnabled: true, EffectiveIntervalMinutes: 240, NextEligibleAt: now.Unix() + 3600} + fullDirective := &Directive{Mode: ModeFull, Reason: "interval_elapsed", GatingEnabled: true, EffectiveIntervalMinutes: 240} + + tests := []struct { + name string + in Inputs + wantSkip bool + wantReason string + }{ + { + name: "force wins over everything", + in: Inputs{ForceScan: true, Directive: skipDirective, Now: now}, + wantSkip: false, + wantReason: "forced", + }, + { + name: "kill switch never skips", + in: Inputs{KillSwitch: true, Directive: skipDirective, Now: now}, + wantSkip: false, + wantReason: "kill_switch", + }, + { + name: "skip directive obeyed", + in: Inputs{Directive: skipDirective, Now: now}, + wantSkip: true, + wantReason: "directive:not_due", + }, + { + name: "full directive obeyed", + in: Inputs{Directive: fullDirective, Now: now}, + wantSkip: false, + wantReason: "directive:interval_elapsed", + }, + { + name: "unknown future mode degrades to full, never skip", + in: Inputs{Now: now, Directive: &Directive{Mode: "partial", Reason: "phased_rollout", GatingEnabled: true}}, + wantSkip: false, + wantReason: "directive:phased_rollout", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec := Decide(tt.in) + if dec.Skip != tt.wantSkip || dec.Reason != tt.wantReason { + t.Fatalf("Decide() = (skip=%v, reason=%q), want (skip=%v, reason=%q)", + dec.Skip, dec.Reason, tt.wantSkip, tt.wantReason) + } + }) + } +} + +func TestDecideSkipCarriesNextEligible(t *testing.T) { + now := time.Unix(1_753_160_800, 0) + d := &Directive{Mode: ModeSkip, Reason: "not_due", GatingEnabled: true, NextEligibleAt: now.Unix() + 1234} + dec := Decide(Inputs{Directive: d, Now: now}) + if !dec.Skip || dec.NextEligibleAt != now.Unix()+1234 { + t.Fatalf("Decide() = %+v, want skip with NextEligibleAt=%d", dec, now.Unix()+1234) + } +} + +func TestDecideOfflineFallback(t *testing.T) { + now := time.Unix(1_753_160_800, 0) + nowUnix := now.Unix() + const interval = 240 // minutes + + state := func(mutate func(*heartbeat.RunGate)) *heartbeat.RunGate { + st := &heartbeat.RunGate{ + GatingEnabled: true, + EffectiveIntervalMinutes: interval, + LastFullRunAt: nowUnix - 60, // 1 min ago + DirectiveFetchedAt: nowUnix - 3600, // 1h ago — fresh + } + if mutate != nil { + mutate(st) + } + return st + } + + tests := []struct { + name string + st *heartbeat.RunGate + wantSkip bool + wantReason string + }{ + {name: "no cached state fails open", st: nil, wantSkip: false, wantReason: "offline_fail_open"}, + {name: "gating was off at last contact", st: state(func(s *heartbeat.RunGate) { s.GatingEnabled = false }), wantSkip: false, wantReason: "offline_fail_open"}, + {name: "no cached interval", st: state(func(s *heartbeat.RunGate) { s.EffectiveIntervalMinutes = 0 }), wantSkip: false, wantReason: "offline_fail_open"}, + {name: "never completed a run", st: state(func(s *heartbeat.RunGate) { s.LastFullRunAt = 0 }), wantSkip: false, wantReason: "offline_fail_open"}, + {name: "never checked in", st: state(func(s *heartbeat.RunGate) { s.DirectiveFetchedAt = 0 }), wantSkip: false, wantReason: "offline_fail_open"}, + {name: "fresh cache within interval skips", st: state(nil), wantSkip: true, wantReason: "offline_cache_skip"}, + {name: "interval elapsed locally runs", st: state(func(s *heartbeat.RunGate) { s.LastFullRunAt = nowUnix - int64(interval)*60 }), wantSkip: false, wantReason: "offline_fail_open"}, + { + name: "stale cache beyond max(floor, 2x interval) runs", + st: state(func(s *heartbeat.RunGate) { + s.DirectiveFetchedAt = nowUnix - int64((8*24*time.Hour)/time.Second) + }), + wantSkip: false, wantReason: "offline_fail_open", + }, + { + name: "floor keeps a days-old cache usable for short intervals", + st: state(func(s *heartbeat.RunGate) { + // 2×interval = 8h, but the 7d floor governs: a 6d-old cache + // still skips when the last (e.g. forced) run was recent. + s.DirectiveFetchedAt = nowUnix - int64((6*24*time.Hour)/time.Second) + }), + wantSkip: true, wantReason: "offline_cache_skip", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec := Decide(Inputs{State: tt.st, Now: now}) + if dec.Skip != tt.wantSkip || dec.Reason != tt.wantReason { + t.Fatalf("Decide() = (skip=%v, reason=%q), want (skip=%v, reason=%q)", + dec.Skip, dec.Reason, tt.wantSkip, tt.wantReason) + } + if tt.wantSkip && dec.NextEligibleAt != tt.st.LastFullRunAt+int64(interval)*60 { + t.Fatalf("NextEligibleAt = %d, want %d", dec.NextEligibleAt, tt.st.LastFullRunAt+int64(interval)*60) + } + }) + } +} diff --git a/internal/rungate/directive.go b/internal/rungate/directive.go new file mode 100644 index 00000000..167be033 --- /dev/null +++ b/internal/rungate/directive.go @@ -0,0 +1,47 @@ +// Package rungate implements the server-driven run gate: on every invocation +// the agent asks the backend's run-directive endpoint whether a full scan is +// due and exits quietly when it isn't. The scan cadence lives in the backend +// (per tenant, with temporary overrides and per-device refresh), so customers +// point their MDM/scheduler at a simple hourly launch and control the real +// frequency from the dashboard. +// +// Every failure path fails OPEN (the scan runs): a tenant that never opted +// in, an unreachable backend, a malformed response, an unresolvable device +// id, or unusable local state must never suppress scanning. The only +// deliberate skips are a backend "skip" directive, the offline cached-interval +// fallback, and the quiet back-off while another instance holds the lock. +package rungate + +// Wire contract for GET /developer-mdm-agent/run-directive. Mode and reason +// strings are wire-permanent and mirrored by the backend's +// run_directive_handler.go. +const ( + ModeFull = "full" + ModeSkip = "skip" +) + +// Directive is the backend's check-in answer. EffectiveIntervalMinutes rides +// along so the agent can cache it as its offline fallback gate; NextEligibleAt +// is informational (skip responses only). +type Directive struct { + Mode string `json:"mode"` + Reason string `json:"reason"` + GatingEnabled bool `json:"gating_enabled"` + EffectiveIntervalMinutes int `json:"effective_interval_minutes"` + NextEligibleAt int64 `json:"next_eligible_at"` + CheckedAt int64 `json:"checked_at"` +} + +// runConfigEnvelope is the subset of the run-config response the gate reads. +// The scan directive rides run-config alongside detection_rules and policy; +// those siblings are intentionally ignored here (the scan path fetches them +// itself). A pointer so a missing field is distinguishable from a zero value. +type runConfigEnvelope struct { + ScanDirective *Directive `json:"scan_directive"` +} + +// ShouldSkip is the single reader of Mode. Anything that is not exactly +// ModeSkip — including future modes like "partial" — means the scan proceeds, +// so new server behavior degrades to a full scan on old agents, never to a +// silent skip. +func (d Directive) ShouldSkip() bool { return d.Mode == ModeSkip } diff --git a/internal/rungate/evaluate.go b/internal/rungate/evaluate.go new file mode 100644 index 00000000..9f687005 --- /dev/null +++ b/internal/rungate/evaluate.go @@ -0,0 +1,110 @@ +package rungate + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/device" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// serialProbeTimeout bounds the one-off device-id probe (macOS ioreg is the +// slow case). The result is cached in the state file, so only a device's +// first gated invocation pays it. +const serialProbeTimeout = 10 * time.Second + +// Result is what main acts on: skip (exit 0 quietly) or proceed. Detail is a +// preformatted human fragment for the single skip log line. +type Result struct { + Skip bool + Reason string + Detail string +} + +// Evaluate runs the whole gate ahead of telemetry.Run: explicit escapes, +// cached-or-probed device id, the backend check-in, the decision, and state +// persistence. It makes one or two network calls — the backend check-in, plus +// a best-effort gated-skip heartbeat on an online skip (and none at all when an +// escape short-circuits) — and NEVER fails the run: every error path degrades +// to Skip=false. Lock contention is deliberately NOT handled here: a not-due +// wakeup skips on the directive before the run ever tries the lock, and a due +// wakeup that collides with a running scan is left to telemetry.Run's +// lock.Acquire so it reports the contention as before. +func Evaluate(ctx context.Context, exec executor.Executor, log *progress.Logger, forceScan bool) Result { + in := Inputs{ + ForceScan: forceScan || os.Getenv("STEPSEC_FORCE_SCAN") == "1", + KillSwitch: os.Getenv("STEPSEC_DISABLE_RUN_GATE") == "1", + Now: time.Now(), + } + + // Local escapes need no I/O at all; resolve them before touching disk or + // network. Everything else defers to the backend's scan directive — there + // is no agent-side feature flag, so the feature is turned on or off + // entirely from the backend. + if in.ForceScan || in.KillSwitch { + if in.ForceScan { + log.Progress("Run gate: bypassed (--force-scan)") + } + return Result{Skip: false, Reason: Decide(in).Reason} + } + + // Device id: cached from a prior run when possible, else a bounded local + // probe. Without a real serial the backend can't be asked anything + // meaningful — fail open rather than gate on a bogus id. + st, stOK := readState() + deviceID := st.DeviceID + if deviceID == "" || deviceID == "unknown" { + probeCtx, cancel := context.WithTimeout(ctx, serialProbeTimeout) + deviceID = device.SerialNumber(probeCtx, exec) + cancel() + } + if deviceID == "" || deviceID == "unknown" { + log.Debug("run-gate: no usable device id — failing open") + return Result{Skip: false, Reason: "no_device_id"} + } + + log.Progress("Run gate: checking scan cadence with the dashboard...") + directive, err := Checkin(ctx, config.APIEndpoint, config.APIKey, config.CustomerID, deviceID, st.LastFullRunAt) + if err != nil { + log.Progress("Run gate: dashboard check-in failed, using cached cadence: %v", err) + } else { + in.Directive = &directive + log.Progress("Run gate: dashboard directive: mode=%s reason=%s interval=%dm", + directive.Mode, directive.Reason, directive.EffectiveIntervalMinutes) + // Persist the resolved id + gating fields even on "full" answers so + // skipped wakeups never re-probe and the offline fallback stays + // current. Best-effort. + if perr := recordCheckin(deviceID, directive, in.Now); perr != nil { + log.Debug("run-gate: could not persist check-in state: %v", perr) + } + } + if stOK { + in.State = &st + } + + dec := Decide(in) + res := Result{Skip: dec.Skip, Reason: dec.Reason} + if dec.Skip { + // Online skip: best-effort heartbeat so the console shows the agent + // checked in and was told not to scan (a gated skip otherwise leaves no + // server-side trace). Never affects the run. Offline skips don't beacon + // — the device can't reach the backend anyway. + if in.Directive != nil { + if err := PostSkipBeacon(ctx, config.APIEndpoint, config.APIKey, config.CustomerID, + deviceID, in.Directive.Reason, dec.NextEligibleAt); err != nil { + log.Debug("run-gate: skip beacon not sent: %v", err) + } + } + detail := "cadence is managed by your StepSecurity dashboard" + if dec.NextEligibleAt > 0 { + detail = fmt.Sprintf("next scan eligible at %s", + time.Unix(dec.NextEligibleAt, 0).UTC().Format(time.RFC3339)) + } + res.Detail = detail + } + return res +} diff --git a/internal/rungate/state.go b/internal/rungate/state.go new file mode 100644 index 00000000..513d6577 --- /dev/null +++ b/internal/rungate/state.go @@ -0,0 +1,66 @@ +package rungate + +import ( + "time" + + "github.com/step-security/dev-machine-guard/internal/heartbeat" + "github.com/step-security/dev-machine-guard/internal/paths" +) + +// The run gate's cached memory lives inside last-run.json (heartbeat.RunGate), +// not a separate file: it is the same "last run" kind of data, written to the +// same install dir, and is read only on the offline fallback path. state.go is +// the thin adapter between the gate's decision logic and that shared storage. + +// statePathOverride lets tests redirect reads/writes to a tempdir. +var statePathOverride string + +// SetStatePathForTest redirects the gate's state file to the given absolute +// path and returns a restore function. Test-only. +func SetStatePathForTest(p string) (restore func()) { + prev := statePathOverride + statePathOverride = p + return func() { statePathOverride = prev } +} + +// statePath resolves last-run.json (the shared heartbeat + gate-cache file), +// or "" when the install dir is disabled — callers fail open on "". +func statePath() string { + if statePathOverride != "" { + return statePathOverride + } + return paths.HeartbeatFile() +} + +// StampLastFullRun records a completed full run (called from telemetry.Run +// right after the upload succeeds). Best-effort by contract: on failure the +// next gated invocation simply runs again. +func StampLastFullRun(now time.Time) error { + return heartbeat.UpdateRunGate(statePath(), func(rg *heartbeat.RunGate) { + rg.LastFullRunAt = now.Unix() + }) +} + +// recordCheckin persists the freshly-resolved device id and the directive's +// gating fields after a successful check-in, preserving LastFullRunAt. The +// interval — never the skip itself — is what the offline fallback replays, so +// a stale cache can only delay a scan by one interval, not suppress it. +func recordCheckin(deviceID string, d Directive, fetchedAt time.Time) error { + return heartbeat.UpdateRunGate(statePath(), func(rg *heartbeat.RunGate) { + rg.DeviceID = deviceID + rg.GatingEnabled = d.GatingEnabled + rg.EffectiveIntervalMinutes = d.EffectiveIntervalMinutes + rg.DirectiveFetchedAt = fetchedAt.Unix() + }) +} + +// readState returns the gate's cached fields for the decision inputs. +// ok=false covers absent, corrupt, future-schema, and never-checked-in files +// alike — all mean "no usable local state" (fail open). +func readState() (heartbeat.RunGate, bool) { + rec, err := heartbeat.Load(statePath()) + if err != nil || rec == nil || rec.RunGate == nil { + return heartbeat.RunGate{}, false + } + return *rec.RunGate, true +} diff --git a/internal/rungate/state_test.go b/internal/rungate/state_test.go new file mode 100644 index 00000000..dccaf09e --- /dev/null +++ b/internal/rungate/state_test.go @@ -0,0 +1,148 @@ +package rungate + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/heartbeat" +) + +func withTempState(t *testing.T) string { + t.Helper() + // The gate cache shares last-run.json with the heartbeat breadcrumb. + path := filepath.Join(t.TempDir(), "last-run.json") + restore := SetStatePathForTest(path) + t.Cleanup(restore) + return path +} + +func TestStampLastFullRunCreatesAndUpdates(t *testing.T) { + path := withTempState(t) + now := time.Unix(1_753_160_800, 0) + + if err := StampLastFullRun(now); err != nil { + t.Fatalf("StampLastFullRun on absent file: %v", err) + } + st, ok := readState() + if !ok || st.LastFullRunAt != now.Unix() { + t.Fatalf("state after stamp = %+v ok=%v, want LastFullRunAt=%d", st, ok, now.Unix()) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat state file: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("state file mode = %v, want 0600", info.Mode().Perm()) + } + } + + later := now.Add(4 * time.Hour) + if err := StampLastFullRun(later); err != nil { + t.Fatalf("StampLastFullRun update: %v", err) + } + st, _ = readState() + if st.LastFullRunAt != later.Unix() { + t.Fatalf("LastFullRunAt = %d, want %d", st.LastFullRunAt, later.Unix()) + } +} + +func TestStampAndRecordPreserveEachOther(t *testing.T) { + withTempState(t) + now := time.Unix(1_753_160_800, 0) + + if err := StampLastFullRun(now); err != nil { + t.Fatalf("stamp: %v", err) + } + d := Directive{Mode: ModeSkip, Reason: "not_due", GatingEnabled: true, EffectiveIntervalMinutes: 240} + if err := recordCheckin("SER123", d, now.Add(time.Minute)); err != nil { + t.Fatalf("recordCheckin: %v", err) + } + + st, ok := readState() + if !ok { + t.Fatal("state unreadable after both writes") + } + if st.LastFullRunAt != now.Unix() { + t.Errorf("recordCheckin clobbered LastFullRunAt: %d", st.LastFullRunAt) + } + if st.DeviceID != "SER123" || !st.GatingEnabled || st.EffectiveIntervalMinutes != 240 { + t.Errorf("check-in fields not persisted: %+v", st) + } + + if err := StampLastFullRun(now.Add(2 * time.Hour)); err != nil { + t.Fatalf("second stamp: %v", err) + } + st, _ = readState() + if st.DeviceID != "SER123" || st.EffectiveIntervalMinutes != 240 || st.DirectiveFetchedAt != now.Add(time.Minute).Unix() { + t.Errorf("stamp clobbered check-in fields: %+v", st) + } +} + +// TestHeartbeatAndGateShareFile is the whole point of folding the cache into +// last-run.json: a heartbeat write at the top of a run must not erase the gate +// cache a prior run left, and the gate write must not erase the breadcrumb. +func TestHeartbeatAndGateShareFile(t *testing.T) { + path := withTempState(t) + now := time.Unix(1_753_160_800, 0) + + // A prior run left a check-in cache. + d := Directive{Mode: ModeSkip, Reason: "not_due", GatingEnabled: true, EffectiveIntervalMinutes: 240} + if err := recordCheckin("SER123", d, now); err != nil { + t.Fatalf("recordCheckin: %v", err) + } + // This run's heartbeat stamps the breadcrumb first. + if err := heartbeat.Write(path, "send-telemetry", "one_time"); err != nil { + t.Fatalf("heartbeat write: %v", err) + } + // The gate cache must have survived the breadcrumb write. + st, ok := readState() + if !ok || st.DeviceID != "SER123" || st.EffectiveIntervalMinutes != 240 { + t.Fatalf("heartbeat write erased the gate cache: %+v ok=%v", st, ok) + } +} + +func TestFutureSchemaReadUnusableThenOverwritten(t *testing.T) { + path := withTempState(t) + future := `{"schema_version": 99, "run_gate": {"device_id": "FROM-THE-FUTURE", "last_full_run_at": 42}}` + "\n" + if err := os.WriteFile(path, []byte(future), 0o600); err != nil { + t.Fatalf("seed future file: %v", err) + } + + // A newer-schema file is unusable for the gate decision (fail open)... + if _, ok := readState(); ok { + t.Fatal("readState must treat a future-schema file as unusable") + } + // ...but is overwritten, not refused: the breadcrumb must always write and + // the gate cache is fail-open-safe, so we don't preserve unknown schemas. + now := time.Unix(1_753_160_800, 0) + if err := StampLastFullRun(now); err != nil { + t.Fatalf("StampLastFullRun over future-schema file: %v", err) + } + st, ok := readState() + if !ok || st.LastFullRunAt != now.Unix() || st.DeviceID != "" { + t.Fatalf("future-schema file not cleanly overwritten: %+v ok=%v", st, ok) + } +} + +func TestCorruptFileIsRecreated(t *testing.T) { + path := withTempState(t) + if err := os.WriteFile(path, []byte("not json{{"), 0o600); err != nil { + t.Fatalf("seed corrupt file: %v", err) + } + if _, ok := readState(); ok { + t.Fatal("corrupt file must read as unusable") + } + now := time.Unix(1_753_160_800, 0) + if err := StampLastFullRun(now); err != nil { + t.Fatalf("stamp over corrupt file: %v", err) + } + st, ok := readState() + if !ok || st.LastFullRunAt != now.Unix() { + t.Fatalf("state not recreated cleanly: %+v ok=%v", st, ok) + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 7c72c593..2dfae729 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -30,6 +30,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/model" "github.com/step-security/dev-machine-guard/internal/paths" "github.com/step-security/dev-machine-guard/internal/progress" + "github.com/step-security/dev-machine-guard/internal/rungate" "github.com/step-security/dev-machine-guard/internal/schedinfo" "github.com/step-security/dev-machine-guard/internal/state" "github.com/step-security/dev-machine-guard/internal/tcc" @@ -355,6 +356,14 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err // it, so it's scoped to the current run. Best-effort; see loader_log.go. seedLoaderLog(capture) + // Echo the run-gate decision into the captured log so a downloaded + // execution log shows the gate checked in and allowed this run. The gate + // runs before StartCapture (in gateSkipsRun), so its live lines aren't + // captured; this one is. + if cfg != nil && cfg.GateProceedReason != "" { + log.Progress("Run gate: checked scan cadence, proceeding with this scan (%s)", cfg.GateProceedReason) + } + // Bind the throttled log-tail emitter to the live capture so every // subsequent postPhase() can ship a recent stderr slice attached to // status_info on the throttle's cadence. See log_tail_emitter.go. @@ -1156,6 +1165,11 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err log.Debug("scan-state: saved %s (telemetry-out mode)", scanStatePath) } } + // Same rationale for the run-gate stamp: the dev harness should show + // the same second-invocation gating behavior as a real upload. + if err := rungate.StampLastFullRun(time.Now()); err != nil { + log.Debug("run-gate: could not stamp last full run: %v", err) + } return nil } @@ -1189,6 +1203,12 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err } } + // Record the completed full run for the run gate. Best-effort by + // contract: a missing stamp only means the next gated invocation runs. + if err := rungate.StampLastFullRun(time.Now()); err != nil { + log.Debug("run-gate: could not stamp last full run: %v", err) + } + fmt.Fprintln(os.Stderr) log.Progress("Telemetry collection completed successfully") tccSkipper.LogHits(log.Debug)