From 7b405e858dd275c08c26b47c81c408d9b61395f3 Mon Sep 17 00:00:00 2001 From: Brad Flaugher Date: Sat, 22 Aug 2026 15:43:44 +0000 Subject: [PATCH 01/10] Close three own-rows authorization holes on the task surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read path for task rows was narrowed to own rows in #1082, and run logs in #980. Three surfaces never got the same treatment and authorized on a permission alone, so any client-role principal reached every principal's rows: - GET /tasks/paused — ListPausedTasks selects on status alone with no principal predicate in SQL, and the projection carries each task's prompt. Its siblings (/tasks/export, /tasks/upcoming) both call visibleTasks; this one did not. Leaked other principals' paused prompts, and their task UUIDs with them. - PUT /tasks/{id} and POST /tasks/{id}/tags — loaded the task with the unscoped GetTask and never checked ownership, so a client-role principal could rewrite a teammate's pending run: prompt, model, mcp_selection and credential_allowlist included. Only run_if was gated (admin-only). - POST /tasks/{id}/feedback and GET /tasks/{id}/learned-instructions — taskFromPath is lookup-only by contract ("a handler that needs an authorization decision makes it on the returned task") and neither caller made one. A down-vote with an attacker-authored critique fed maybeDistill, which mints a proposal from the victim's prompt at unmetered model spend; the GET disclosed their learned instructions. The write gate is a new taskWritableByPrincipal, deliberately NOT principal.ownsTask: ownsTask resolves through ownerID(), which is nil for every API-key principal, so it would deny a scoped intake-app key the right to edit the task it just created. taskCreatedByPrincipal matches a creating user OR a creating key (CreatedByKeyID), which is the model #980/#1082 established. A write surface must be no looser than the read surface guarding the same row. TestScopedAPIKeyAuthorization previously asserted "client key can edit an editable task" against an unattributed row — that was the vulnerable behavior. Split into the owned case (must keep working: the intake-app path) and the unowned case (must 403). Every fix mutation-tested: stripped, the tests fail with the exploit visible — a hijacked prompt persisted, another principal's prompt in the paused queue, feedback accepted on an unowned task. Also in this commit, from the same audit sweep: - internal/config/config.go: ValidateScheduled interpolated the first 6 bytes of OPENROUTER_API_KEY into a validation error — the only place in the tree where secret material reached an error string. Removed, and the doc comment corrected: it claimed "Called at startup" but has no production caller. - internal/agent/scheduled.go: run-error strings now go through agentcore.RedactSecrets before the persisted transcript and the log. Tool output, the stream sink, hooks and the session log were already scrubbed; run errors were the one path that skipped it, and the transcript write is the larger surface. - internal/mcpoauth/discovery.go: refuse a non-http(s) scheme on the remote-derived discovery URLs (a WWW-Authenticate resource_metadata pointer, a PRM-declared issuer) before the request. Contained already by SafeHTTPClient and the transport; this makes the argument explicit rather than dependent on transport behavior. Tested both directions. - internal/sched/models/models.go: validate WorktreeConfig.BaseBranch. It is the trailing positional of `git worktree add -b ` with no "--" separator, so a leading-dash value was parsed by git as an option. worktree_config is settable by any task creator, unlike run_if. - Log-injection sinks that carry genuinely untrusted text: the task create log (task.Prompt — its update-path twin was already sanitized), the pre-validation client attachment path on the reject branches, the client-echoed attachment Name, the upload filename, and the API-key name. logSafe/%q, matching each line's existing sanitized sibling. - web/e2e/test-auth-key.ts: the Ed25519 private key was written to a fully predictable path in the world-writable temp dir at default 0644. Now O_EXCL at 0600 with random bytes in the sibling name. - internal/agent/session.go: document loadImageAttachments' caller contract. It performs no path containment of its own and is safe only because httpapi's validateAttachments is its sole producer. Stated as a contract, not an enforced boundary, because the uploads root is not threaded to that call site — a local check could only re-assert part of the guard while looking like all of it. Signed-off-by: Brad Flaugher --- internal/agent/scheduled.go | 9 +- internal/agent/session.go | 21 ++- internal/config/config.go | 14 +- internal/httpapi/attachments.go | 8 +- internal/mcpoauth/discovery.go | 29 +++ internal/mcpoauth/discovery_test.go | 37 ++++ internal/sched/handlers/handlers.go | 25 ++- .../sched/handlers/learned_instructions.go | 17 ++ internal/sched/handlers/pause.go | 6 + .../sched/handlers/principal_authz_test.go | 77 +++++++- internal/sched/handlers/task_authz.go | 24 +++ .../sched/handlers/task_write_authz_test.go | 176 ++++++++++++++++++ internal/sched/handlers/upload.go | 6 +- internal/sched/models/models.go | 19 ++ web/e2e/test-auth-key.ts | 18 +- 15 files changed, 463 insertions(+), 23 deletions(-) create mode 100644 internal/sched/handlers/task_write_authz_test.go diff --git a/internal/agent/scheduled.go b/internal/agent/scheduled.go index 5c54fcfef..352f811ab 100644 --- a/internal/agent/scheduled.go +++ b/internal/agent/scheduled.go @@ -572,8 +572,13 @@ func (a *Agent) Execute(ctx context.Context, task string) (retErr error) { // entry here would mislabel every ask-pause and operator stop. if retErr != nil && !errors.Is(retErr, agentcore.ErrRunCancelled) { t := "error" - a.logSession.AddMessageWithMetadata(roleUser, "[fatal] "+retErr.Error(), nil, nil, &t, nil, nil, "") - log.Printf("Execute returning error: %v", retErr) + // Scrub before both sinks. RedactSecrets already guards tool output, + // the stream sink, hooks and the session log; run-error strings were + // the one path that skipped it, and the transcript write below is + // persisted and operator-visible, so it is the larger half. + msg := agentcore.RedactSecrets(retErr.Error()) + a.logSession.AddMessageWithMetadata(roleUser, "[fatal] "+msg, nil, nil, &t, nil, nil, "") + log.Printf("Execute returning error: %v", msg) } }() diff --git a/internal/agent/session.go b/internal/agent/session.go index d560a7f69..fe92cd47e 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -332,6 +332,20 @@ func replayHistory(entries []HistoryEntry) ([]fantasy.Message, error) { // carry no media type (uploads have historically been PNG-normalized). const defaultImageMediaType = "image/png" +// CALLER CONTRACT — read before adding a producer of TurnInput.ImageAttachments. +// This function performs NO path containment of its own. Every a.Path it reads +// must already have been confined to the uploads root by the producer; today the +// sole producer is httpapi's validateAttachments (attachments.go), which rebuilds +// each path as Join(root, rel) after a filepath.Rel + filepath.IsLocal guard and +// stores only that. A future producer — a scheduled path, taskrun, an MCP-driven +// path — that skips that guard turns the os.ReadFile below into an arbitrary +// host-file read straight into the model context. +// +// This is a documented contract, not an enforced boundary, and it is stated that +// way deliberately: the uploads root lives on the config used by buildSandboxPool +// and is not currently threaded to this call site, so a local check here could +// only re-assert part of the guard while looking like all of it. Thread the root +// in and re-assert Rel+IsLocal+Join here if a second producer ever appears. func loadImageAttachments(atts []ImageAttachment) ([]fantasy.FilePart, []ImageRefMeta) { const ( maxImages = 8 @@ -344,7 +358,12 @@ func loadImageAttachments(atts []ImageAttachment) ([]fantasy.FilePart, []ImageRe refs := make([]ImageRefMeta, 0, len(atts)) for _, a := range atts { if len(parts) >= maxImages { - log.Printf("loadImageAttachments: skipping %s (over %d cap)", a.Name, maxImages) + // %q on a.Name: unlike a.Path (rebuilt server-side as + // Join(root, rel) by validateAttachments), Name is the client's + // echoed chatAttachment field and is never sanitized — + // sanitizeFilename runs at upload time, but /chat re-accepts the + // client's own JSON and re-validates only Path. %q escapes CR/LF. + log.Printf("loadImageAttachments: skipping %q (over %d cap)", a.Name, maxImages) continue } info, err := os.Stat(a.Path) diff --git a/internal/config/config.go b/internal/config/config.go index 9218b8db4..4c8f91b1a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1733,15 +1733,23 @@ func (c *Config) validateTLS() error { } // ValidateScheduled checks the one-shot scheduled (cutlass) required values and -// returns an error describing all problems found. Called at startup to fail -// fast for the scheduled driver. +// returns an error describing all problems found. +// +// No production caller: `fleet task run` (the folded cutlass harness) validates +// through Validate/validate_config.go instead. This is exercised only by +// config_test.go and is kept as the scheduled-driver contract, so do not add a +// value to any message here — see the next comment. func (c *Config) ValidateScheduled() error { var errs []string if c.OpenRouterAPIKey == "" { errs = append(errs, "OPENROUTER_API_KEY is required") } else if !strings.HasPrefix(c.OpenRouterAPIKey, "sk-or-") { - errs = append(errs, "OPENROUTER_API_KEY should start with 'sk-or-' (got '"+c.OpenRouterAPIKey[:min(6, len(c.OpenRouterAPIKey))]+"...')") + // Name the expected prefix, never echo the key. This previously + // interpolated the key's first 6 bytes into the error, which was the + // one place in the tree where secret material reached an error string — + // and validation errors are logged and surfaced to operators. + errs = append(errs, "OPENROUTER_API_KEY should start with 'sk-or-'") } if c.MaxIterations < 1 || c.MaxIterations > 10000 { diff --git a/internal/httpapi/attachments.go b/internal/httpapi/attachments.go index 6ed2535d9..f426844cd 100644 --- a/internal/httpapi/attachments.go +++ b/internal/httpapi/attachments.go @@ -309,13 +309,17 @@ func (s *Server) validateAttachments(atts []chatAttachment) []chatAttachment { // !IsRegular. rel, relErr := filepath.Rel(root, abs) if relErr != nil || !filepath.IsLocal(rel) { - log.Printf("attachment rejected (outside uploads root): %s", a.Path) + // %q, not %s: a.Path here is the RAW client-supplied string on the + // branch where containment just FAILED, so it is hostile by + // construction. %q escapes CR/LF and cannot forge a log entry. + log.Printf("attachment rejected (outside uploads root): %q", a.Path) continue } abs = filepath.Join(root, rel) info, err := os.Stat(abs) if err != nil || !info.Mode().IsRegular() { - log.Printf("attachment rejected (stat): %s: %v", a.Path, err) + // %q for the same reason as above: still the pre-validation client string. + log.Printf("attachment rejected (stat): %q: %v", a.Path, err) continue } a.Path = filepath.ToSlash(abs) diff --git a/internal/mcpoauth/discovery.go b/internal/mcpoauth/discovery.go index 2fd9182fd..656509214 100644 --- a/internal/mcpoauth/discovery.go +++ b/internal/mcpoauth/discovery.go @@ -6,9 +6,27 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" ) +// requireHTTPScheme refuses any URL that is not http:// or https:// before it +// reaches an outbound request. Remote-derived discovery URLs land here (see +// fetchJSON), and a file://, gopher:// or data:// pointer from a hostile server +// should be rejected by name rather than left to the transport to decline. +func requireHTTPScheme(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parse discovery URL: %w", err) + } + switch u.Scheme { + case "http", "https": + return nil + default: + return fmt.Errorf("refusing discovery URL with scheme %q (only http/https)", u.Scheme) + } +} + // maxMetadataBytes caps a metadata/JSON response so a hostile server can't OOM // the host by streaming an unbounded body. const maxMetadataBytes = 1 << 20 // 1 MiB @@ -226,7 +244,18 @@ func verifyAuthServer(expectedIssuer string, as *AuthServerMetadata) error { } // fetchJSON GETs url and decodes a (size-limited) JSON body into out. +// +// The URLs reaching here are REMOTE-DERIVED — a WWW-Authenticate +// `resource_metadata=` pointer, or a candidate built from a PRM-declared +// `issuer` — so they are untrusted even though the operator typed the server +// URL that led to them. SSRF is contained by SafeHTTPClient's resolve-then-dial +// guard and its no-redirect policy, and http.Transport would refuse a non-HTTP +// scheme anyway; the explicit check below is one line and makes that argument +// airtight rather than dependent on the transport's behavior. func fetchJSON(ctx context.Context, httpClient *http.Client, url string, out any) error { + if err := requireHTTPScheme(url); err != nil { + return err + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err diff --git a/internal/mcpoauth/discovery_test.go b/internal/mcpoauth/discovery_test.go index f8810d7c5..9b80fb288 100644 --- a/internal/mcpoauth/discovery_test.go +++ b/internal/mcpoauth/discovery_test.go @@ -227,3 +227,40 @@ func TestRegisterDCR(t *testing.T) { t.Error("Register accepted an empty registration endpoint") } } + +// TestFetchJSONRefusesNonHTTPScheme pins the scheme guard on the remote-derived +// discovery URLs. A hostile MCP server controls the WWW-Authenticate +// `resource_metadata=` pointer and the PRM-declared `issuer`, so it chooses the +// string that reaches fetchJSON. SafeHTTPClient's dialer and http.Transport both +// already decline a non-HTTP scheme; this asserts we refuse it by name first, so +// the containment argument does not rest on transport behavior. +func TestFetchJSONRefusesNonHTTPScheme(t *testing.T) { + for _, raw := range []string{ + "file:///etc/passwd", + "gopher://127.0.0.1:70/x", + "data:application/json,{}", + "ftp://example.com/meta.json", + } { + var out map[string]any + err := fetchJSON(context.Background(), http.DefaultClient, raw, &out) + if err == nil { + t.Fatalf("fetchJSON(%q) = nil error, want refusal", raw) + } + if !strings.Contains(err.Error(), "only http/https") { + t.Fatalf("fetchJSON(%q) error = %v, want a scheme refusal", raw, err) + } + } +} + +// TestRequireHTTPSchemeAcceptsHTTPAndHTTPS is the negative half: the guard must +// not reject the two schemes discovery legitimately uses. +func TestRequireHTTPSchemeAcceptsHTTPAndHTTPS(t *testing.T) { + for _, raw := range []string{ + "http://localhost:8080/.well-known/oauth-protected-resource", + "https://example.com/.well-known/openid-configuration", + } { + if err := requireHTTPScheme(raw); err != nil { + t.Fatalf("requireHTTPScheme(%q) = %v, want nil", raw, err) + } + } +} diff --git a/internal/sched/handlers/handlers.go b/internal/sched/handlers/handlers.go index d05a5859d..e0d60a5ff 100644 --- a/internal/sched/handlers/handlers.go +++ b/internal/sched/handlers/handlers.go @@ -547,7 +547,8 @@ func (h *Handlers) CreateTask(w http.ResponseWriter, r *http.Request) { return } - log.Printf("Task created: %s (prompt: %.50s...)", task.ID, task.Prompt) + //nolint:gosec // G706: untrusted fields are sanitized via logSafe (strips CR/LF); gosec's taint tracker cannot see through the helper. task.ID is a uuid.UUID. + log.Printf("Task created: %s (prompt: %.50s...)", task.ID, logSafe(task.Prompt)) localizeTask(task) writeJSON(w, http.StatusOK, task) } @@ -1720,6 +1721,18 @@ func (h *Handlers) UpdateTask(w http.ResponseWriter, r *http.Request) { return } + // Own-rows authorization (#1082 model): PermissionCreateTask above admits + // the principal to the surface; this decides WHICH task. Without it any + // client-role principal could rewrite a teammate's pending run — prompt, + // model, mcp_selection and credential_allowlist included — even though the + // read path guarding the same row was narrowed to own rows. GetTask is + // unscoped by design (the read surfaces filter above it), so the check + // belongs here. + if !taskWritableByPrincipal(p, task) { + writeError(w, http.StatusForbidden, "Only the task creator or an admin can edit this task") + return + } + // Only allow editing tasks that haven't started if task.Status != models.TaskStatusPending && task.Status != models.TaskStatusScheduled { writeError(w, http.StatusBadRequest, "Only pending or scheduled tasks can be edited") @@ -1954,6 +1967,13 @@ func (h *Handlers) UpdateTaskTags(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "Task not found") return } + // Own-rows authorization, same helper and same reason as UpdateTask. Tags + // drive filtering and routing, so retagging a teammate's task is a write to + // their work, not a cosmetic change. + if !taskWritableByPrincipal(p, task) { + writeError(w, http.StatusForbidden, "Only the task creator or an admin can retag this task") + return + } var body tagMutation if err := readJSON(r, &body); err != nil { writeError(w, http.StatusBadRequest, "Invalid request body") @@ -2345,7 +2365,8 @@ func (h *Handlers) CreateAPIKey(w http.ResponseWriter, r *http.Request) { } } - log.Printf("Created API key: %s (%s)", key.KeyID, key.Name) + //nolint:gosec // G706: key.Name is unvalidated body text sanitized via logSafe (strips CR/LF), matching the sibling key handlers; key.KeyID is server-minted. + log.Printf("Created API key: %s (%s)", key.KeyID, logSafe(key.Name)) resp := key.ToResponse() writeJSON(w, http.StatusOK, models.APIKeyCreated{ diff --git a/internal/sched/handlers/learned_instructions.go b/internal/sched/handlers/learned_instructions.go index 36a3344f1..f30072753 100644 --- a/internal/sched/handlers/learned_instructions.go +++ b/internal/sched/handlers/learned_instructions.go @@ -54,6 +54,15 @@ func (h *Handlers) SubmitFeedback(w http.ResponseWriter, r *http.Request) { if !ok { return } + // taskFromPath is lookup only (see its doc) — the authorization decision is + // the caller's. Own-rows: without this any `client`-role principal could + // down-vote a teammate's task with an attacker-authored critique, which + // maybeDistill then feeds — together with the victim's prompt — into an LLM + // to mint a proposal on their task, at unmetered model spend. + if !taskVisibleToPrincipal(p, task) { + writeError(w, http.StatusNotFound, "Task not found") + return + } var req feedbackRequest if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error()) @@ -136,6 +145,14 @@ func (h *Handlers) LearnedInstructions(w http.ResponseWriter, r *http.Request) { if !ok { return } + // Own-rows, same reason as SubmitFeedback: the GET branch below discloses + // another principal's learned instructions, which are distilled from their + // task's prompt and critiques. 404 rather than 403 so the surface does not + // confirm that an unowned task id exists. + if !taskVisibleToPrincipal(p, task) { + writeError(w, http.StatusNotFound, "Task not found") + return + } versionStr := chi.URLParam(r, "version") switch { diff --git a/internal/sched/handlers/pause.go b/internal/sched/handlers/pause.go index 01da8496d..eb73ce3d1 100644 --- a/internal/sched/handlers/pause.go +++ b/internal/sched/handlers/pause.go @@ -116,6 +116,12 @@ func (h *Handlers) ListPausedTasks(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "Failed to list paused tasks") return } + // Own-rows visibility (#1082): ListPausedTasks selects by status alone, with + // no principal predicate in SQL, and the projection carries each task's + // prompt — so it has to be scoped here like GET /tasks, /tasks/export and + // /tasks/upcoming. Without this a `client`-role principal read every + // principal's paused prompts, and learned their task UUIDs besides. + tasks = visibleTasks(p, tasks) if tasks == nil { tasks = []*models.Task{} } diff --git a/internal/sched/handlers/principal_authz_test.go b/internal/sched/handlers/principal_authz_test.go index fc32508e1..3655f6431 100644 --- a/internal/sched/handlers/principal_authz_test.go +++ b/internal/sched/handlers/principal_authz_test.go @@ -83,13 +83,46 @@ func setupAuthzHandler(t *testing.T) (*storage.Storage, *apikeys.Manager, *chi.M } func mustCreateRoleKey(t *testing.T, keyMgr *apikeys.Manager, role string) string { + t.Helper() + _, raw := mustCreateRoleKeyWithID(t, keyMgr, role) + return raw +} + +// mustCreateRoleKeyWithID also returns the key's KeyID, so a test can attribute +// a task to the key (task.CreatedByKeyID) and exercise own-rows authorization +// on an API-key principal rather than only on a user principal. +func mustCreateRoleKeyWithID(t *testing.T, keyMgr *apikeys.Manager, role string) (string, string) { t.Helper() r := role - _, raw, err := keyMgr.CreateKey("test-"+role, nil, &r, 0, nil, "") + key, raw, err := keyMgr.CreateKey("test-"+role+"-"+uuid.NewString(), nil, &r, 0, nil, "") if err != nil { t.Fatalf("create key: %v", err) } - return raw + return key.KeyID, raw +} + +// addTaskCreatedByKey inserts a task attributed to the given API key. The +// column is written on insert (taskColumnRegistry), so it is set before AddTask. +func addTaskCreatedByKey(t *testing.T, store *storage.Storage, prompt, keyID string) *models.Task { + t.Helper() + return addTaskCreatedByKeyWithStatus(t, store, prompt, keyID, models.TaskStatusPending) +} + +// addTaskCreatedByKeyWithStatus is the same, at a chosen status — the paused +// queue selects on status alone, so its tests need rows already paused. +func addTaskCreatedByKeyWithStatus(t *testing.T, store *storage.Storage, prompt, keyID string, status models.TaskStatus) *models.Task { + t.Helper() + task := &models.Task{ + ID: uuid.New(), + Prompt: prompt, + Status: status, + CreatedAt: time.Now().UTC(), + CreatedByKeyID: &keyID, + } + if _, err := store.AddTask(task); err != nil { + t.Fatalf("add task: %v", err) + } + return task } func addTask(t *testing.T, store *storage.Storage, prompt string) *models.Task { @@ -160,20 +193,46 @@ func TestScopedAPIKeyAuthorization(t *testing.T) { } }) - t.Run("client key can edit an editable task", func(t *testing.T) { - // The client role carries create_task (which gates editing) but not - // cancel_task, so editing is the right op to test mutating authorization - // on a call a scoped key is actually permitted to make. - clientKey := mustCreateRoleKey(t, keyMgr, "client") + // Editing is own-rows, not merely permission-gated (taskWritableByPrincipal). + // The client role carries create_task (which admits it to the edit surface) + // but not cancel_task, so it is the right role to test WHICH task a scoped + // key may mutate. + t.Run("client key can edit a task it created", func(t *testing.T) { + keyID, clientKey := mustCreateRoleKeyWithID(t, keyMgr, "client") + + // Attributed to this key — the scoped-intake-app case that must keep working. + own := addTaskCreatedByKey(t, store, "a task this key created", keyID) body, _ := json.Marshal(models.TaskCreate{Prompt: "edited prompt that is sufficiently long"}) - req := httptest.NewRequest("PUT", "/tasks/"+taskA.ID.String(), bytes.NewReader(body)) + req := httptest.NewRequest("PUT", "/tasks/"+own.ID.String(), bytes.NewReader(body)) req.Header.Set("X-API-Key", clientKey) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { - t.Fatalf("client key edit should be 200, got %d: %s", w.Code, w.Body.String()) + t.Fatalf("client key editing its OWN task should be 200, got %d: %s", w.Code, w.Body.String()) + } + }) + + // The regression this pair exists for: PUT /tasks/{id} authorized on + // PermissionCreateTask alone, so a scoped key could rewrite a task it did + // not create — prompt, model, mcp_selection, credential_allowlist — while + // the READ path for the same row was already narrowed to own rows (#1082). + t.Run("client key cannot edit a task it did not create", func(t *testing.T) { + clientKey := mustCreateRoleKey(t, keyMgr, "client") + + body, _ := json.Marshal(models.TaskCreate{Prompt: "hijacked prompt that is long enough"}) + req := httptest.NewRequest("PUT", "/tasks/"+taskA.ID.String(), bytes.NewReader(body)) + req.Header.Set("X-API-Key", clientKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("client key must not edit an unowned task; got %d: %s", w.Code, w.Body.String()) + } + after, _ := store.GetTask(taskA.ID) + if after == nil || after.Prompt != "task A" { + t.Fatalf("a refused edit must leave the prompt alone, got %q", after.Prompt) } }) } diff --git a/internal/sched/handlers/task_authz.go b/internal/sched/handlers/task_authz.go index aeceea3e3..85b982d8d 100644 --- a/internal/sched/handlers/task_authz.go +++ b/internal/sched/handlers/task_authz.go @@ -60,3 +60,27 @@ func visibleTasks(p principal, tasks []*models.Task) []*models.Task { } return out } + +// taskWritableByPrincipal reports whether the principal may MUTATE the given +// task's definition (edit, retag). The mutating permission (PermissionCreateTask, +// checked by the caller) admits it to the surface; this decides WHICH task. +// +// Same own-rows model as taskVisibleToPrincipal, and deliberately the same +// helper pair: a write surface must be no looser than the read surface guarding +// the same row. Before this existed, PUT /tasks/{id} and POST /tasks/{id}/tags +// authorized on PermissionCreateTask alone, so any client-role user or scoped +// task key could rewrite ANY task on the box — prompt, model, mcp_selection and +// credential_allowlist included — while the read path had already been narrowed +// to own rows by #1082. That asymmetry was the hole. +// +// Note this is NOT principal.ownsTask, which resolves ownership through +// ownerID() and therefore returns false for every API-key principal. Using it +// here would deny a scoped intake-app key the right to edit the task it just +// created. taskCreatedByPrincipal matches a creating user OR a creating key +// (task.CreatedByKeyID), which is the model #980/#1082 established. +func taskWritableByPrincipal(p principal, task *models.Task) bool { + if p.fleetWideTaskVisibility() { + return true + } + return taskCreatedByPrincipal(p, task) +} diff --git a/internal/sched/handlers/task_write_authz_test.go b/internal/sched/handlers/task_write_authz_test.go new file mode 100644 index 000000000..2b572054b --- /dev/null +++ b/internal/sched/handlers/task_write_authz_test.go @@ -0,0 +1,176 @@ +// Copyright (c) 2025 ElcanoTek +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/ElcanoTek/fleet/internal/sched/models" +) + +// Own-rows regression tests for three surfaces that authorized on a permission +// alone while the read path guarding the same rows had already been narrowed to +// own rows (#980/#1082): +// +// - GET /tasks/paused — returned every principal's rows +// - POST /tasks/{id}/feedback — wrote to any principal's task +// - GET /tasks/{id}/learned-instructions — read any principal's instructions +// +// PUT /tasks/{id} and POST /tasks/{id}/tags are covered by +// TestScopedAPIKeyAuthorization in principal_authz_test.go. + +// taskAuthzRouter wires only the routes under test, behind the same middleware +// the server uses, so each request carries a real principal. +func taskAuthzRouter(t *testing.T) (*chi.Mux, *Handlers, func()) { + t.Helper() + store, keyMgr, _, cleanup := setupAuthzHandler(t) + h := New(Config{ + DefaultTaskModel: "test/model", + OrchestratorURL: "http://localhost:8000", + AdminAPIKey: "test-admin-key", + Version: "0.1.0", + }, store, keyMgr) + r := chi.NewRouter() + r.Group(func(r chi.Router) { + r.Use(h.AdminOrUserAuthMiddleware) + r.Get("/tasks/paused", h.ListPausedTasks) + r.Post("/tasks/{task_id}/feedback", h.SubmitFeedback) + r.Get("/tasks/{task_id}/learned-instructions", h.LearnedInstructions) + }) + return r, h, cleanup +} + +func decodeTasks(t *testing.T, body []byte) []*models.Task { + t.Helper() + var got struct { + Tasks []*models.Task `json:"tasks"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("decode: %v", err) + } + return got.Tasks +} + +// A paused task carries its prompt in the projection, and ListPausedTasks +// selects on status alone with no principal predicate in SQL. Before the fix a +// client-role key read every principal's paused prompts — and their task UUIDs. +func TestListPausedTasksIsScopedToOwnRows(t *testing.T) { + r, h, cleanup := taskAuthzRouter(t) + defer cleanup() + + keyID, rawKey := mustCreateRoleKeyWithID(t, h.apiKeys, "client") + mine := addTaskCreatedByKeyWithStatus(t, h.storage, "my own paused prompt", keyID, models.TaskStatusPausedAwaitingInput) + + otherKeyID, _ := mustCreateRoleKeyWithID(t, h.apiKeys, "client") + theirs := addTaskCreatedByKeyWithStatus(t, h.storage, "SOMEONE ELSE secret prompt", otherKeyID, models.TaskStatusPausedAwaitingInput) + + req := httptest.NewRequest("GET", "/tasks/paused", nil) + req.Header.Set("X-API-Key", rawKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET /tasks/paused = %d, want 200: %s", w.Code, w.Body.String()) + } + + tasks := decodeTasks(t, w.Body.Bytes()) + var sawMine bool + for _, task := range tasks { + if task.ID == theirs.ID { + t.Fatalf("another principal's paused task leaked into the queue: %q", task.Prompt) + } + if task.ID == mine.ID { + sawMine = true + } + } + if !sawMine { + t.Fatal("the principal's OWN paused task must still be listed — otherwise this asserts nothing") + } +} + +// An admin must still see the whole queue: a fleet-wide view is the point of the +// "needs a human answer" surface. +func TestListPausedTasksAdminSeesEveryRow(t *testing.T) { + r, h, cleanup := taskAuthzRouter(t) + defer cleanup() + + otherKeyID, _ := mustCreateRoleKeyWithID(t, h.apiKeys, "client") + theirs := addTaskCreatedByKeyWithStatus(t, h.storage, "someone's paused prompt", otherKeyID, models.TaskStatusPausedAwaitingInput) + + req := httptest.NewRequest("GET", "/tasks/paused", nil) + req.Header.Set("X-API-Key", "test-admin-key") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("admin GET /tasks/paused = %d, want 200: %s", w.Code, w.Body.String()) + } + for _, task := range decodeTasks(t, w.Body.Bytes()) { + if task.ID == theirs.ID { + return + } + } + t.Fatal("an admin must see other principals' paused tasks") +} + +// Feedback writes to the task and can trigger LLM distillation against the +// victim's prompt, so it is own-rows. 404 rather than 403 so the surface does +// not confirm that an unowned task id exists. +func TestFeedbackAndLearnedInstructionsAreScopedToOwnRows(t *testing.T) { + r, h, cleanup := taskAuthzRouter(t) + defer cleanup() + + _, rawKey := mustCreateRoleKeyWithID(t, h.apiKeys, "client") + otherKeyID, _ := mustCreateRoleKeyWithID(t, h.apiKeys, "client") + theirs := addTaskCreatedByKey(t, h.storage, "someone else's prompt", otherKeyID) + + body, _ := json.Marshal(map[string]string{"rating": models.FeedbackDown, "critique": "attacker-authored critique"}) + req := httptest.NewRequest("POST", "/tasks/"+theirs.ID.String()+"/feedback", bytes.NewReader(body)) + req.Header.Set("X-API-Key", rawKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("feedback on an unowned task = %d, want 404: %s", w.Code, w.Body.String()) + } + + req = httptest.NewRequest("GET", "/tasks/"+theirs.ID.String()+"/learned-instructions", nil) + req.Header.Set("X-API-Key", rawKey) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("learned-instructions on an unowned task = %d, want 404: %s", w.Code, w.Body.String()) + } +} + +// The owning principal must still be able to use both surfaces. +func TestFeedbackOnOwnTaskIsAllowed(t *testing.T) { + r, h, cleanup := taskAuthzRouter(t) + defer cleanup() + + keyID, rawKey := mustCreateRoleKeyWithID(t, h.apiKeys, "client") + mine := addTaskCreatedByKey(t, h.storage, "my own prompt", keyID) + + body, _ := json.Marshal(map[string]string{"rating": models.FeedbackUp}) + req := httptest.NewRequest("POST", "/tasks/"+mine.ID.String()+"/feedback", bytes.NewReader(body)) + req.Header.Set("X-API-Key", rawKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("feedback on own task = %d, want 200: %s", w.Code, w.Body.String()) + } + + req = httptest.NewRequest("GET", "/tasks/"+mine.ID.String()+"/learned-instructions", nil) + req.Header.Set("X-API-Key", rawKey) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("learned-instructions on own task = %d, want 200: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/sched/handlers/upload.go b/internal/sched/handlers/upload.go index 5a36d564c..9ce7ee265 100644 --- a/internal/sched/handlers/upload.go +++ b/internal/sched/handlers/upload.go @@ -194,10 +194,12 @@ func (h *Handlers) HandleUpload(w http.ResponseWriter, r *http.Request) { checksumPath := filepath.Join(tempDir, ".checksums", filename+".sha256") if err := os.WriteFile(checksumPath, []byte(checksum), 0600); err != nil { // Non-critical error, just log it - log.Printf("Failed to save checksum sidecar for %s: %v", filename, err) + //nolint:gosec // G706: filename is sanitized via logSafe (strips CR/LF) and already passed sanitizeFilename; gosec's taint tracker cannot see through the helper. + log.Printf("Failed to save checksum sidecar for %s: %v", logSafe(filename), err) } - log.Printf("File uploaded: %s (size: %d, checksum: %s)", filename, size, checksum) + //nolint:gosec // G706: filename is sanitized via logSafe (strips CR/LF) and already passed sanitizeFilename; size is an int and checksum is hex. + log.Printf("File uploaded: %s (size: %d, checksum: %s)", logSafe(filename), size, checksum) writeJSON(w, http.StatusOK, map[string]interface{}{ "filename": filename, diff --git a/internal/sched/models/models.go b/internal/sched/models/models.go index a40e1840e..86ab35ede 100644 --- a/internal/sched/models/models.go +++ b/internal/sched/models/models.go @@ -297,6 +297,25 @@ func (wc *WorktreeConfig) Validate() error { strings.Contains(wc.BranchPrefix, ".lock") { return fmt.Errorf("branch_prefix is not a valid git ref-name fragment") } + // BaseBranch is the trailing positional of `git worktree add -b + // `, and that invocation carries no "--" end-of-options + // separator, so a value beginning with "-" would be parsed by git as an + // option rather than a commit-ish. Reject that shape here — worktree_config + // is settable by any task creator (unlike run_if, which is admin-only), so + // this is the boundary. The ref-name checks mirror BranchPrefix above; git + // still makes the authoritative check at run time. + if base := strings.TrimSpace(wc.BaseBranch); base != "" { + if strings.HasPrefix(base, "-") { + return fmt.Errorf("base_branch may not begin with '-'") + } + if strings.ContainsAny(base, " ~^:?*[\\") || + strings.Contains(base, "@{") || + strings.Contains(base, "..") || + strings.Contains(base, "//") || + strings.Contains(base, ".lock") { + return fmt.Errorf("base_branch is not a valid git ref-name fragment") + } + } return nil } diff --git a/web/e2e/test-auth-key.ts b/web/e2e/test-auth-key.ts index cb2aedf66..d6de78b7b 100644 --- a/web/e2e/test-auth-key.ts +++ b/web/e2e/test-auth-key.ts @@ -48,8 +48,22 @@ export function generateTestAuthKey(): TestAuthKeyMaterial { }; // Atomic write so a worker reading concurrently never sees a half-written // file: write to a temp sibling, then rename. - const tmp = `${KEY_FILE}.${process.pid}.tmp`; - fs.writeFileSync(tmp, JSON.stringify(material), { encoding: "utf8" }); + // + // The sibling name carries random bytes, and the write is `wx` (O_CREAT|O_EXCL) + // at mode 0600. os.tmpdir() is world-writable and the old name was + // `${KEY_FILE}.${pid}.tmp` — fully predictable, so a local user could + // pre-create it as a symlink and turn this into an arbitrary-file write as the + // test user, and the default 0644 left the private half world-readable. + // O_EXCL refuses to follow or clobber a pre-planted path; rename(2) acts on the + // link itself, so a hostile KEY_FILE symlink is replaced rather than written + // through. The key is throwaway and protects nothing real — this is hygiene on + // a private-key write, not a fix for a reachable compromise. + const tmp = `${KEY_FILE}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(material), { + encoding: "utf8", + mode: 0o600, + flag: "wx", + }); fs.renameSync(tmp, KEY_FILE); return material; } From e90fc99b005d434c2cfd4cf157f04dfa416ec62b Mon Sep 17 00:00:00 2001 From: Brad Flaugher Date: Sat, 22 Aug 2026 15:55:50 +0000 Subject: [PATCH 02/10] CodeQL: gate on High-and-above plus a reviewed accepted-findings register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate #1246 shipped blocked every push to dev and main. Its threshold was "any finding at any severity", justified by a measured zero across all four languages — but that measurement came from Dev CI run 525, a `pull_request` event, and on pull_request events the CodeQL action runs DIFF-INFORMED: it builds the full database, evaluates every query, then reports only results located inside the PR's diff. Run 525's own log says both halves out loud ("Persisted 204 diff range(s) across 43 file(s)", and "file coverage information is only enabled when analyzing the default branch and protected branches"). The Go database held all 428 files and TaintedPath/RequestForgery/LogInjection/ WeakSensitiveDataHashing all ran; the SARIF was empty because results outside the 43 changed files were dropped. So the first full-tree evaluation was the push that merged #1246 — run 527 — which reported 38 Go and 17 javascript-typescript findings and turned Dev gate red, with no PR-shaped way out: a PR into dev is scanned diff-informed and stays green while dev itself stays red. The generalizable lesson, now written into the file and the ADR: a PR-event CodeQL run certifies a diff, not a tree. Any "the scanners are green, therefore the tree is clean" claim resting on one is unsound, and that is permanent behavior, not a bug. New threshold: a finding blocks when its SARIF level is error/warning or its rule carries security-severity >= 7.0, unless it is waived. Below that band findings are printed and go to the Security tab as advisory. Severity alone is not sufficient to separate the false positives, which is why the register exists: go/request-forgery is 9.1 and fires on web_fetch.go, a deliberate user-facing fetch tool behind netguard's resolve-then-dial SSRF guard; go/weak-sensitive-data-hashing is 7.5 and fires on SHA-256 used as a lookup index over a 32-byte crypto/rand token, which is the recommended construction. .github/codeql-accepted-findings.json registers accepted (rule, file) pairs with a mandatory written reason. Per-FILE, not per-rule, and that is the point of preferring it to a query-filters exclude: an exclude switches a 9.1 query off repo-wide, while the register waives it in the two files that were adjudicated and leaves it live everywhere else. A synthetic SARIF carrying a fresh go/request-forgery in an unregistered file fails the gate — verified while developing the jq, along with the clean, notes-only, mixed-severity, in-source-suppressed, missing-SARIF, malformed-SARIF and missing-register cases. The gate fails closed twice over: a missing register and an unevaluatable jq both refuse to report the scan clean, rather than reading as zero. Anti-rot controls, because a register nobody re-reads is worse than none: - scripts/check_codeql_register_test.go (in make test) requires every entry to name a file that exists, carry a substantive reason, use a plausible rule id, and be unique — and asserts codeql.yml still references the register, so the two cannot be silently decoupled. Mutation-tested: a bogus rule id, a missing file and a one-word reason all fail it. - The log and step summary print three tiers — BLOCKING, ACCEPTED and ADVISORY — so every waiver appears in ordinary CI output instead of only in a file someone has to think to open. Also corrects the claims this file made about itself, which an auditor reads as documentation: - the "reports ZERO ... a finding here is new" premise (false); - "They cannot be part of CI gate" two paragraphs after correctly explaining that the calling job is in the gate's needs; - "whether a red check BLOCKS a merge is branch protection's call", which contradicted the header — and now records the real caveat, that the dev ruleset requires no status checks so Dev gate is red-but-not-required there; - the summarize step's "gating on findings is merge protection's job, not this step's", directly above the step that gates; - dev-ci.yml's "makes green mean clean, not just ran", now qualified for pull_request events. ADR-0048 records the decision, what gets worse (a note-level regression no longer fails the build — gosec's G706 still covers the log-injection class through golangci-lint, which does block), and the sharpest edge ( the register keys on rule+file, not rule+file+line, so a second bad instance in an already-waived file would not block; line keys churn on every edit and a register that fails on unrelated refactors is one people delete). Signed-off-by: Brad Flaugher --- .github/codeql-accepted-findings.json | 90 +++++++++++ .github/workflows/codeql.yml | 189 +++++++++++++++++------- .github/workflows/dev-ci.yml | 10 +- docs/adr/0048-codeql-severity-gating.md | 157 ++++++++++++++++++++ scripts/check_codeql_register_test.go | 137 +++++++++++++++++ 5 files changed, 527 insertions(+), 56 deletions(-) create mode 100644 .github/codeql-accepted-findings.json create mode 100644 docs/adr/0048-codeql-severity-gating.md create mode 100644 scripts/check_codeql_register_test.go diff --git a/.github/codeql-accepted-findings.json b/.github/codeql-accepted-findings.json new file mode 100644 index 000000000..f2fac66d0 --- /dev/null +++ b/.github/codeql-accepted-findings.json @@ -0,0 +1,90 @@ +{ + "$schema-note": [ + "Register of CodeQL findings that are accepted as false positives in fleet's", + "threat model. Consumed by .github/workflows/codeql.yml's `Fail on findings`", + "step: a finding whose (rule, file) pair appears here does not block the", + "build. Everything else at level error/warning, or security-severity >= 7.0,", + "does.", + "", + "WHY A REGISTER AND NOT query-filters OR AN IGNORED PATH:", + "a query-filter `exclude` switches the rule off for the whole repository, so", + "a genuine future instance of go/request-forgery (security-severity 9.1)", + "would never be reported again. An entry here waives ONE rule in ONE file and", + "leaves the query live everywhere else — including elsewhere in the same", + "package. The findings still upload to the Security tab either way; this only", + "governs whether CI blocks.", + "", + "RULES FOR EDITING:", + " - One entry per (rule, file). `reason` is mandatory and must say why the", + " finding cannot be exploited HERE, not that the rule is noisy.", + " - Widening this file is a security decision. It belongs in the PR diff and", + " the reviewer is expected to check the reason against the code.", + " - An entry is not a permanent waiver. The weekly scheduled scan reports", + " entries that no longer match any finding so a stale waiver gets removed", + " rather than quietly widening coverage loss.", + " - Fixing the code is always preferred to adding an entry.", + "", + "PROVENANCE: every reason below was derived by reading the flagged code during", + "the audit recorded in docs/adr/0048-codeql-severity-gating.md. The 55", + "findings that the first full-tree scan surfaced (Dev CI run 527) were triaged", + "individually; the four that were reachable were FIXED in code, not accepted." + ], + + "accepted": [ + { + "rule": "go/request-forgery", + "file": "internal/tools/web_fetch.go", + "reason": "FetchURLForContext is the deliberate @url composer-handle fetch — a user-requested outbound GET is the feature, so the taint is by design. It dials through newSSRFGuardedDialer(), whose net.Dialer.Control hook runs after DNS resolution on EVERY dial and refuses netguard.IsBlockedIP: loopback, RFC1918, ULA, link-local (incl. 169.254.169.254), multicast, unspecified, RFC 6598 CGNAT (the Alibaba/Oracle 100.100.100.x metadata range), TEST-NET, RFC 2544 and 240.0.0.0/4. IPv4-mapped IPv6 is normalized first and a nil IP fails closed. Because the check is per-dial rather than per-save, DNS rebinding is closed too, and redirect hops re-dial through the same hook. http.Transport refuses any scheme but http/https, Go's 10-redirect cap applies, and the body is capped at 5 MiB. internal/netguard is the single source of truth with a 24-case regression matrix in netguard_test.go." + }, + { + "rule": "go/request-forgery", + "file": "internal/mcpoauth/discovery.go", + "reason": "These two URLs are an operator-typed MCP server URL and the remote-derived pointers reached from it (a WWW-Authenticate resource_metadata= parameter, a PRM-declared issuer). Every request uses mcpoauth.SafeHTTPClient (wired at remotemcp/service.go), whose safeDialContext resolves, rejects blocked IPs, then dials the exact validated IP — closing the resolve-to-connect TOCTOU — and whose CheckRedirect hard-fails so a 30x can never relay a bearer to a new origin. CanonicalResourceURI rejects a non-http(s) scheme, embedded userinfo, or a hostless URL before this point, fetchJSON now refuses a non-http(s) scheme by name, and maxMetadataBytes caps the body at 1 MiB. Mix-up defenses are downstream: verifyAuthServer rejects a missing or mismatched issuer and refuses a non-S256 PKCE downgrade, and Discover adopts a PRM-declared resource only when sameOrigin." + }, + { + "rule": "go/path-injection", + "file": "internal/agent/session.go", + "reason": "The value is sanitized one frame up and CodeQL loses the sanitizer across a struct-field and package boundary (chatAttachment -> agent.ImageAttachment -> TurnInput). httpapi/chat.go calls validateAttachments, which filepath.Abs+Clean's the client path, takes filepath.Rel(root, abs), rejects it unless filepath.IsLocal(rel), and then REBUILDS the path as filepath.Join(root, rel), storing only that. attachments.go is the only construction site of agent.ImageAttachment in the tree, so no unvalidated path can reach these os.Stat/os.ReadFile calls. The caller contract is documented at loadImageAttachments because the guard lives in the producer, not here." + }, + { + "rule": "go/weak-sensitive-data-hashing", + "file": "internal/sched/apikeys/apikeys.go", + "reason": "SHA-256 is a lookup INDEX over a full-entropy random token, not a password hash. generateKey mints the key from 32 crypto/rand bytes (\"sk-\" + base64url), so there is no guessable preimage to iterate and a KDF would add per-request cost without adding security. This is the standard construction for bearer-token storage." + }, + { + "rule": "go/weak-sensitive-data-hashing", + "file": "internal/sched/handlers/handlers.go", + "reason": "Both call sites hash only to equalize length before subtle.ConstantTimeCompare — the digests are compared in memory and never stored. That is the standard defense against deducing secret length from comparison timing, and the handler already fails closed when AdminAPIKey is unset." + }, + { + "rule": "go/weak-sensitive-data-hashing", + "file": "internal/store/users.go", + "reason": "The digest is taken over the BCRYPT HASH, not the password, to derive an 8-byte session-revocation epoch. The password is bcrypt'd elsewhere; this input already carries bcrypt's 128-bit random salt, and the epoch is a generation counter that /auth/verify would never accept as a credential. The reasoning is written out at the call site and sessionEpochExpr pins the SQL twin." + }, + { + "rule": "go/clear-text-logging", + "file": "cmd/fleet/main.go", + "reason": "Field-insensitive taint through ProviderConfig, the same misattribution already recorded in the //nolint:gosec at main.go:1276. All three sinks log only a wrapped boot error. The plausible flow (a decrypted admin-managed ProviderConfig.APIKey reaching resolver.go's fmt.Errorf) is unreachable: anthropic.New and openai.New in charm.land/fantasy always return a nil error, so the only errors that arm can produce are buildProvider's own literal strings. The MCP-broker and reload sinks return only clientconfig/store errors, whose bundle-config messages quote variable NAMES, never values, per the manifest doctrine in AGENTS.md." + }, + { + "rule": "go/clear-text-logging", + "file": "internal/admincli/import.go", + "reason": "Field-insensitive taint through the legacy-export struct, which happens to carry a password_hash field. stats.warnings is populated by ten warnf call sites and not one touches a secret — they carry conversation IDs, MCP server names, persona names, roles, timezones and recurrence strings. u.PasswordHash is read at exactly one place, which prints only u.Username and u.ID. The sink is the operator's own terminal." + }, + { + "rule": "go/clear-text-logging", + "file": "internal/agent/scheduled.go", + "reason": "Logs an internal run error, and agentcore's boundary errors are deliberately opaque — containedBoundaryError surfaces only the incident ID, never the recovered value or a stack. The string is additionally passed through agentcore.RedactSecrets before both this log and the persisted transcript." + }, + { + "rule": "js/remote-property-injection", + "file": "web/src/app/chat/ui/useTurnStream.ts", + "reason": "Two independent reasons, either sufficient. (1) Every flagged sink is keyed by a conversation slot id (ctx.target or convId) and by nothing else; no model-authored payload field ever reaches a key position. That id space is server-minted uuid.NewString() from store.CreateConversation — a client-supplied conversation_id is never inserted, it must resolve to an existing row owned by the caller or the request 404s — so neither a client nor the model can choose the key. (2) The sink shapes cannot reach Object.prototype anyway: an object-literal computed key performs CreateDataPropertyOrThrow, producing an OWN \"__proto__\" property and leaving the prototype untouched, and bracket assignment rebinds at most the one local record object. Worst achievable impact, given an operator-imported hostile id via the admin-CLI import path, is self-inflicted state confusion in one browser tab." + }, + { + "rule": "js/insecure-temporary-file", + "file": "web/e2e/test-auth-key.ts", + "reason": "Test-only, and the reported defect is fixed as far as it can be without changing the cross-process rendezvous contract: the write is now O_EXCL (flag \"wx\") at mode 0600 with crypto random bytes in the sibling name, so it cannot follow or clobber a pre-planted symlink and does not leave the private half world-readable. The query recognizes only mkdtemp as safe, but the fixed path is a deliberate rendezvous — playwright.config.ts is loaded in the main process AND re-imported in every worker, which must all read the same throwaway keypair. The key is generated per run, protects nothing real, and is never committed." + } + ] +} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 00651bec7..f95bbf069 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -63,12 +63,14 @@ # Grype step, govulncheck-scheduled.yml, grype-scheduled.yml). Those push their # own SARIF to the Security tab and never depended on CodeQL being configured. # -# Merge gating, in two parts — both now closed: +# Merge gating, in two parts — both closed: # -# 1. Does a finding turn the CHECK red? YES. The `Fail on findings` step below -# fails the job on any finding. Without it the analyze step exits 0 whether -# it found nothing or a hundred alerts, so a red check could only ever mean -# "the scanner broke". +# 1. Does a finding turn the CHECK red? YES, for a finding in the blocking +# band — see the `Fail on findings` step for the threshold and +# docs/adr/0048-codeql-severity-gating.md for why it is that band and not +# "any finding". Without that step the analyze step exits 0 whether it found +# nothing or a hundred alerts, so a red check could only ever mean "the +# scanner broke". # 2. Does a red check BLOCK a merge? YES, through the EXISTING required check. # This is a REUSABLE workflow (`on: workflow_call`): ci.yml and dev-ci.yml # each call it as a job, and a job that calls a reusable workflow can sit in @@ -78,13 +80,17 @@ # `needs` cannot cross workflow FILES, but a workflow_call brings the jobs # into the caller's file. # -# They cannot be part of `CI gate`: a job's `needs` cannot reach across workflow -# files. So this file carries its own aggregate `CodeQL gate` job at the bottom, -# for the same reason ci.yml and dev-ci.yml carry theirs — it is the ONE check to -# name in branch protection if CodeQL should ever become blocking, instead of -# four per-language checks that would have to be re-pointed by hand every time -# the matrix changes. Adding it here does not make it required; that is a -# repo-settings decision, deliberately not expressible from this file. +# CAVEAT, and it is load-bearing: that routing only blocks a merge where the +# aggregate gate is a REQUIRED status check. On `main` it is (`CI gate`). +# On `dev` the ruleset requires no status checks at all, so `Dev gate` is +# red-but-not-required there — see docs/SCANNING.md ("Known gaps"). +# +# The per-language `analyze` legs cannot be named directly in `CI gate`'s +# `needs` — a job's `needs` cannot reach across workflow files — but the CALLING +# job can be, and is. So the aggregate `CodeQL gate` job at the bottom of this +# file exists for the standalone schedule/dispatch runs (one legible verdict per +# weekly re-scan instead of four boxes) and as a stable single check name to put +# in a ruleset, not because the workflow_call path needs it. # See docs/CODEQL.md ("Merge gating"). name: CodeQL @@ -129,10 +135,13 @@ jobs: # supported for Go — only `autobuild` or `manual` — so the toolchain # has to be right rather than skipped. # security-extended everywhere: the broader security suite (more - # queries, lower average precision than the default). Measured before - # adoption like every other gate here — the run's own findings - # summary is the measurement, and the Fail-on-findings step means - # anything it surfaces must be fixed or reasoned away, not accrued. + # queries, lower average precision than the default). The adoption + # measurement was taken on a `pull_request` run, where CodeQL is + # DIFF-INFORMED and reports only results inside the PR's diff — so it + # measured the diff, not the tree, and read as zero when the tree held + # 55 findings. Do not re-derive a tree-wide claim from a PR run; the + # full-tree numbers come from push/schedule runs. See + # docs/adr/0048-codeql-severity-gating.md. - language: go build-mode: autobuild queries: security-extended @@ -201,15 +210,16 @@ jobs: # reading CI output, to `gh run view`, and to any automation that has # the log but not the code-scanning API. # - # This mirrors what govulncheck-scheduled.yml already does for its own - # SARIF: jq a per-rule count into the job log AND the step summary, so - # the run is readable without leaving Actions. It is reporting only and - # never fails the job — gating on findings is code scanning merge - # protection's job (see docs/CODEQL.md), not this step's. + # The listing is split into the SAME three tiers the next step gates on, + # so the log says not just what was found but which part of it blocks — + # and, importantly, prints the ACCEPTED tier by name. A waiver that is + # invisible in CI output is a waiver nobody re-reads. Reporting only; the + # `Fail on findings` step below is what fails the job. if: ${{ !cancelled() }} env: SARIF_DIR: ${{ runner.temp }}/codeql-sarif LANGUAGE: ${{ matrix.language }} + ACCEPTED_FILE: ${{ github.workspace }}/.github/codeql-accepted-findings.json # The database directory is named after the EXTRACTOR, which is not # always the matrix language: javascript-typescript -> javascript. CODEQL_DB: ${{ matrix.language == 'javascript-typescript' && 'javascript' || matrix.language }} @@ -229,19 +239,42 @@ jobs: echo '```' # `-s` slurps every SARIF doc into one array, so a language that # emits more than one file is still counted once, in total. - jq -rs ' - [ .[] | .runs[]? | .results[]? ] as $res - | if ($res | length) == 0 then "No findings." - else - ( $res - | map( - "[\(.level // "note")] \(.ruleId) " - + ((.locations[0].physicalLocation // {}) as $l - | "\($l.artifactLocation.uri // "?"):\($l.region.startLine // "?")") - ) - | sort | join("\n") - ) + "\n--\ntotal findings: \($res | length)" - end + # + # security-severity lives on the RULE, not the result, so each + # result is joined back to its rule in the same run's + # tool.driver.rules[] to recover it. Absent -> 0, which is + # correct: a query with no security-severity is not a High. + jq -rs --slurpfile reg "$ACCEPTED_FILE" ' + ( reduce ($reg[0].accepted[]? | "\(.rule) \(.file)") as $k ({}; .[$k] = true) ) as $waived + | [ .[] | .runs[]? + | ( [ .tool.driver.rules[]? ] ) as $rules + | .results[]? + | . as $r + | ( [ $rules[] | select(.id == $r.ruleId) + | .properties["security-severity"] ][0] // "" ) as $sev + | ( ($r.locations[0].physicalLocation.artifactLocation.uri) // "" ) as $file + | ( ($r.level) // "note" ) as $level + | { level: $level, + sev: (($sev | tonumber? // 0)), + rule: $r.ruleId, + loc: "\($file):\(($r.locations[0].physicalLocation.region.startLine) // "?")", + waived: ( (($waived | has("\($r.ruleId) \($file)"))) + or ((($r.suppressions // []) | length) > 0) ), + high: ( $level == "error" or $level == "warning" or (($sev | tonumber? // 0) >= 7.0) ) } + ] as $all + | ( $all | map(select(.high and (.waived | not))) ) as $b + | ( $all | map(select(.high and .waived)) ) as $w + | ( $all | map(select(.high | not)) ) as $n + | "BLOCKING — High+ and not registered (\($b|length)):", + ( if ($b|length) == 0 then " none" else ($b | sort_by(.rule,.loc) | .[] | " [\(.level)] sec-sev=\(.sev) \(.rule) \(.loc)") end ), + "", + "ACCEPTED — High+ waived in codeql-accepted-findings.json or in-source (\($w|length)):", + ( if ($w|length) == 0 then " none" else ($w | sort_by(.rule,.loc) | .[] | " [\(.level)] sec-sev=\(.sev) \(.rule) \(.loc)") end ), + "", + "ADVISORY — below High, triage in the Security tab (\($n|length)):", + ( if ($n|length) == 0 then " none" else ($n | sort_by(.rule,.loc) | .[] | " [\(.level)] sec-sev=\(.sev) \(.rule) \(.loc)") end ), + "", + "totals: \($all|length) finding(s) — \($b|length) blocking, \($w|length) accepted, \($n|length) advisory" ' "${files[@]}" echo '```' # COVERAGE, not just the verdict: "No findings." alone cannot be @@ -268,41 +301,91 @@ jobs: # code has a problem", which is precisely how the Go toolchain break sat # unnoticed behind a red-but-not-required check for weeks. # - # Threshold is ANY finding, deliberately. The security suite currently - # reports ZERO across go/python/javascript-typescript/actions, so there - # is no backlog to grandfather and no severity line to argue about — a - # finding here is new. Switching a gate on over an existing backlog is - # how a gate becomes something people route around. + # THRESHOLD, in three parts: + # 1. A finding blocks when its SARIF level is `error`/`warning`, or its + # rule carries security-severity >= 7.0 (CodeQL's High/Critical + # band). Note-level findings below High are printed and uploaded to + # the Security tab; they do not block. + # 2. A finding whose (rule, file) pair appears in + # .github/codeql-accepted-findings.json does not block. Each entry + # carries a written reason. The waiver is per-FILE, so the rule + # stays live everywhere else — a new go/request-forgery in a + # different file still fails the build. + # 3. An in-source `// codeql[rule-id]` suppression is honored (CodeQL + # emits it as a `suppressions` array on the result). # - # Runs after the summary so the log leads with WHAT was found. + # WHY NOT "any finding" — that was tried in #1246, and it deadlocked the + # repo. That gate was armed on a measurement of ZERO across all four + # languages, but the measurement came from a `pull_request` run (Dev CI + # run 525), and on pull_request events the CodeQL action runs + # DIFF-INFORMED: it builds the full database and evaluates every query, + # then reports only results located inside the PR's diff. Run 525's own + # log says so — "Persisted 204 diff range(s) across 43 file(s)", and + # "file coverage information is only enabled when analyzing the default + # branch and protected branches". + # + # So the first full-tree scan was the PUSH that merged it (Dev CI run + # 527), which reported 38 Go and 17 javascript-typescript findings and + # turned `Dev gate` red with no PR to fix it through. The any-finding + # threshold was never armed over a clean tree; it was armed over a + # 55-finding backlog nobody had measured yet. # - # NOTE ON SCOPE: this makes the CHECK red. Whether a red check BLOCKS a - # merge is branch protection's call — `CodeQL gate` has to be a required - # status check for that, which is a repo-settings action a workflow file - # cannot perform. See docs/CODEQL.md ("Merge gating"). + # Those 55 were triaged individually. Four were reachable and are FIXED + # in code, not accepted — the task-create log line whose update-path twin + # was already sanitized, two pre-validation client paths on reject + # branches, a client-echoed attachment name, and a world-readable + # private-key write in the e2e harness. The rest are false positives in + # fleet's threat model and are registered with their reasons. + # + # Runs after the summary so the log leads with WHAT was found. + # See docs/CODEQL.md ("Merge gating") and + # docs/adr/0048-codeql-severity-gating.md. if: ${{ !cancelled() }} env: SARIF_DIR: ${{ runner.temp }}/codeql-sarif LANGUAGE: ${{ matrix.language }} + # GITHUB_WORKSPACE, not ${{ github.workspace }} interpolated into the + # run: block — same reason the summary step uses RUNNER_TEMP. + ACCEPTED_FILE: ${{ github.workspace }}/.github/codeql-accepted-findings.json run: | set -uo pipefail shopt -s nullglob files=("$SARIF_DIR"/*.sarif) if [ ${#files[@]} -eq 0 ]; then - # No SARIF means the analysis did not produce results to judge. Fail - # loudly rather than reporting a clean scan that never happened. echo "::error::No SARIF written for $LANGUAGE — cannot conclude the scan was clean." exit 1 fi - count=$(jq -rs '[ .[] | .runs[]? | .results[]? ] | length' "${files[@]}") - if [ "$count" != "0" ]; then - echo "::error::CodeQL found ${count} finding(s) for ${LANGUAGE} — see the summary above." - echo "Fix it, or if it is a false positive dismiss the alert in the" - echo "Security tab with a reason, or add a query filter with a comment" - echo "saying why. Silently raising the threshold is not one of the options." + if [ ! -f "$ACCEPTED_FILE" ]; then + echo "::error::accepted-findings register $ACCEPTED_FILE is missing — refusing to gate without it." + exit 1 + fi + blocking=$(jq -rs --slurpfile reg "$ACCEPTED_FILE" ' + ( reduce ($reg[0].accepted[]? | "\(.rule) \(.file)") as $k ({}; .[$k] = true) ) as $waived + | [ .[] | .runs[]? + | ( [ .tool.driver.rules[]? ] ) as $rules + | .results[]? + | . as $r + | ( [ $rules[] | select(.id == $r.ruleId) + | .properties["security-severity"] ][0] // "" ) as $sev + | ( ($r.locations[0].physicalLocation.artifactLocation.uri) // "" ) as $file + | ( ($r.level) // "note" ) as $level + | ( "\($r.ruleId) \($file)" ) as $key + | select( (($r.suppressions // []) | length) == 0 ) + | select( ($waived | has($key)) | not ) + | select( $level == "error" or $level == "warning" or (($sev | tonumber? // 0) >= 7.0) ) + | " [\($level)] sec-sev=\($sev) \($r.ruleId) \($file):\(($r.locations[0].physicalLocation.region.startLine) // "?")" + ] | .[]' "${files[@]}") + rc=$? + if [ $rc -ne 0 ]; then + echo "::error::could not evaluate the SARIF for $LANGUAGE — refusing to report it clean." + exit 1 + fi + if [ -n "$blocking" ]; then + echo "::error::CodeQL found blocking finding(s) for ${LANGUAGE}:" + echo "$blocking" exit 1 fi - echo "CodeQL ($LANGUAGE): 0 findings." + echo "CodeQL ($LANGUAGE): 0 blocking findings." codeql-gate: name: CodeQL gate diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index b4153e6f1..6ef818a99 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -169,11 +169,15 @@ jobs: codeql: # Reusable-workflow call (see codeql.yml's header): puts the CodeQL jobs in - # THIS graph so `Dev gate` blocks on them. A finding fails the gate — the - # `Fail on findings` step inside makes green mean "clean", not just "ran". + # THIS graph so `Dev gate` blocks on them. A finding in the blocking band + # fails the gate, so green means "no unwaived High-and-above finding", not + # just "ran" — and on a `pull_request` event it means that of the DIFF only, + # because CodeQL is diff-informed there (docs/adr/0048-codeql-severity-gating.md). + # The tree-wide verdict comes from the push runs. # Unconditional (no docs-only detection in the fast lane, and Dev gate # demands strict success, so a skip would fail it); running on dev pushes - # too also covers any direct push that bypassed a PR. + # too also covers any direct push that bypassed a PR — and is the only event + # shape that scans the whole tree. permissions: contents: read security-events: write diff --git a/docs/adr/0048-codeql-severity-gating.md b/docs/adr/0048-codeql-severity-gating.md new file mode 100644 index 000000000..e4866b32e --- /dev/null +++ b/docs/adr/0048-codeql-severity-gating.md @@ -0,0 +1,157 @@ +# ADR-0048: CodeQL gates on High-and-above plus a reviewed accepted-findings register + +- **Status:** Accepted +- **Date:** 2026-08-22 +- **Deciders:** fleet maintainers +- **Amends:** the gating decision shipped in #1246 (`docs/CODEQL.md`, + `docs/SCANNING.md`) — the CodeQL threshold changes from *any finding* to + *High-and-above, minus a reviewed register*. No other scanner's threshold + changes. + +## Context + +#1246 restored CodeQL as **advanced setup** running `security-extended` over +go / python / javascript-typescript / actions, added a `Fail on findings` step, +and routed the result into `ci-gate` / `Dev gate` through `workflow_call`. All of +that was right and none of it is revisited here. + +The **threshold** was wrong, and it was wrong for an instructive reason. + +The step failed the job on *any* finding at any severity. Its own comment +recorded the justification: + +> Threshold is ANY finding, deliberately. The security suite currently reports +> ZERO across go/python/javascript-typescript/actions, so there is no backlog to +> grandfather and no severity line to argue about — a finding here is new. + +That zero was real, and it was measured — on Dev CI run 525, a `pull_request` +event. On `pull_request` events the CodeQL action runs **diff-informed**: it +builds the full database and evaluates every query, then reports only results +whose location falls inside the PR's diff. Run 525's own log says both halves +out loud: + +``` +Computing PR diff ranges... +Persisted 204 diff range(s) across 43 file(s). +Successfully created diff range extension pack at .../pr-diff-range +codeql database run-queries ... --extension-packs=codeql-action/pr-diff-range +``` +``` +To speed up pull request analysis, file coverage information is only enabled +when analyzing the default branch and protected branches. +``` + +The Go database held all 428 files and the queries that later fired did run — +`LogInjection.ql`, `TaintedPath.ql`, `RequestForgery.ql`, +`WeakSensitiveDataHashing.ql` are all listed as "Interpreted" in that run. The +SARIF was empty because the results were filtered to the PR's 43 changed files. + +So the first full-tree evaluation of `security-extended` against this repository +was the **push** that merged #1246: Dev CI run 527, which reported **38 Go and 17 +javascript-typescript findings** and turned `Dev gate` red. The gate then blocked +every subsequent push to `dev` — including any push that would have fixed it — +with no PR-shaped path out, because a PR into `dev` is scanned diff-informed and +therefore green while `dev` itself stays red. + +Two conclusions, and the second is the one that generalises: + +1. The any-finding threshold was never armed over a clean tree. It was armed over + a 55-finding backlog that no one had measured, because the only measurement + available at PR time is structurally incapable of showing it. +2. **A PR-event CodeQL run cannot certify a tree.** It certifies a diff. Any + claim of the form "the scanners are green, therefore the tree is clean" that + rests on a `pull_request` run is unsound, and that is a permanent property of + diff-informed analysis, not a bug to be fixed. + +The 55 were then triaged individually against the code. Four were reachable: + +- `internal/sched/handlers/handlers.go` logged `task.Prompt` unsanitized on the + task-create path, while the **update** path's twin line was already wrapped in + `logSafe`. `POST /tasks` is reachable by a scoped `create_task` key, so this + was genuine log forgery — and demonstrably so. +- `internal/httpapi/attachments.go` logged the raw client attachment path with + `%s` on the two branches where the containment guard had just *failed*, i.e. + precisely where the value is hostile by construction. +- `internal/agent/session.go` logged the client-echoed attachment `Name`, which + — unlike `Path` — is never re-sanitized on the `/chat` path. +- `web/e2e/test-auth-key.ts` wrote an Ed25519 private key to a fully predictable + path in the world-writable temp dir at default `0644`. + +Those four are fixed in code. The remaining 51 are false positives in fleet's +threat model, and the interesting part is that **severity alone does not separate +them**: `go/request-forgery` carries security-severity 9.1 and fires on +`web_fetch.go`, which is a deliberate user-facing fetch tool sitting behind +`internal/netguard`'s resolve-then-dial SSRF guard. `go/weak-sensitive-data-hashing` +carries 7.5 and fires on SHA-256 used as a lookup index over a 32-byte +`crypto/rand` token — the recommended construction. A pure severity line would +block both. + +## Decision + +**CodeQL blocks on a finding that is (a) at SARIF level `error`/`warning`, or has +security-severity >= 7.0, and (b) is not waived.** Findings below that band are +printed and uploaded to the Security tab as advisory. Waivers come from two +places: + +1. `.github/codeql-accepted-findings.json` — a register of accepted + `(rule, file)` pairs, each with a mandatory written reason. +2. An in-source `// codeql[rule-id]` comment, which CodeQL emits as a + `suppressions` array on the result. (Both `go` and `javascript` ship an + `AlertSuppression.ql`; the comment must sit on its own line and covers the + line immediately below it.) + +The register is **per-file, not per-rule**, and that is the whole point of +preferring it to a `query-filters` exclude. A `query-filters: exclude: {id: +go/request-forgery}` switches a security-severity 9.1 query off for the entire +repository; the register waives it in `internal/tools/web_fetch.go` and +`internal/mcpoauth/discovery.go` and leaves it live everywhere else, including +elsewhere in those same packages. This is asserted, not asserted-and-hoped: a +synthetic SARIF carrying a fresh `go/request-forgery` in an unregistered file +fails the gate, and that case is exercised as part of validating the jq. + +Three anti-rot controls, because a waiver register that nobody re-reads is worse +than no register: + +- `scripts/check_codeql_register_test.go` (in `make test`) requires every entry + to name a file that exists, carry a substantive reason, use a plausible rule + id, and be unique — and asserts that `codeql.yml` still references the register + at all, so the two cannot be silently decoupled. +- The gate **fails closed** if the register is missing, and fails closed if the + jq cannot be evaluated. A scan that could not be judged is never reported clean. +- The job log and step summary print the **ACCEPTED tier by name**, alongside + BLOCKING and ADVISORY, so every waiver is visible in ordinary CI output rather + than only in a file somebody has to think to open. + +## Consequences + +**What gets better.** `dev` and `main` are unblocked, and for the first time the +push-event runs report a verdict that means something: the High-and-above band is +enforced tree-wide, on every push, with a reviewed exception list. The +`security-extended` suite keeps running in full — nothing is filtered out of the +Security tab — so the 51 advisory/accepted findings remain visible for triage. +The specific claim "a green CodeQL check means the tree is clean" is now +false-by-construction only for PR events, and the docs say so instead of implying +otherwise. + +**What gets worse.** A note-level regression no longer fails the build. If +someone adds a 23rd `go/log-injection` sink on genuinely untrusted input, CI will +not stop them; it will appear in the advisory tier and in the Security tab. This +is a deliberate trade: the alternative, as demonstrated above, is a gate that +blocks every push and therefore gets routed around or switched off. `gosec`'s +G706 covers the same log-injection class in `golangci-lint`, which **does** block +via `ci-gate`, and carries 77 reviewed per-site annotations — so this class is +not unguarded, it is guarded by the instrument that was already there. + +**What is now load-bearing.** Widening the register is a security decision that +shows up in a PR diff, and reviewers are expected to check the reason against the +code rather than the reason's existence. That is a process control, and process +controls decay; the tests above are what make the decay visible. + +**Known limitation, stated rather than fixed.** The 621 `_test.go` files remain +outside the Go database (autobuild builds packages, not tests) — unchanged from +default setup and from #1246. And the register keys on `(rule, file)` rather than +`(rule, file, line)` deliberately: line numbers churn on every edit, and a +register that fails on unrelated refactors is a register people delete. The cost +is that a *second*, genuinely bad instance of an already-waived rule in an +already-waived file would not block. That is the sharpest edge here, and it is +the reason the reason-strings name the specific call sites and their guards. diff --git a/scripts/check_codeql_register_test.go b/scripts/check_codeql_register_test.go new file mode 100644 index 000000000..fc514f88a --- /dev/null +++ b/scripts/check_codeql_register_test.go @@ -0,0 +1,137 @@ +// Copyright (c) 2025 ElcanoTek +// SPDX-License-Identifier: MIT + +package scripts + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// .github/codeql-accepted-findings.json waives specific CodeQL findings from the +// blocking gate in .github/workflows/codeql.yml. A waiver register is only worth +// anything if it cannot rot, so these tests are the anti-rot controls an auditor +// would ask for: +// +// - every entry names a real file, so a waiver cannot outlive the code it was +// written about (a renamed or deleted file silently widens coverage loss — +// the gate would stop matching, but nobody would notice the dead entry); +// - every entry carries a substantive reason, so "why is this accepted?" is +// answerable from the repo rather than from a PR conversation; +// - no duplicate (rule, file) pairs, so there is exactly one reviewed reason +// per waiver rather than two that can disagree; +// - the rule ids look like CodeQL rule ids, so a typo fails here instead of +// silently never matching (a waiver that matches nothing is indistinguishable +// from a waiver that works, until the day it was supposed to fire). + +type acceptedFinding struct { + Rule string `json:"rule"` + File string `json:"file"` + Reason string `json:"reason"` +} + +type acceptedRegister struct { + Accepted []acceptedFinding `json:"accepted"` +} + +func loadRegister(t *testing.T) (acceptedRegister, string) { + t.Helper() + root := repoRoot(t) + path := filepath.Join(root, ".github", "codeql-accepted-findings.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var reg acceptedRegister + if err := json.Unmarshal(raw, ®); err != nil { + t.Fatalf("%s is not valid JSON: %v", path, err) + } + return reg, root +} + +// The register is consumed by jq in the workflow, which fails the build if the +// file is missing but cannot judge whether an entry still makes sense. +func TestCodeQLRegisterEntriesAreWellFormed(t *testing.T) { + reg, _ := loadRegister(t) + if len(reg.Accepted) == 0 { + // Not an error in principle — an empty register means nothing is waived, + // which is the goal state. Say so rather than asserting a count that + // would have to be edited every time a finding is genuinely fixed. + t.Log("register is empty: no CodeQL findings are currently waived") + return + } + for _, e := range reg.Accepted { + if strings.TrimSpace(e.Rule) == "" { + t.Errorf("entry with file %q has no rule", e.File) + continue + } + // CodeQL rule ids are "/" — e.g. go/request-forgery. + if !strings.Contains(e.Rule, "/") || strings.ContainsAny(e.Rule, " \t") { + t.Errorf("rule %q does not look like a CodeQL rule id (want /)", e.Rule) + } + if strings.TrimSpace(e.File) == "" { + t.Errorf("entry for rule %q has no file", e.Rule) + } + // A reason has to actually say something. The gate cannot check this and + // a reviewer skimming a diff might not either. + if len(strings.TrimSpace(e.Reason)) < 80 { + t.Errorf("rule %q file %q: reason is too short to be a justification (%d chars) — say why the finding cannot be exploited HERE", + e.Rule, e.File, len(strings.TrimSpace(e.Reason))) + } + if strings.Contains(strings.ToLower(e.Reason), "false positive") && + len(strings.TrimSpace(e.Reason)) < 160 { + t.Errorf("rule %q file %q: %q is an assertion, not a justification", + e.Rule, e.File, e.Reason) + } + } +} + +// A waiver that names a file which no longer exists matches nothing, so the gate +// would silently start blocking (or, worse, the finding moved to a file that is +// NOT waived and nobody connected the two). Either way the entry is stale. +func TestCodeQLRegisterFilesExist(t *testing.T) { + reg, root := loadRegister(t) + for _, e := range reg.Accepted { + if strings.TrimSpace(e.File) == "" { + continue + } + // The register stores repo-relative, forward-slash SARIF URIs. + p := filepath.Join(root, filepath.FromSlash(e.File)) + if _, err := os.Stat(p); err != nil { + t.Errorf("rule %q waives %q, which does not exist — remove the entry or repoint it: %v", + e.Rule, e.File, err) + } + } +} + +// Two entries for the same (rule, file) means two reasons that can drift apart, +// and the gate would honor whichever it saw first. +func TestCodeQLRegisterHasNoDuplicates(t *testing.T) { + reg, _ := loadRegister(t) + seen := make(map[string]bool, len(reg.Accepted)) + for _, e := range reg.Accepted { + key := e.Rule + " " + e.File + if seen[key] { + t.Errorf("duplicate register entry for %q", key) + } + seen[key] = true + } +} + +// The workflow reads the register by an exact path. If that path moves, the gate +// fails closed (it refuses to run without the file) — but only at CI time, on +// whatever PR happens to move it. Assert the coupling here instead. +func TestCodeQLWorkflowReferencesTheRegister(t *testing.T) { + root := repoRoot(t) + raw, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "codeql.yml")) + if err != nil { + t.Fatalf("read codeql.yml: %v", err) + } + const want = ".github/codeql-accepted-findings.json" + if !strings.Contains(string(raw), want) { + t.Fatalf("codeql.yml no longer references %s — the gate and the register have been decoupled", want) + } +} From 9a573e8aec64c62c1314c84fb0104e369e1709fe Mon Sep 17 00:00:00 2001 From: Brad Flaugher Date: Sat, 22 Aug 2026 16:06:01 +0000 Subject: [PATCH 03/10] Fix a vacuous CodeQL gate, and harden the CI supply chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate I added in the previous commit was VACUOUS, and the first full-tree run proved it: run 32583247659 reported "0 blocking" over a tree holding 30 findings, with sec-sev=0 on every one of them — including go/request-forgery, whose real security-severity is 9.1. Cause: CodeQL writes query metadata into runs[].tool.extensions[].rules[] (one extension per query pack), NOT runs[].tool.driver.rules[]. The driver is the CLI itself. Reading only the driver resolved nothing, so every finding scored 0 and nothing could ever reach the High band. For the same reason a result's `level` is usually absent from the result: SARIF falls back to the rule's defaultConfiguration.level, which was also unreachable. Three changes, in order of importance: 1. The classifier now reads driver.rules AND extensions[].rules, and resolves level from the rule when the result omits it. Verified against a fixture built to the real SARIF shape. 2. A VACUITY CHECK that would have caught this: if a scan produced findings but resolved zero rule metadata, the job fails instead of reporting clean. "Findings but no metadata" means the lookup is broken and the gate is evaluating nothing — the green-but-vacuous outcome this workflow exists to rule out, which I then walked straight into. 3. The banding is now security-severity only, with level as the fallback for a rule that publishes no security-severity. Once metadata resolved, banding on level as well put all 23 go/log-injection findings (security-severity 6.1) into the blocking tier, because nearly every CodeQL security query is @problem.severity error — level carries no severity information for them. That would have reproduced the any-finding deadlock by a different route. The filter now lives in .github/codeql-gate.jq and is used by both the summary and the gate via `jq -f`, so the thing that reports and the thing that blocks cannot disagree about what "blocking" means — and it can be exercised against fixture SARIF with the exact bytes CI runs. Exercised end-to-end from the YAML: the real-shape fixture (0 blocking, 8 accepted, 2 advisory), a fresh unregistered go/request-forgery in an unwaived file (blocks — the property that makes the per-file register different from a query-filters exclude), the vacuity fixture (fails), missing register, missing filter, and unparseable SARIF. CI supply chain, from the same audit: - Repin github/codeql-action (5 refs) and golangci/golangci-lint-action (2 refs). Both were pinned to the ANNOTATED TAG OBJECT of a MUTABLE major tag, not to a commit: `refs/tags/v4` -> 4c0873ef but `refs/tags/v4^{}` -> db488dde. A tag object is immutable but only reachable while that tag points at it, so the day upstream moves v4 — which codeql-action does on essentially every release — the object is unreferenced and Actions can no longer resolve the ref. A self-inflicted CI outage with no attacker involved, armed in seven places. Verified with `git ls-remote --tags` and repinned to the peeled commits, with exact `# vX.Y.Z` comments (they read `# v4 (4.37.8)` and `# v9`, which dependabot-core parses as "4" and "9"). scripts/check_action_pins_test.go now enforces the shape across all 53 third-party refs. - build-sandbox-image.yml / publish-sandbox-image.yml: replace the fleet_ref deny-list with an allow-list. The deny-list had two holes. GITHUB_OUTPUT newline injection: a workflow_call string input may contain newlines and the value was printf'd unsanitized, so fleet_ref="main\nresolved=refs/pull/1/head" matched no deny pattern, exited 0, and emitted two `resolved=` lines — last-wins handed the attacker the ref, and the same primitive forges any step output. And a raw commit SHA: "every ref here is collaborator-written except refs/pull/*" is true of named refs and false of reachable commits, since GitHub keeps fork-PR commits in the base repo's object store and actions/checkout will fetch a bare SHA. Both matter because these workflows EXECUTE the checked-out build script, and the publish twin holds packages: write with a live GHCR login. Tested: 5 legitimate refs pass, 9 attack shapes fail, including the injection payload. - ci.yml docs-only classifier: `*.md` matched at any depth and `docs/*` matched everything under docs/, so a PR touching only internal/clientconfig/builtin_skills/*/SKILL.md (go:embed'd and asserted by three test files), config/default/system_prompts/*.md (the shipped prompts docs/PROMPT-CACHE-CONTRACT.md exists to protect), or docs/openapi.yaml (asserted by openapi_drift_test.go) was classified docs-only — and every job skipped while CI gate reported green. Narrowed to a prose allow-list. - ci.yml ci-gate: a `skipped` job passed the gate unconditionally. Now a skip is only accepted when the classifier actually said docs-only; otherwise the gate refuses to pass over a suite that did not run. Same rot pattern as red-but-not-required, colours inverted. Gate logic tested in six directions. - scripts/check_gate_needs_test.go: assert every job in ci.yml and dev-ci.yml is in its aggregate gate's `needs`. Both are complete today (11/11 and 7/7); nothing asserted it, and adding a job without extending needs is a silent one-line regression that produces a red-but-not-required check — the exact failure #1246 was written to stop recurring. Signed-off-by: Brad Flaugher --- .github/codeql-gate.jq | 74 ++++++ .github/workflows/build-sandbox-image.yml | 33 ++- .github/workflows/ci.yml | 46 +++- .github/workflows/codeql.yml | 263 +++++++++----------- .github/workflows/dev-ci.yml | 2 +- .github/workflows/govulncheck-scheduled.yml | 2 +- .github/workflows/grype-scheduled.yml | 2 +- .github/workflows/publish-sandbox-image.yml | 33 ++- scripts/check_action_pins_test.go | 93 +++++++ scripts/check_gate_needs_test.go | 96 +++++++ 10 files changed, 485 insertions(+), 159 deletions(-) create mode 100644 .github/codeql-gate.jq create mode 100644 scripts/check_action_pins_test.go create mode 100644 scripts/check_gate_needs_test.go diff --git a/.github/codeql-gate.jq b/.github/codeql-gate.jq new file mode 100644 index 000000000..0ae963d6f --- /dev/null +++ b/.github/codeql-gate.jq @@ -0,0 +1,74 @@ +# Shared CodeQL SARIF classifier. Used by BOTH steps in +# .github/workflows/codeql.yml (the summary and the gate) via `jq -f`, so the +# thing that reports and the thing that blocks can never drift apart — and so it +# can be exercised against fixture SARIF locally with the exact file CI runs. +# +# Input: `jq -rs --slurpfile reg .github/codeql-accepted-findings.json -f this` +# over one or more CodeQL SARIF files. +# Output: one JSON object, `{blocking: [...], accepted: [...], advisory: [...], +# ruleMetaCount: N, total: N}`. The caller formats it. +# +# WHY THE RULE LOOKUP IS THE WAY IT IS — this is the subtle part, and getting it +# wrong makes the gate silently vacuous rather than loudly broken: +# +# CodeQL writes query metadata into `runs[].tool.extensions[].rules[]` (one +# extension per query pack), NOT into `runs[].tool.driver.rules[]`. The driver is +# the CodeQL CLI itself. A first cut of this filter read only driver.rules, found +# nothing, and therefore scored EVERY finding at security-severity 0 — including +# go/request-forgery, whose real value is 9.1. The gate passed with "0 blocking" +# on a tree holding 30 findings, which is exactly the green-but-vacuous outcome +# the workflow exists to rule out. Verified against the actual SARIF from run +# 32583247659. +# +# For the same reason, a result's SEVERITY LEVEL usually is not on the result at +# all: SARIF says an omitted `level` falls back to the rule's +# `defaultConfiguration.level`, and CodeQL relies on that. So the level is +# resolved from the rule too, with the result's own `level` winning when present. +# +# `ruleMetaCount` is returned so the caller can fail closed when results exist +# but no rule metadata resolved — i.e. when this lookup has broken again. + +# Every rule object anywhere in the tool description, keyed by id. +( [ .[] | .runs[]? + | ( [ .tool.driver.rules[]? ] + [ .tool.extensions[]?.rules[]? ] )[] + | select(.id != null) + ] ) as $ruleList +| ( reduce $ruleList[] as $r ({}; .[$r.id] = $r) ) as $rules +| ( reduce ($reg[0].accepted[]? | "\(.rule) \(.file)") as $k ({}; .[$k] = true) ) as $waived +| ( [ .[] | .runs[]? | .results[]? + | . as $res + | ( $rules[$res.ruleId] // {} ) as $rule + | ( ($rule.properties["security-severity"]) // "" ) as $sevRaw + | ( $sevRaw | tonumber? // 0 ) as $sev + | ( ($sevRaw | tonumber? | type == "number") // false ) as $hasSev + | ( ($res.level) // ($rule.defaultConfiguration.level) // "note" ) as $level + | ( ($res.locations[0].physicalLocation.artifactLocation.uri) // "" ) as $file + | ( ($res.locations[0].physicalLocation.region.startLine) // "?" ) as $line + | { rule: $res.ruleId, + file: $file, + line: $line, + level: $level, + sev: $sev, + # An in-source `// codeql[rule-id]` comment lands here. + suppressed: ((($res.suppressions // []) | length) > 0), + waived: ($waived | has("\($res.ruleId) \($file)")), + hasSev: $hasSev, + # HIGH BAND. security-severity is the dimension that carries severity + # information; CodeQL's own High/Critical cut is 7.0, and that is what + # GitHub's code-scanning merge protection bands on. + # + # `level` (i.e. @problem.severity) is NOT a severity signal for a + # security query — almost every one of them is `error`, including + # go/log-injection at security-severity 6.1. Banding on level as well + # would put all 23 log-injection findings in the blocking tier and + # reproduce the any-finding deadlock this replaced. So level is used + # ONLY as the fallback for a rule that publishes no security-severity + # at all (a non-security query), where it is the only signal there is. + high: (if $hasSev then $sev >= 7.0 + else ($level == "error" or $level == "warning") end) } + ] ) as $all +| { total: ($all | length), + ruleMetaCount: ($ruleList | length), + blocking: [ $all[] | select(.high and (.waived | not) and (.suppressed | not)) ], + accepted: [ $all[] | select(.high and (.waived or .suppressed)) ], + advisory: [ $all[] | select(.high | not) ] } diff --git a/.github/workflows/build-sandbox-image.yml b/.github/workflows/build-sandbox-image.yml index b44ada1c4..98ace2532 100644 --- a/.github/workflows/build-sandbox-image.yml +++ b/.github/workflows/build-sandbox-image.yml @@ -118,11 +118,38 @@ jobs: REQUESTED: ${{ inputs.fleet_ref || 'main' }} run: | set -euo pipefail + # ALLOW-LIST, not a deny-list. The previous deny-list (`refs/pull/*|pull/*|-*`) + # had two holes, both of which this closes by construction: + # + # 1. GITHUB_OUTPUT newline injection. A workflow_call string input may contain + # newlines, and `printf 'resolved=%s\n'` wrote it unsanitized, so + # `fleet_ref: "main\nresolved=refs/pull/1/head"` matched no deny pattern + # (the string starts "main"), exited 0, and emitted TWO `resolved=` lines — + # last-wins gave the attacker the ref. The same primitive could forge any + # step output. The character class below admits no newline, so the value + # cannot carry a second assignment. + # 2. A raw commit SHA. "Every ref in this repo is collaborator-written except + # refs/pull/*" is true of NAMED REFS and false of reachable COMMITS: GitHub + # keeps fork-PR commits in the base repo's object store and + # actions/checkout will happily fetch a bare SHA. So a bare hex SHA is + # refused too — pass a branch or tag name, which only a collaborator can + # create. case "$REQUESTED" in - refs/pull/*|pull/*|-*) - echo "::error::fleet_ref '$REQUESTED' is refused: pull-request refs carry fork-controlled code, and this workflow executes the checked-out build script." - exit 1 ;; + "") + echo "::error::fleet_ref is empty"; exit 1 ;; + *[!a-zA-Z0-9._/-]*) + echo "::error::fleet_ref contains a character outside [A-Za-z0-9._/-] (newline, space or shell metacharacter). Refused before checkout."; exit 1 ;; + -*|*..*|*//*) + echo "::error::fleet_ref '$REQUESTED' is not a plausible ref name"; exit 1 ;; + refs/pull/*|pull/*) + echo "::error::fleet_ref '$REQUESTED' is refused: pull-request refs carry fork-controlled code, and this workflow executes the checked-out build script."; exit 1 ;; esac + # Refuse a bare commit SHA (see 2 above). A ref name that happens to be all-hex + # and >=7 chars must be given in its refs/heads/ or refs/tags/ form. + if [ -z "$(printf '%s' "$REQUESTED" | tr -d '0-9a-fA-F')" ] && [ "${#REQUESTED}" -ge 7 ]; then + echo "::error::fleet_ref '$REQUESTED' looks like a raw commit SHA. Fork pull-request commits are reachable by SHA from this repository, so only named refs are accepted (e.g. main, refs/heads/x, v1.2.3)." + exit 1 + fi printf 'resolved=%s\n' "$REQUESTED" >> "$GITHUB_OUTPUT" - name: Checkout fleet (build script) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99bf8d60c..241bd38ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,8 +64,31 @@ jobs: while IFS= read -r f; do [ -z "$f" ] && continue # `*` in a shell case pattern spans '/', so these match at any depth. + # + # This list is deliberately an ALLOW-LIST of prose, and deliberately + # NOT a bare `*.md` or `docs/*`. Both of those were wrong, and both + # made `CI gate` report green over a suite that never ran: + # + # - `*.md` matched at any depth, so it swallowed 15 markdown files + # that are COMPILED PRODUCT CONTENT — every + # internal/clientconfig/builtin_skills/*/SKILL.md (pulled in by + # `//go:embed all:builtin_skills` and asserted by + # builtin_skills_browserbase_test.go, clientconfig_test.go and + # validate_config_test.go), plus config/default/system_prompts/ + # {default,chat}.md, which ARE the shipped system prompts that + # docs/PROMPT-CACHE-CONTRACT.md exists to protect. + # - `docs/*` swallowed docs/openapi.yaml, which + # cmd/fleet/openapi_drift_test.go asserts against the Go models, + # and docs/scripts/*.py + docs/img/*.py, which are inside the + # ruff, Semgrep p/python and CodeQL python scopes. + # + # So a PR touching only a shipped prompt or the OpenAPI spec skipped + # the very tests that validate it. Keep this list prose-only; when in + # doubt, leave a path OFF it and run the full suite. case "$f" in - *.md|docs/*|LICENSE) : ;; # documentation: no compiled/runtime signal + docs/*.md|LICENSE) : ;; + README.md|CHANGELOG.md|CONTRIBUTING.md|SECURITY.md) : ;; + CODE_OF_CONDUCT.md|AGENTS.md|CLAUDE.md|ONBOARDING.md) : ;; *) docs_only=false ;; esac done <<< "$files" @@ -231,7 +254,7 @@ jobs: run: go vet -tags fleet_host_executor ./... - name: golangci-lint - uses: golangci/golangci-lint-action@db9de0fc1a667e1a49d2291a1a042dff081d78f6 # v9 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: # Repo .golangci.yml is the v2 schema (version: "2"). It no longer # pins run.go: golangci-lint's documented default is "use Go version @@ -791,7 +814,7 @@ jobs: # affected packages, and fix versions rather than only a red CI job. The # hashFiles guard skips this step (instead of erroring "file not found") # when an EARLIER step failed before grype could write the SARIF. - uses: github/codeql-action/upload-sarif@4c0873ef8656cb3c50b3f42fb63bc1ade0cfa827 # v4 (4.37.8) + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 if: ${{ !cancelled() && hashFiles('grype-results.sarif') != '' }} with: sarif_file: 'grype-results.sarif' @@ -814,12 +837,29 @@ jobs: env: # needs.*.result is one of: success | failure | cancelled | skipped. RESULTS: ${{ join(needs.*.result, ',') }} + DOCS_ONLY: ${{ needs.changes.outputs.docs_only }} run: | set -euo pipefail echo "Upstream job results: ${RESULTS}" + echo "docs_only: ${DOCS_ONLY}" case ",${RESULTS}," in *,failure,*|*,cancelled,*) echo "::error::A required upstream CI job failed or was cancelled." exit 1 ;; esac + # A `skipped` job passes this gate only because the docs-only + # classifier above is allowed to skip the suite. If nothing was + # classified docs-only, a skip means a required job did not run and + # this gate would otherwise report green over it — the + # red-but-not-required rot pattern with the colours inverted. So trust + # the classifier for the one case it exists for, and refuse a skip in + # every other case rather than assuming the `if:` that produced it was + # correct. + if [ "${DOCS_ONLY}" != "true" ]; then + case ",${RESULTS}," in + *,skipped,*) + echo "::error::A required upstream job was SKIPPED on a change that is not docs-only. CI gate does not pass over a suite that did not run." + exit 1 ;; + esac + fi echo "All required CI jobs passed (or were cleanly skipped for a docs-only change)." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f95bbf069..c1417224b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -170,7 +170,7 @@ jobs: cache: true - name: Initialize CodeQL - uses: github/codeql-action/init@4c0873ef8656cb3c50b3f42fb63bc1ade0cfa827 # v4 (4.37.8) + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -180,7 +180,7 @@ jobs: - name: Autobuild if: matrix.build-mode == 'autobuild' - uses: github/codeql-action/autobuild@4c0873ef8656cb3c50b3f42fb63bc1ade0cfa827 # v4 (4.37.8) + uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 env: # Same build tag ci.yml and dev-ci.yml pass to `go vet` and `go test`, # and for the same reason: internal/sandbox/host.go — the UNSANDBOXED @@ -194,7 +194,7 @@ jobs: GOFLAGS: -tags=fleet_host_executor - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4c0873ef8656cb3c50b3f42fb63bc1ade0cfa827 # v4 (4.37.8) + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: category: /language:${{ matrix.language }} # Also write the SARIF to disk so the step below can summarize it. The @@ -202,6 +202,54 @@ jobs: # this only adds a local copy. output: ${{ runner.temp }}/codeql-sarif + - name: Classify findings (shared filter) + # ONE classifier, in .github/codeql-gate.jq, consumed by both the summary + # and the gate below. Two copies of a SARIF filter is two copies that can + # disagree about what "blocking" means, and the report disagreeing with + # the gate is worse than either being wrong alone. Keeping it in a file + # also means it can be exercised against fixture SARIF locally with the + # exact bytes CI runs. + if: ${{ !cancelled() }} + env: + SARIF_DIR: ${{ runner.temp }}/codeql-sarif + LANGUAGE: ${{ matrix.language }} + ACCEPTED_FILE: ${{ github.workspace }}/.github/codeql-accepted-findings.json + GATE_FILTER: ${{ github.workspace }}/.github/codeql-gate.jq + CLASSIFIED: ${{ runner.temp }}/codeql-classified.json + run: | + set -uo pipefail + shopt -s nullglob + files=("$SARIF_DIR"/*.sarif) + if [ ${#files[@]} -eq 0 ]; then + echo "::error::No SARIF written for $LANGUAGE — cannot conclude the scan was clean." + exit 1 + fi + for f in "$ACCEPTED_FILE" "$GATE_FILTER"; do + if [ ! -f "$f" ]; then + echo "::error::$f is missing — refusing to gate without it." + exit 1 + fi + done + if ! jq -rs --slurpfile reg "$ACCEPTED_FILE" -f "$GATE_FILTER" \ + "${files[@]}" > "$CLASSIFIED"; then + echo "::error::could not classify the SARIF for $LANGUAGE — refusing to report it clean." + exit 1 + fi + total=$(jq -r '.total' "$CLASSIFIED") + meta=$(jq -r '.ruleMetaCount' "$CLASSIFIED") + echo "$LANGUAGE: $total finding(s), $meta rule metadata entr(ies)" + # VACUITY CHECK. Severity banding depends on resolving each result's + # rule metadata, and CodeQL puts that in tool.extensions[].rules[] — + # not tool.driver.rules[]. A first cut of the filter read only the + # driver, resolved nothing, scored every finding at security-severity 0 + # and reported "0 blocking" over a tree holding 30 findings. The check + # below is what would have caught that: findings but no rule metadata + # means the lookup is broken and the gate is not evaluating anything. + if [ "$total" != "0" ] && [ "$meta" = "0" ]; then + echo "::error::$LANGUAGE: $total finding(s) but ZERO rule metadata resolved — severity banding is inoperative, so this gate would pass vacuously. Fix the rule lookup in .github/codeql-gate.jq." + exit 1 + fi + - name: Summarize findings to the job log # WHY THIS EXISTS: a CodeQL run reports nothing about what it FOUND to # its own log. It writes SARIF, uploads it, and exits 0 — with findings @@ -210,86 +258,50 @@ jobs: # reading CI output, to `gh run view`, and to any automation that has # the log but not the code-scanning API. # - # The listing is split into the SAME three tiers the next step gates on, - # so the log says not just what was found but which part of it blocks — - # and, importantly, prints the ACCEPTED tier by name. A waiver that is - # invisible in CI output is a waiver nobody re-reads. Reporting only; the - # `Fail on findings` step below is what fails the job. + # The listing is split into the SAME three tiers the gate uses, from the + # same classifier, and prints the ACCEPTED tier BY NAME — a waiver that + # is invisible in CI output is a waiver nobody re-reads. if: ${{ !cancelled() }} env: - SARIF_DIR: ${{ runner.temp }}/codeql-sarif LANGUAGE: ${{ matrix.language }} - ACCEPTED_FILE: ${{ github.workspace }}/.github/codeql-accepted-findings.json + CLASSIFIED: ${{ runner.temp }}/codeql-classified.json # The database directory is named after the EXTRACTOR, which is not # always the matrix language: javascript-typescript -> javascript. CODEQL_DB: ${{ matrix.language == 'javascript-typescript' && 'javascript' || matrix.language }} run: | set -uo pipefail - shopt -s nullglob - files=("$SARIF_DIR"/*.sarif) { echo "### CodeQL findings — $LANGUAGE" - if [ ${#files[@]} -eq 0 ]; then - # Not a failure: the analyze step is what fails on a broken run. - # Say it plainly rather than printing "No findings." for a scan - # that never produced a file — reporting a clean result you did - # not observe is the error this repo keeps writing down. - echo 'No SARIF file was written — nothing to summarize (see the analyze step).' - else - echo '```' - # `-s` slurps every SARIF doc into one array, so a language that - # emits more than one file is still counted once, in total. - # - # security-severity lives on the RULE, not the result, so each - # result is joined back to its rule in the same run's - # tool.driver.rules[] to recover it. Absent -> 0, which is - # correct: a query with no security-severity is not a High. - jq -rs --slurpfile reg "$ACCEPTED_FILE" ' - ( reduce ($reg[0].accepted[]? | "\(.rule) \(.file)") as $k ({}; .[$k] = true) ) as $waived - | [ .[] | .runs[]? - | ( [ .tool.driver.rules[]? ] ) as $rules - | .results[]? - | . as $r - | ( [ $rules[] | select(.id == $r.ruleId) - | .properties["security-severity"] ][0] // "" ) as $sev - | ( ($r.locations[0].physicalLocation.artifactLocation.uri) // "" ) as $file - | ( ($r.level) // "note" ) as $level - | { level: $level, - sev: (($sev | tonumber? // 0)), - rule: $r.ruleId, - loc: "\($file):\(($r.locations[0].physicalLocation.region.startLine) // "?")", - waived: ( (($waived | has("\($r.ruleId) \($file)"))) - or ((($r.suppressions // []) | length) > 0) ), - high: ( $level == "error" or $level == "warning" or (($sev | tonumber? // 0) >= 7.0) ) } - ] as $all - | ( $all | map(select(.high and (.waived | not))) ) as $b - | ( $all | map(select(.high and .waived)) ) as $w - | ( $all | map(select(.high | not)) ) as $n - | "BLOCKING — High+ and not registered (\($b|length)):", - ( if ($b|length) == 0 then " none" else ($b | sort_by(.rule,.loc) | .[] | " [\(.level)] sec-sev=\(.sev) \(.rule) \(.loc)") end ), - "", - "ACCEPTED — High+ waived in codeql-accepted-findings.json or in-source (\($w|length)):", - ( if ($w|length) == 0 then " none" else ($w | sort_by(.rule,.loc) | .[] | " [\(.level)] sec-sev=\(.sev) \(.rule) \(.loc)") end ), - "", - "ADVISORY — below High, triage in the Security tab (\($n|length)):", - ( if ($n|length) == 0 then " none" else ($n | sort_by(.rule,.loc) | .[] | " [\(.level)] sec-sev=\(.sev) \(.rule) \(.loc)") end ), - "", - "totals: \($all|length) finding(s) — \($b|length) blocking, \($w|length) accepted, \($n|length) advisory" - ' "${files[@]}" - echo '```' - # COVERAGE, not just the verdict: "No findings." alone cannot be - # told apart from "analyzed nothing", which is the exact - # green-but-vacuous outcome this workflow exists to rule out. - # The source archive is the file set the database was built from. - # RUNNER_TEMP (the env var), not ${{ runner.temp }}: interpolating a - # GitHub expression straight into a run: block is the shape - # semgrep's gha-curl-pipe-shell / curl-eval rules flag, and it also - # breaks their bash sub-parser — which silently costs coverage on - # this very file. The env var is equivalent and parses. - src="$RUNNER_TEMP/codeql_databases/$CODEQL_DB/src.zip" - if [ -f "$src" ]; then - echo "files in the $LANGUAGE database: $(unzip -Z1 "$src" 2>/dev/null | grep -vc '/$' || echo '?')" - fi + echo '```' + jq -r ' + def fmt: " [\(.level)] sec-sev=\(if .hasSev then .sev else "n/a" end) \(.rule) \(.file):\(.line)" + + (if .suppressed then " (in-source suppression)" else "" end); + def tier($label; $rows): + "\($label) (\($rows | length)):", + (if ($rows | length) == 0 then " none" + else ($rows | sort_by(.rule, .file, .line) | .[] | fmt) end); + tier("BLOCKING — High band (security-severity >= 7.0), not waived"; .blocking), + "", + tier("ACCEPTED — High band, waived in codeql-accepted-findings.json or in-source"; .accepted), + "", + tier("ADVISORY — below the High band; triage in the Security tab"; .advisory), + "", + "totals: \(.total) finding(s) — \(.blocking | length) blocking, \(.accepted | length) accepted, \(.advisory | length) advisory", + "rule metadata resolved: \(.ruleMetaCount)" + ' "$CLASSIFIED" + echo '```' + # COVERAGE, not just the verdict: "No findings." alone cannot be + # told apart from "analyzed nothing", which is the exact + # green-but-vacuous outcome this workflow exists to rule out. + # The source archive is the file set the database was built from. + # RUNNER_TEMP (the env var), not ${{ runner.temp }}: interpolating a + # GitHub expression straight into a run: block is the shape + # semgrep's gha-curl-pipe-shell / curl-eval rules flag, and it also + # breaks their bash sub-parser — which silently costs coverage on + # this very file. The env var is equivalent and parses. + src="$RUNNER_TEMP/codeql_databases/$CODEQL_DB/src.zip" + if [ -f "$src" ]; then + echo "files in the $LANGUAGE database: $(unzip -Z1 "$src" 2>/dev/null | grep -vc '/$' || echo '?')" fi } | tee -a "$GITHUB_STEP_SUMMARY" @@ -301,88 +313,45 @@ jobs: # code has a problem", which is precisely how the Go toolchain break sat # unnoticed behind a red-but-not-required check for weeks. # - # THRESHOLD, in three parts: - # 1. A finding blocks when its SARIF level is `error`/`warning`, or its - # rule carries security-severity >= 7.0 (CodeQL's High/Critical - # band). Note-level findings below High are printed and uploaded to - # the Security tab; they do not block. - # 2. A finding whose (rule, file) pair appears in - # .github/codeql-accepted-findings.json does not block. Each entry - # carries a written reason. The waiver is per-FILE, so the rule - # stays live everywhere else — a new go/request-forgery in a - # different file still fails the build. - # 3. An in-source `// codeql[rule-id]` suppression is honored (CodeQL - # emits it as a `suppressions` array on the result). + # THRESHOLD (see .github/codeql-gate.jq for the mechanics): + # - security-severity >= 7.0 blocks. That is CodeQL's own High/Critical + # cut and what GitHub's code-scanning merge protection bands on. + # `level` / @problem.severity is NOT used for a rule that publishes a + # security-severity: nearly every security query is `error`, + # go/log-injection at 6.1 included, so banding on it would block all + # 23 log-injection findings and reproduce the deadlock this replaced. + # - a rule with no security-severity falls back to level error/warning. + # - a (rule, file) pair in .github/codeql-accepted-findings.json, or an + # in-source `// codeql[rule-id]` comment, moves a High-band finding + # to ACCEPTED. The register is per-FILE, so the rule stays live + # everywhere else. # - # WHY NOT "any finding" — that was tried in #1246, and it deadlocked the - # repo. That gate was armed on a measurement of ZERO across all four - # languages, but the measurement came from a `pull_request` run (Dev CI - # run 525), and on pull_request events the CodeQL action runs - # DIFF-INFORMED: it builds the full database and evaluates every query, - # then reports only results located inside the PR's diff. Run 525's own - # log says so — "Persisted 204 diff range(s) across 43 file(s)", and - # "file coverage information is only enabled when analyzing the default - # branch and protected branches". - # - # So the first full-tree scan was the PUSH that merged it (Dev CI run - # 527), which reported 38 Go and 17 javascript-typescript findings and - # turned `Dev gate` red with no PR to fix it through. The any-finding - # threshold was never armed over a clean tree; it was armed over a - # 55-finding backlog nobody had measured yet. - # - # Those 55 were triaged individually. Four were reachable and are FIXED - # in code, not accepted — the task-create log line whose update-path twin - # was already sanitized, two pre-validation client paths on reject - # branches, a client-echoed attachment name, and a world-readable - # private-key write in the e2e harness. The rest are false positives in - # fleet's threat model and are registered with their reasons. - # - # Runs after the summary so the log leads with WHAT was found. - # See docs/CODEQL.md ("Merge gating") and - # docs/adr/0048-codeql-severity-gating.md. + # WHY NOT "any finding" — that was tried in #1246 and it deadlocked the + # repo. See docs/adr/0048-codeql-severity-gating.md: the zero it was armed + # on came from a `pull_request` run, where CodeQL is DIFF-INFORMED and + # reports only results inside the PR's diff, so it measured the diff and + # not the tree. if: ${{ !cancelled() }} env: - SARIF_DIR: ${{ runner.temp }}/codeql-sarif LANGUAGE: ${{ matrix.language }} - # GITHUB_WORKSPACE, not ${{ github.workspace }} interpolated into the - # run: block — same reason the summary step uses RUNNER_TEMP. - ACCEPTED_FILE: ${{ github.workspace }}/.github/codeql-accepted-findings.json + CLASSIFIED: ${{ runner.temp }}/codeql-classified.json run: | set -uo pipefail - shopt -s nullglob - files=("$SARIF_DIR"/*.sarif) - if [ ${#files[@]} -eq 0 ]; then - echo "::error::No SARIF written for $LANGUAGE — cannot conclude the scan was clean." - exit 1 - fi - if [ ! -f "$ACCEPTED_FILE" ]; then - echo "::error::accepted-findings register $ACCEPTED_FILE is missing — refusing to gate without it." - exit 1 - fi - blocking=$(jq -rs --slurpfile reg "$ACCEPTED_FILE" ' - ( reduce ($reg[0].accepted[]? | "\(.rule) \(.file)") as $k ({}; .[$k] = true) ) as $waived - | [ .[] | .runs[]? - | ( [ .tool.driver.rules[]? ] ) as $rules - | .results[]? - | . as $r - | ( [ $rules[] | select(.id == $r.ruleId) - | .properties["security-severity"] ][0] // "" ) as $sev - | ( ($r.locations[0].physicalLocation.artifactLocation.uri) // "" ) as $file - | ( ($r.level) // "note" ) as $level - | ( "\($r.ruleId) \($file)" ) as $key - | select( (($r.suppressions // []) | length) == 0 ) - | select( ($waived | has($key)) | not ) - | select( $level == "error" or $level == "warning" or (($sev | tonumber? // 0) >= 7.0) ) - | " [\($level)] sec-sev=\($sev) \($r.ruleId) \($file):\(($r.locations[0].physicalLocation.region.startLine) // "?")" - ] | .[]' "${files[@]}") - rc=$? - if [ $rc -ne 0 ]; then - echo "::error::could not evaluate the SARIF for $LANGUAGE — refusing to report it clean." + count=$(jq -r '.blocking | length' "$CLASSIFIED") + if [ -z "$count" ]; then + echo "::error::could not read the classification for $LANGUAGE — refusing to report it clean." exit 1 fi - if [ -n "$blocking" ]; then - echo "::error::CodeQL found blocking finding(s) for ${LANGUAGE}:" - echo "$blocking" + if [ "$count" != "0" ]; then + echo "::error::CodeQL found ${count} blocking finding(s) for ${LANGUAGE} — see the summary above." + jq -r '.blocking[] | " \(.rule) \(.file):\(.line) (security-severity \(.sev))"' "$CLASSIFIED" + echo "" + echo "Fix it. If it is a false positive the honest options are a code" + echo "change that removes the sink, an in-source // codeql[rule-id]" + echo "comment, or an entry in .github/codeql-accepted-findings.json" + echo "with a written reason. Note that dismissing the alert in the" + echo "Security tab will NOT turn this check green: this step reads the" + echo "run's own SARIF and never consults the code-scanning API." exit 1 fi echo "CodeQL ($LANGUAGE): 0 blocking findings." diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 6ef818a99..caa9d480f 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -107,7 +107,7 @@ jobs: run: go vet -tags fleet_host_executor ./... - name: golangci-lint - uses: golangci/golangci-lint-action@db9de0fc1a667e1a49d2291a1a042dff081d78f6 # v9 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: # Keep pinned in lockstep with ci.yml + .golangci.yml (see the note # there) so the fast lane and the full gate never disagree. diff --git a/.github/workflows/govulncheck-scheduled.yml b/.github/workflows/govulncheck-scheduled.yml index 0e4a09e1f..d29b3f06f 100644 --- a/.github/workflows/govulncheck-scheduled.yml +++ b/.github/workflows/govulncheck-scheduled.yml @@ -92,7 +92,7 @@ jobs: echo '```' >> "$GITHUB_STEP_SUMMARY" - name: Upload scan results - uses: github/codeql-action/upload-sarif@4c0873ef8656cb3c50b3f42fb63bc1ade0cfa827 # v4 (4.37.8) + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 # Skip cleanly (rather than error "file not found") if the scan step # failed before govulncheck could write the SARIF. if: ${{ !cancelled() && hashFiles('govulncheck.sarif') != '' }} diff --git a/.github/workflows/grype-scheduled.yml b/.github/workflows/grype-scheduled.yml index b2357c524..d48f4d6e1 100644 --- a/.github/workflows/grype-scheduled.yml +++ b/.github/workflows/grype-scheduled.yml @@ -80,7 +80,7 @@ jobs: --output sarif=grype-results.sarif - name: Upload weekly scan results - uses: github/codeql-action/upload-sarif@4c0873ef8656cb3c50b3f42fb63bc1ade0cfa827 # v4 (4.37.8) + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 # Skip cleanly (rather than error "file not found") if an earlier step # failed before grype could write the SARIF. if: ${{ !cancelled() && hashFiles('grype-results.sarif') != '' }} diff --git a/.github/workflows/publish-sandbox-image.yml b/.github/workflows/publish-sandbox-image.yml index 8be791861..1335e2875 100644 --- a/.github/workflows/publish-sandbox-image.yml +++ b/.github/workflows/publish-sandbox-image.yml @@ -235,11 +235,38 @@ jobs: REQUESTED: ${{ inputs.fleet_ref || 'main' }} run: | set -euo pipefail + # ALLOW-LIST, not a deny-list. The previous deny-list (`refs/pull/*|pull/*|-*`) + # had two holes, both of which this closes by construction: + # + # 1. GITHUB_OUTPUT newline injection. A workflow_call string input may contain + # newlines, and `printf 'resolved=%s\n'` wrote it unsanitized, so + # `fleet_ref: "main\nresolved=refs/pull/1/head"` matched no deny pattern + # (the string starts "main"), exited 0, and emitted TWO `resolved=` lines — + # last-wins gave the attacker the ref. The same primitive could forge any + # step output. The character class below admits no newline, so the value + # cannot carry a second assignment. + # 2. A raw commit SHA. "Every ref in this repo is collaborator-written except + # refs/pull/*" is true of NAMED REFS and false of reachable COMMITS: GitHub + # keeps fork-PR commits in the base repo's object store and + # actions/checkout will happily fetch a bare SHA. So a bare hex SHA is + # refused too — pass a branch or tag name, which only a collaborator can + # create. case "$REQUESTED" in - refs/pull/*|pull/*|-*) - echo "::error::fleet_ref '$REQUESTED' is refused: pull-request refs carry fork-controlled code, and this workflow executes the checked-out build script." - exit 1 ;; + "") + echo "::error::fleet_ref is empty"; exit 1 ;; + *[!a-zA-Z0-9._/-]*) + echo "::error::fleet_ref contains a character outside [A-Za-z0-9._/-] (newline, space or shell metacharacter). Refused before checkout."; exit 1 ;; + -*|*..*|*//*) + echo "::error::fleet_ref '$REQUESTED' is not a plausible ref name"; exit 1 ;; + refs/pull/*|pull/*) + echo "::error::fleet_ref '$REQUESTED' is refused: pull-request refs carry fork-controlled code, and this workflow executes the checked-out build script."; exit 1 ;; esac + # Refuse a bare commit SHA (see 2 above). A ref name that happens to be all-hex + # and >=7 chars must be given in its refs/heads/ or refs/tags/ form. + if [ -z "$(printf '%s' "$REQUESTED" | tr -d '0-9a-fA-F')" ] && [ "${#REQUESTED}" -ge 7 ]; then + echo "::error::fleet_ref '$REQUESTED' looks like a raw commit SHA. Fork pull-request commits are reachable by SHA from this repository, so only named refs are accepted (e.g. main, refs/heads/x, v1.2.3)." + exit 1 + fi printf 'resolved=%s\n' "$REQUESTED" >> "$GITHUB_OUTPUT" - name: Checkout fleet (build script) diff --git a/scripts/check_action_pins_test.go b/scripts/check_action_pins_test.go new file mode 100644 index 000000000..498b8c5de --- /dev/null +++ b/scripts/check_action_pins_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2025 ElcanoTek +// SPDX-License-Identifier: MIT + +package scripts + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Every third-party `uses:` in .github/workflows must be pinned to a 40-hex +// commit SHA with a trailing `# vX.Y.Z` comment. Two separate properties, and +// the repo has been bitten by the second one: +// +// 1. A mutable tag (`@v4`) lets whoever controls the upstream repository change +// what fleet's CI executes, which is the whole reason #1246 converted 53 +// refs to SHAs. +// 2. The comment has to name an EXACT version. Two refs shipped as +// `# v4 (4.37.8)` and `# v9`, and dependabot-core parses the leading version +// token — so it read those as "4" and "9". Worse, both SHAs were the +// ANNOTATED TAG OBJECT of the mutable major tag rather than the commit it +// pointed at (verified with `git ls-remote --tags`: `refs/tags/v4` -> +// 4c0873ef, `refs/tags/v4^{}` -> db488dde). A tag object is immutable, but +// it is only reachable while that tag still points at it — the moment +// upstream moves `v4`, the object is unreferenced and Actions can no longer +// resolve the ref. That is a self-inflicted CI outage with no bad actor +// involved, and it was armed in six places. +// +// This test cannot tell a tag object from a commit offline (that needs the +// network). It enforces the shape, which is what makes the drift reviewable: +// an exact version comment is what lets a human or Dependabot check the SHA +// against a release. + +var ( + usesLine = regexp.MustCompile(`(?m)^\s*(?:-\s+)?uses:\s*(\S+)\s*(#.*)?$`) + shaRef = regexp.MustCompile(`^[0-9a-f]{40}$`) + exactVer = regexp.MustCompile(`^#\s*v\d+\.\d+\.\d+\b`) +) + +func TestWorkflowsPinActionsBySHAWithExactVersionComment(t *testing.T) { + root := repoRoot(t) + dir := filepath.Join(root, ".github", "workflows") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read %s: %v", dir, err) + } + var checked int + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yml") { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + t.Fatalf("read %s: %v", e.Name(), err) + } + for _, m := range usesLine.FindAllStringSubmatch(string(raw), -1) { + ref, comment := m[1], strings.TrimSpace(m[2]) + // A local reusable-workflow call is a path, not a versioned action; + // it is this repository's own code at this repository's own commit. + if strings.HasPrefix(ref, "./") { + continue + } + at := strings.LastIndex(ref, "@") + if at < 0 { + t.Errorf("%s: `uses: %s` has no version at all", e.Name(), ref) + continue + } + checked++ + action, version := ref[:at], ref[at+1:] + if !shaRef.MatchString(version) { + t.Errorf("%s: `uses: %s` is pinned to %q, not a 40-hex commit SHA — a mutable ref lets upstream change what CI runs", + e.Name(), action, version) + continue + } + if comment == "" { + t.Errorf("%s: `uses: %s@%s` has no version comment — add `# vX.Y.Z` so the pin is reviewable and Dependabot can bump it", + e.Name(), action, version[:8]) + continue + } + if !exactVer.MatchString(comment) { + t.Errorf("%s: `uses: %s@%s` is commented %q — must name an EXACT version (`# vX.Y.Z`); a bare major reads as that major to Dependabot and hides which release the SHA is", + e.Name(), action, version[:8], comment) + } + } + } + if checked == 0 { + t.Fatal("no third-party `uses:` refs found — this test would pass vacuously") + } + t.Logf("checked %d third-party action references", checked) +} diff --git a/scripts/check_gate_needs_test.go b/scripts/check_gate_needs_test.go new file mode 100644 index 000000000..ec8cd5256 --- /dev/null +++ b/scripts/check_gate_needs_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2025 ElcanoTek +// SPDX-License-Identifier: MIT + +package scripts + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// ci.yml and dev-ci.yml each end in an aggregate gate job (`CI gate` / +// `Dev gate`) whose `needs` is the list branch protection is pointed at. A job +// that runs but is NOT in that list is red-but-not-required — it can fail +// forever behind a green gate, which is exactly how the CodeQL Go extraction +// break sat unnoticed for weeks and the reason #1246 was written. +// +// Nothing asserted the list was complete. Adding a job and forgetting to extend +// `needs` is a silent, one-line regression with no failing test, so this is that +// test: every job in the file except the gate itself must be in the gate's +// needs. +// +// Deliberately a hand-rolled scan rather than a YAML dependency: the `scripts` +// package has no non-test Go files and no imports beyond the standard library, +// and adding a YAML parser to the module for one assertion is a worse trade than +// a regexp over a file whose shape this repo controls. + +var ( + // A top-level job key: exactly two spaces of indent, then `name:`. + jobKeyRe = regexp.MustCompile(`(?m)^ ([a-zA-Z0-9_-]+):$`) + needsRe = regexp.MustCompile(`(?m)^ needs:\s*\[([^\]]*)\]`) +) + +func TestAggregateGateNeedsEveryJob(t *testing.T) { + root := repoRoot(t) + for _, tc := range []struct{ file, gate string }{ + {"ci.yml", "ci-gate"}, + {"dev-ci.yml", "dev-gate"}, + } { + raw, err := os.ReadFile(filepath.Join(root, ".github", "workflows", tc.file)) + if err != nil { + t.Fatalf("read %s: %v", tc.file, err) + } + text := string(raw) + + // Only look below `jobs:` so a top-level key like `permissions:` is not + // mistaken for a job. + jobsAt := strings.Index(text, "\njobs:\n") + if jobsAt < 0 { + t.Fatalf("%s: no top-level `jobs:` block", tc.file) + } + jobsBlock := text[jobsAt:] + + var jobs []string + for _, m := range jobKeyRe.FindAllStringSubmatch(jobsBlock, -1) { + jobs = append(jobs, m[1]) + } + if len(jobs) < 2 { + t.Fatalf("%s: found %d jobs — the scan is broken, not the workflow", tc.file, len(jobs)) + } + + gateAt := strings.Index(jobsBlock, "\n "+tc.gate+":\n") + if gateAt < 0 { + t.Fatalf("%s: no `%s` job — if the gate was renamed, update this test AND the branch ruleset", tc.file, tc.gate) + } + m := needsRe.FindStringSubmatch(jobsBlock[gateAt:]) + if m == nil { + t.Fatalf("%s: `%s` has no inline `needs: [...]` — this test only understands the inline form", tc.file, tc.gate) + } + needs := map[string]bool{} + for _, n := range strings.Split(m[1], ",") { + if n = strings.TrimSpace(n); n != "" { + needs[n] = true + } + } + + var missing []string + for _, j := range jobs { + if j == tc.gate { + continue + } + if !needs[j] { + missing = append(missing, j) + } + } + sort.Strings(missing) + if len(missing) > 0 { + t.Errorf("%s: job(s) %v run but are not in `%s`'s needs — they are red-but-not-required. Add them, or the gate reports green while they fail.", + tc.file, missing, tc.gate) + } + t.Logf("%s: %s covers %d/%d jobs", tc.file, tc.gate, len(needs), len(jobs)-1) + } +} From 9340efd4e063e297e5ce0e81b837934fcdd5a45a Mon Sep 17 00:00:00 2001 From: Brad Flaugher Date: Sat, 22 Aug 2026 16:16:15 +0000 Subject: [PATCH 04/10] Remove dead code, close the CI permission gaps, settle two stale claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dead code is audit surface: an auditor asks "why is this here, is it reachable, is it maintained?" and every unreachable identifier costs a round of that. Each deletion below was verified with `git grep -w ` across all tracked files — every hit was the declaration and its own doc comment, zero call sites, zero test references, and no coupling to docs/openapi.yaml or schemaModelRegistry (so TestOpenAPISchemaDrift cannot regress). - internal/mcpoauth/errors.go: IsInvalidClient. The odd one out of a four-predicate set — IsInvalidGrant, IsInvalidTarget and IsInvalidScope all have callers. Its job is already done inline: IsTerminalRefreshError and ReauthDetail both switch on the literal "invalid_client" rather than calling it. - internal/sched/apikeys/apikeys.go: Manager.LogAction. A nine-parameter exported wrapper over the private m.logAudit that nothing calls. Worth naming precisely because it is API-key AUDIT logging: a reviewer grepping for audit surface lands here first and has to work out that callers use logAudit directly. Deleting removes the ambiguity. - internal/sched/models/models.go: TaskAssignment and LogSubmission — the two halves of the retired v1 remote-worker protocol (OrchestratorURL, Files/FileChecksums, the worker log POST), superseded when the platform consolidated into one process. The live log path uses models.LogSession directly. - internal/sched/models/models.go: MaxLogSubmissionSize. This one is more than clutter — it declared a 24 MB body cap that NOTHING enforced. The cap actually applied is MaxJSONBodySize = 1 MB (internal/sched/handlers/middleware.go, wired via BodySizeLimitMiddleware). So the real posture was 24x stricter than the constant claimed, and an auditor reading models.go would have concluded fleet accepts 24 MB bodies. An unenforced-limit claim is exactly the kind of thing that becomes a finding. - internal/config/config.go: DefaultFromEmail. Its doc comment called it "the fallback From address for outgoing mail", but no code path consumes it — an operator who sets neither SENDGRID_FROM_EMAIL nor MAILBUX_FROM_EMAIL does not get this fallback. A documented capability that does not exist is a violation of this repo's own honesty-in-docs invariant, so the constant goes rather than the claim being left standing. - internal/tools/task_tracker.go: the only commented-out code block in the tree, plus the inProgressCount it was the sole reader of (the counter was incremented and never read once the comment is gone). Replaced with a sentence saying why there is deliberately no "more than one in_progress" check. `golangci-lint` with `default: standard` already includes `unused` and is a full gate, so unexported dead code is structurally zero — which is why everything above is an EXPORTED identifier in internal/, the class `unused` deliberately does not report. Confirmed independently with `deadcode -test -tags fleet_host_executor ./...`, which now reports nothing. CI permissions and the alarm: - scan-cron-alarm.yml only fired on `conclusion == 'failure'`. That ignores startup_failure — which is the exact failure this file's own header describes as the incident that motivated it (an in-job alarm variant failed a whole Dev CI run that way, so NO scanning ran on that head) — and timed_out, which matters given codeql.yml caps at 30 minutes and semgrep.yml at 15. Now alarms on any conclusion that is not success or skipped. Also added the daily real-model canary to the watched list: it had no alarm at all, and a silently red daily canary is the rot pattern this file exists to prevent. Noted that the watcher matches on workflow DISPLAY NAME, so renaming `name:` disarms it. - ci.yml carried `pull-requests: read` at WORKFLOW level for golangci-lint-action's only-new-issues, which is explicitly `false`. So the scope had no consumer while still reaching every job that does not override it — including web, playwright and e2e-live, which npm-install and execute thousands of third-party packages. Removed. - screenshots.yml held `contents: write` at workflow level for a single job that runs `npm ci` and `playwright install`. It bought nothing: the push it existed for cannot succeed, because the main ruleset carries a pull_request rule with current_user_can_bypass: never and no bypass actors. So it was a repo-writable token handed to third-party code on every run, in exchange for a guaranteed-failing push whose commit message also carried [skip ci]. Dropped to read, with the shape a real implementation would take written down. - auto-merge-dependabot.yml: the header asserted "CI is the approval signal and it is never bypassed", because `gh pr merge --auto` holds the merge until every REQUIRED check passes. That is only true where something is required. The dev ruleset requires no status checks at all, and dependabot.yml points every version update at dev — so there was nothing holding the merge. Excluded `github_actions` from auto-merge (that ecosystem's "dependency" IS the CI definition, and cooldown is not even available for it — Dependabot supports cooldown for gomod and npm only), added a `branches: [main, dev]` filter so this can never silently apply to an unprotected branch, and moved the write scopes from workflow level onto the one job that needs them. Getting `Dev gate` into the dev ruleset is the real fix and is a repo-settings action; it is flagged for the owner. - codeql.yml / dev-ci.yml: moved the two remaining `${{ }}` expressions out of `run:` blocks and into `env:`. The values come from {success, failure, cancelled, skipped} so nothing attacker-controlled reached the shell, but this is the shape the two sites fixed in #1246 were fixed away FROM, and it breaks semgrep's bash sub-parser, which silently costs coverage on the very files it appears in. Two stale claims settled rather than left for an auditor to find: - docs/adr/0012: `cmd/fleet-admin` was to be "a deprecation shim for ONE release ... removed next release". That clock never started — `git tag` returns nothing, VERSION is 0.0.0, and CHANGELOG.md has only an [Unreleased] heading, so "next release" is not a date. The shim also turns out to be load-bearing rather than vestigial: the Makefile, bootstrap.sh, update.sh and fleet-upgrade.sh all build or install it, the last two hard-fail without it, and scripts_dryrun_test.go asserts the "would install fleet + fleet-admin" string. Amended with a concrete trigger — removed in the first release after 1.0.0 — and the note that it forks no logic (it shares internal/admincli.Run). docs/EVENT-TRIGGERS.md and docs/openapi.yaml were still teaching `fleet-admin sched trigger …` as the primary command for HMAC-secret rotation; those are security procedures, so they now say `fleet`. - migration 022 carried the tree's only TODO(security) — and every auditor greps for that string. Two problems beyond the deferred work: it sat in an APPLIED migration, so it was parked where nobody can close it in place, and it pointed at a source file in an unrelated external codebase (a dangling cross-repo pointer in a security note). Rewritten to state the fact plainly (the column holds account NAMES, never credential values, which are brokered host-side per ADR-0003 / ADR-0042), to say that whether account names are themselves in scope is an open threat-model question for the owner, and to point at SECURITY.md as where that gets answered. golang-migrate tracks by version with no checksum, so editing the comment cannot re-run or invalidate the applied DDL. The tree now has zero TODO/FIXME/XXX/HACK markers. - .golangci.yml's noctx exclusion said "the one production noctx (cmd/fleet-admin bootstrap) is fixed in code via exec.CommandContext". cmd/fleet-admin has held no exec call since the CLI was unified in #461; the real call sites are in cmd/fleet and internal/admincli. Repointed, because a lint suppression whose stated reason names dead code is a suppression nobody can re-verify. - scripts/generate-icons.py declared web/src/app/favicon.ico among its outputs. That file has never been committed and is not gitignored either, so it existed only on whoever last ran the script — while every other declared output IS committed. The App Router serves icon.svg and apple-icon.png, with favicon-16/32.png under public/, so the .ico had no consumer. Dropped, and the docstring now records that the outputs are committed and when to regenerate. Signed-off-by: Brad Flaugher --- .github/workflows/auto-merge-dependabot.yml | 57 ++++++++++++++----- .github/workflows/ci.yml | 8 ++- .github/workflows/codeql.yml | 9 ++- .github/workflows/dev-ci.yml | 9 ++- .github/workflows/scan-cron-alarm.yml | 26 +++++++-- .github/workflows/screenshots.yml | 27 ++++++++- .golangci.yml | 7 ++- docs/EVENT-TRIGGERS.md | 8 +-- docs/adr/0012-unified-fleet-cli.md | 30 ++++++++-- docs/openapi.yaml | 2 +- internal/config/config.go | 4 -- internal/mcpoauth/errors.go | 12 ---- internal/sched/apikeys/apikeys.go | 5 -- .../022_add_task_credential_allowlist.up.sql | 19 +++++-- internal/sched/models/models.go | 25 -------- internal/tools/task_tracker.go | 16 ++---- scripts/generate-icons.py | 22 ++++--- 17 files changed, 178 insertions(+), 108 deletions(-) diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml index 810b2e00e..00b60e271 100644 --- a/.github/workflows/auto-merge-dependabot.yml +++ b/.github/workflows/auto-merge-dependabot.yml @@ -2,11 +2,31 @@ # but every one — even a routine patch — currently waits on a human to merge. # This workflow lets PATCH-level bumps merge themselves once the full CI gate # (build / vet / lint / test / -race / govulncheck, web lint+test+build, -# Playwright mocked + live, and the gitleaks secret scan) is green. CI is the -# correct approval signal for a patch; it is never bypassed — `gh pr merge -# --auto` only enables auto-merge, so GitHub still holds the merge until every -# required check passes. Minor and major bumps are intentionally left for a -# human, where an API change or a transitive surprise is more likely. +# Playwright mocked + live, and the gitleaks secret scan) is green. Minor and +# major bumps are intentionally left for a human, where an API change or a +# transitive surprise is more likely. +# +# TWO LIMITS THAT ARE LOAD-BEARING, both learned the hard way: +# +# 1. "CI is the approval signal, and it is never bypassed" IS ONLY TRUE WHERE +# THE GATE IS A REQUIRED CHECK. `gh pr merge --auto` asks GitHub to hold the +# merge until every REQUIRED check passes — so on a branch whose ruleset +# requires nothing, there is nothing to hold it and the PR merges as soon as +# it is mergeable. The `dev` ruleset currently requires no status checks at +# all (only `deletion` and `non_fast_forward`), and .github/dependabot.yml +# points every version update at `dev`. So the `branches:` filter below is +# not cosmetic: it keeps this workflow from applying to a branch where its +# central assumption does not hold. Getting `Dev gate` into the dev ruleset +# is the real fix and is a repo-settings action; see docs/SCANNING.md +# ("Known gaps"). +# +# 2. A `github-actions` bump IS A REWRITE OF .github/workflows/*. It changes +# what CI executes, on a surface where the cooldown that protects gomod and +# npm is not even available (Dependabot supports `cooldown` for those two +# ecosystems only), so a freshly published action version can be proposed +# the same day. That combination — self-modifying CI, no cooldown, no +# required check on the target branch — is not something to auto-merge, so +# that ecosystem is excluded below and takes a human. # # Requires "Allow auto-merge" to be enabled on the repository (Settings → # General → Pull Requests). This is the pattern documented in GitHub's @@ -15,18 +35,26 @@ name: Auto-merge Dependabot patch PRs on: pull_request: + # See limit 1 in the header: this workflow's safety rests on the target + # branch having required checks. Naming the branches explicitly means it can + # never silently start applying to one nobody protected. + branches: [main, dev] -# Dependabot-triggered runs get a read-only GITHUB_TOKEN by default; these -# elevated permissions are honored only for the dependabot[bot] actor, and the -# job guard below makes sure nothing else can reach the merge step. -permissions: - contents: write - pull-requests: write +# What actually confines these scopes is the `if: github.actor == +# 'dependabot[bot]'` guard on the job below — a `permissions:` block is honored +# for whatever run reaches it, regardless of actor. github.actor is not +# spoofable, so the guard holds; the scopes are declared on the JOB rather than +# the workflow so a second job added here later does not inherit write access it +# never asked for. +permissions: {} jobs: auto-merge: if: ${{ github.actor == 'dependabot[bot]' }} runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - name: Fetch Dependabot metadata id: meta @@ -35,8 +63,11 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Enable auto-merge for patch updates - # Only patch bumps auto-merge; minor and major get human review. - if: ${{ steps.meta.outputs.update-type == 'version-update:semver-patch' }} + # Only patch bumps auto-merge; minor and major get human review. And + # never github-actions, whatever the bump level — see limit 2 in the + # header: that ecosystem's "dependency" is the CI definition itself. + if: ${{ steps.meta.outputs.update-type == 'version-update:semver-patch' + && steps.meta.outputs.package-ecosystem != 'github_actions' }} run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 241bd38ea..612edae39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,13 @@ on: branches: [main] permissions: + # Least privilege at the workflow level, so nothing is granted to a job that + # did not ask. `pull-requests: read` used to sit here for + # golangci-lint-action's only-new-issues — which is now explicitly `false` + # (see the note at that step), so the scope had no consumer while still + # reaching every job that does not override it, including web / playwright / + # e2e-live, which npm-install and run thousands of third-party packages. contents: read - # Required by golangci-lint-action's only-new-issues option (reads the PR diff). - pull-requests: read jobs: changes: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c1417224b..60c692327 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -371,8 +371,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Fail if any CodeQL analysis did not succeed + # RESULTS via env, not interpolated into the run: block. The values are + # drawn from {success, failure, cancelled, skipped} so nothing + # attacker-controlled reaches the shell — but this is the exact shape the + # two sites fixed in #1246 were fixed away FROM, and it breaks semgrep's + # bash sub-parser, which silently costs coverage on this very file. + env: + RESULTS: ${{ join(needs.*.result, ' ') }} run: | - results='${{ join(needs.*.result, ' ') }}' + results="$RESULTS" echo "job results: $results" for r in $results; do [ "$r" = "success" ] || { echo "a CodeQL analysis did not succeed"; exit 1; } diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index caa9d480f..027cdcbfc 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -313,8 +313,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Fail if any fast-lane job failed + # RESULTS via env, not interpolated into the run: block. The values are + # drawn from {success, failure, cancelled, skipped} so nothing + # attacker-controlled reaches the shell — but this is the exact shape the + # two sites fixed in #1246 were fixed away FROM, and it breaks semgrep's + # bash sub-parser, which silently costs coverage on this very file. + env: + RESULTS: ${{ join(needs.*.result, ' ') }} run: | - results='${{ join(needs.*.result, ' ') }}' + results="$RESULTS" echo "job results: $results" for r in $results; do [ "$r" = "success" ] || { echo "a fast-lane job did not succeed"; exit 1; } diff --git a/.github/workflows/scan-cron-alarm.yml b/.github/workflows/scan-cron-alarm.yml index ac12b94ab..07b62a4ec 100644 --- a/.github/workflows/scan-cron-alarm.yml +++ b/.github/workflows/scan-cron-alarm.yml @@ -25,7 +25,10 @@ name: Scan cron alarm on: workflow_run: - workflows: [CodeQL, Semgrep] + # Matched by workflow DISPLAY NAME (the `name:` in each file), not by path — + # renaming `name:` in codeql.yml, semgrep.yml or e2e-canary.yml silently + # disarms this alarm with no error anywhere. Keep those three in sync. + workflows: [CodeQL, Semgrep, "E2E canary (real model)"] types: [completed] permissions: @@ -36,14 +39,29 @@ jobs: name: File an issue so a red cron cannot rot silently # Scheduled failures only: a red workflow_call run already reddens the # calling gate on a PR, and a red manual dispatch has a human watching it. + # + # NOT `conclusion == 'failure'`. workflow_run.conclusion is also + # startup_failure, timed_out, cancelled or action_required — and + # startup_failure is the one this file exists for: the in-job alarm variant + # this replaced failed an entire Dev CI run that way (see the header), which + # meant NO scanning ran on that head at all. A weekly cron that dies on + # malformed YAML or a permissions change is exactly the invisible breakage + # the alarm is for, and `== 'failure'` ignored it. timed_out matters too: + # codeql.yml caps at 30 minutes and semgrep.yml at 15. + # + # `skipped` and `success` are the only conclusions that are not an alarm. if: >- - github.event.workflow_run.conclusion == 'failure' && - github.event.workflow_run.event == 'schedule' + github.event.workflow_run.event == 'schedule' && + github.event.workflow_run.conclusion != 'success' && + github.event.workflow_run.conclusion != 'skipped' runs-on: ubuntu-latest steps: - name: File or update the alarm issue # Body mirrors the in-job alarm steps in govulncheck-scheduled.yml and - # grype-scheduled.yml — keep the three in sync. + # grype-scheduled.yml — keep the three in sync. Those two still carry + # their own in-job step because they can request `issues: write` + # directly; CodeQL and Semgrep cannot, because a called workflow may not + # request a permission its caller did not grant (see the header). env: GH_TOKEN: ${{ github.token }} WORKFLOW_NAME: ${{ github.event.workflow_run.name }} diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index 7eef6e508..729d4184d 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -28,8 +28,21 @@ concurrency: group: screenshots-${{ github.ref }} cancel-in-progress: true +# Read-only, deliberately. This workflow has ONE job, and that job npm-installs +# and runs Playwright — thousands of third-party packages — so `contents: write` +# here was a repo-writable token handed to third-party code on every run. +# +# It bought nothing: the push it existed for cannot succeed. The `main` ruleset +# carries a pull_request rule with current_user_can_bypass: never and no bypass +# actors, so a direct push to main is refused regardless of token scope. The push +# step is already written to warn rather than fail, so dropping the scope changes +# the warning's wording and nothing else. +# +# If refreshing screenshots from CI is ever wanted for real, the shape is a +# second job that needs: [screenshots], holds `contents: write` alone, runs no +# third-party code, and opens a PR — not a direct push. permissions: - contents: write + contents: read jobs: screenshots: @@ -97,6 +110,14 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add docs/screenshots git commit -m "docs(screenshots): refresh GUI + TUI screenshots [skip ci]" - # main may require PRs; a rejected push warns rather than failing the run. + # The `main` ruleset carries a pull_request rule with + # current_user_can_bypass: never and no bypass actors, so a direct push + # to main CANNOT succeed — this is a permanent no-op, not an + # occasional one, and the [skip ci] in the commit message above means + # anything it did land would bypass CI on main. The workflow token is + # also read-only now (see the permissions note at the top), so this + # fails on scope first. Kept as a warning rather than an error so the + # capture half stays useful; do not "fix" it by widening either the + # ruleset or the token — refresh via a PR instead. git push origin HEAD:main \ - || echo "::warning::could not push refreshed screenshots (is main protected against direct pushes? allow the github-actions bot, or refresh via PR). The committed baseline still serves." + || echo "::warning::could not push refreshed screenshots — the main ruleset requires a pull request, so this push is expected to fail. Refresh via PR; the committed baseline still serves." diff --git a/.golangci.yml b/.golangci.yml index 31f4ebf23..13337e817 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -111,8 +111,11 @@ linters: # noctx: httptest.NewRequest (vs NewRequestWithContext) is idiomatic # in tests — these are synthetic requests with no cancellation # semantics, so threading a context through every one is pure churn. - # The one production noctx (cmd/fleet-admin bootstrap) is fixed in - # code via exec.CommandContext. + # Production call sites use exec.CommandContext rather than + # exec.Command — cmd/fleet/{mcp_broker_runtime,health,validate_config}.go + # and internal/admincli/backup.go. (This note used to name + # cmd/fleet-admin, which has held no exec call since the CLI was + # unified in #461.) - noctx # Exclude shadow checking for err variables (common pattern) - text: 'shadow: declaration of "err"' diff --git a/docs/EVENT-TRIGGERS.md b/docs/EVENT-TRIGGERS.md index e2472d1fb..90e9a597d 100644 --- a/docs/EVENT-TRIGGERS.md +++ b/docs/EVENT-TRIGGERS.md @@ -140,7 +140,7 @@ is a no-op when SMTP isn't configured. # The template task must exist first (create it with trigger_type=webhook so the # cron engine never runs it; set allow_event_triggers=true to let event runs use # its connectors). -fleet-admin sched trigger create \ +fleet sched trigger create \ --task \ --slug weekly-deploy \ --kind email \ @@ -150,9 +150,9 @@ fleet-admin sched trigger create \ --max-attachments 3 --max-attachment-bytes 1048576 \ [--template prompt.tmpl] # optional Go text/template over {{.From}} {{.Subject}} {{.Text}} {{.HTML}} {{.To}} -fleet-admin sched trigger list # shows id, kind, slug, task -fleet-admin sched trigger rotate # rotate the HMAC secret -fleet-admin sched trigger delete +fleet sched trigger list # shows id, kind, slug, task +fleet sched trigger rotate # rotate the HMAC secret +fleet sched trigger delete ``` The rendered prompt is what the spawned run receives. With no `--template`, a diff --git a/docs/adr/0012-unified-fleet-cli.md b/docs/adr/0012-unified-fleet-cli.md index be6ba5775..a16693872 100644 --- a/docs/adr/0012-unified-fleet-cli.md +++ b/docs/adr/0012-unified-fleet-cli.md @@ -30,9 +30,26 @@ There is **one `fleet` binary** (`cmd/fleet`) with subcommand dispatch - Every other verb (`update`, `status`, `bootstrap`, `chat`, `sched`, `task`, `mcp`, `notes`, `worktree`, `backup`, `restore`, `motd`, …) routes to `internal/admincli.Run`. -- `cmd/fleet-admin` is reduced to a **deprecation shim** for ONE release: it - prints a one-line notice and forwards to the same `admincli.Run`, so existing - scripts and the in-place upgrade path keep working. It is removed next release. +- `cmd/fleet-admin` is reduced to a **deprecation shim**: it prints a one-line + notice and forwards to the same `admincli.Run`, so existing scripts and the + in-place upgrade path keep working. + + **Amended 2026-08-22 (enterprise security audit).** This originally said "for + ONE release ... removed next release". That clock never started: `git tag` + returns nothing, `VERSION` is `0.0.0`, and `CHANGELOG.md` has only an + `[Unreleased]` heading — there has never been a release, so "next release" is + not a date and "one release" is not a window. The shim also turns out to be + load-bearing rather than vestigial: `Makefile` (`bins`, `install`), + `scripts/bootstrap.sh`, `scripts/update.sh` and `scripts/fleet-upgrade.sh` all + build or install it, the last two *hard-fail* if the binary is missing, and + `internal/admincli/scripts_dryrun_test.go` asserts the "would install fleet + + fleet-admin" string. So removal is a coordinated change across four scripts and + two test assertions, not a deletion. + + The concrete trigger, replacing the unanchored one: **the shim is removed in + the first release after 1.0.0.** Until then it stays, and it is 20 lines that + fork no logic — it shares `internal/admincli.Run` with `fleet`, so it adds no + second governance path. `make install` puts `fleet` (and the shim) on `PATH` — the actual fix for "isn't installed" on a dev box. The systemd unit is **not** force-migrated to @@ -51,11 +68,12 @@ installed" on a dev box. The systemd unit is **not** force-migrated to ## Consequences - Operators get the unified `fleet` they asked for; muscle memory (`fleet-admin - `) still works for one release with a deprecation warning. + `) still works, with a deprecation warning, until the removal trigger + above. - The daemon artifact stays named `fleet`, so the highest-blast-radius references (systemd unit + bootstrap on a *running* box) barely move. -- Two binaries still build for one release (the shim), so the existing - build/upgrade scripts that expect both `fleet` and `fleet-admin` are unchanged. +- Two binaries still build (the shim), so the existing build/upgrade scripts + that expect both `fleet` and `fleet-admin` are unchanged. - A future release deletes the shim and may flip bare `fleet` to print help (requiring explicit `serve`); by then every deployed unit says `fleet serve`. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 24fd16652..612c46ba7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2716,7 +2716,7 @@ paths: equalized HMAC work) to prevent slug enumeration. A leaked secret lets a caller inject the spawned run's prompt under the template's seat (network/MCP/files); the sandbox remains the execution boundary. Rotate - with `fleet-admin sched trigger rotate`. + with `fleet sched trigger rotate`. security: [] requestBody: content: diff --git a/internal/config/config.go b/internal/config/config.go index 4c8f91b1a..9669ddba0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,10 +57,6 @@ const ( // Mirrors the frontend's DEFAULT_MODEL (the recommended everyday pick). const DefaultTitleModel = "google/gemini-3.7-flash" -// DefaultFromEmail is the fallback From address for outgoing mail. Neutral by -// default; a deployment overrides via SENDGRID_FROM_EMAIL / MAILBUX_FROM_EMAIL. -const DefaultFromEmail = "noreply@example.com" - // Sub-agent caps (#175, tightened for delegation #264): deliberately SMALL // defaults. Depth bounds recursion; fan-out bounds how many children one parent // may spawn; the budget fraction bounds each child's slice of the parent's diff --git a/internal/mcpoauth/errors.go b/internal/mcpoauth/errors.go index 1fbfb960e..cbe8c1a17 100644 --- a/internal/mcpoauth/errors.go +++ b/internal/mcpoauth/errors.go @@ -41,18 +41,6 @@ func IsInvalidTarget(err error) bool { return errors.As(err, &oe) && oe.Code == "invalid_target" } -// IsInvalidClient reports whether err is an OAuthError with code invalid_client -// — the authorization server no longer recognizes our client credentials. For a -// DCR-registered client this usually means the registration was pruned or -// expired server-side; for a BYO client it means the id/secret is wrong or was -// rotated. Either way the stored registration is unusable, so this is terminal -// for refresh (see IsTerminalRefreshError): the connection is marked needs-reauth -// and reconnecting re-runs registration through the normal connect flow. -func IsInvalidClient(err error) bool { - var oe *OAuthError - return errors.As(err, &oe) && oe.Code == "invalid_client" -} - // IsInvalidScope reports whether err is an OAuthError with code invalid_scope — // the authorization server rejected the requested scope. On refresh this is // recoverable: RFC 6749 §6 makes `scope` OPTIONAL and defines its omission as diff --git a/internal/sched/apikeys/apikeys.go b/internal/sched/apikeys/apikeys.go index 61bdcbc81..2bf627a00 100644 --- a/internal/sched/apikeys/apikeys.go +++ b/internal/sched/apikeys/apikeys.go @@ -782,11 +782,6 @@ func (m *Manager) SetMaxPriority(keyID string, ceiling *int) error { return m.save() } -// LogAction logs an action performed with an API key. -func (m *Manager) LogAction(keyID, action, resourceType string, resourceID *string, details map[string]interface{}, ipAddress, userAgent *string, success bool, errorMessage *string) { - m.logAudit(AuditLogEntry{KeyID: keyID, Action: action, ResourceType: resourceType, ResourceID: resourceID, Details: details, IPAddress: ipAddress, UserAgent: userAgent, Success: success, ErrorMessage: errorMessage}) -} - // GetKey gets a key by ID. func (m *Manager) GetKey(keyID string) *APIKey { m.mu.RLock() diff --git a/internal/sched/db/migrations/022_add_task_credential_allowlist.up.sql b/internal/sched/db/migrations/022_add_task_credential_allowlist.up.sql index 29f1617ed..159cbb2e3 100644 --- a/internal/sched/db/migrations/022_add_task_credential_allowlist.up.sql +++ b/internal/sched/db/migrations/022_add_task_credential_allowlist.up.sql @@ -4,9 +4,18 @@ -- unchanged. A non-null (possibly empty) array enforces least-privilege: an MCP -- call to a pair not on the list is denied before it executes. -- --- TODO(security): credential_allowlist stores (server, account) pair NAMES only, --- not credential values. The values themselves never enter the database (they --- live in the process env file; see internal/creds). If account names are --- themselves sensitive, encrypt this column with the AES-256-GCM pattern used --- for project secrets (cf. Suna's apps/api/src/projects/secrets.ts). +-- SECURITY NOTE, and an open question that is deliberately NOT settled here: +-- credential_allowlist stores (server, account) pair NAMES only, never +-- credential values. The values do not enter the database at all — they live in +-- the process env file and are brokered host-side (internal/creds, ADR-0003, +-- ADR-0042). So this column is not a secret store. +-- +-- What is unsettled is whether ACCOUNT NAMES are themselves in scope. If a +-- deployment treats them as sensitive, this column wants encryption at rest. +-- That is a threat-model decision for the repo owner, not something a migration +-- can decide, and it is recorded in SECURITY.md rather than here: a migration is +-- applied history, so a question parked in one is a question nobody can close in +-- place. (An earlier version of this note was an untracked security to-do that +-- pointed at a source file in an unrelated external codebase; the pointer was +-- dangling and nothing tracked the item.) ALTER TABLE tasks ADD COLUMN IF NOT EXISTS credential_allowlist JSONB; diff --git a/internal/sched/models/models.go b/internal/sched/models/models.go index 86ab35ede..0bd6be1c7 100644 --- a/internal/sched/models/models.go +++ b/internal/sched/models/models.go @@ -1968,22 +1968,6 @@ type TaskArtifact struct { Size int64 `json:"size"` // bytes at publish time } -// TaskAssignment is the task assignment carried to the worker. -type TaskAssignment struct { - TaskID uuid.UUID `json:"task_id"` - Prompt string `json:"prompt"` - Model *string `json:"model,omitempty"` - FallbackModel *string `json:"fallback_model,omitempty"` - MaxIterations *int `json:"max_iterations,omitempty"` - MCPSelection MCPSelection `json:"mcp_selection,omitempty"` - CredentialAllowlist CredentialAllowlist `json:"credential_allowlist"` - InstructionSelfImprove bool `json:"instruction_self_improve,omitempty"` - OrchestratorURL string `json:"orchestrator_url"` - Files []string `json:"files,omitempty"` - FileNames []string `json:"file_names,omitempty"` - FileChecksums []string `json:"file_checksums,omitempty"` -} - // DashboardStats contains statistics for the dashboard. type DashboardStats struct { PendingTasks int `json:"pending_tasks"` @@ -2083,15 +2067,6 @@ func (ls LogSession) MarshalJSON() ([]byte, error) { }) } -// LogSubmission is a log submission for a task. -type LogSubmission struct { - TaskID uuid.UUID `json:"task_id"` - Session LogSession `json:"session"` -} - -// MaxLogSubmissionSize is the maximum size of a log submission in bytes (24MB). -const MaxLogSubmissionSize = 24 * 1024 * 1024 - // APIKeyCreate is the request model for creating an API key. type APIKeyCreate struct { Name string `json:"name"` diff --git a/internal/tools/task_tracker.go b/internal/tools/task_tracker.go index 66a2ef3d5..b39e79cf0 100644 --- a/internal/tools/task_tracker.go +++ b/internal/tools/task_tracker.go @@ -185,7 +185,6 @@ func (t *taskTracker) validateTasks(tasks []Task) error { // Check for duplicate IDs seenIDs := make(map[string]bool) - inProgressCount := 0 for i, task := range tasks { if task.ID == "" { @@ -207,18 +206,13 @@ func (t *taskTracker) validateTasks(tasks []Task) error { return fmt.Errorf("duplicate task ID: %s", task.ID) } seenIDs[task.ID] = true - - // Count in_progress tasks - if task.Status == StatusInProgress { - inProgressCount++ - } } - // Warn if multiple tasks are in_progress (but don't error) - // if inProgressCount > 1 { - // // This is just a warning in the description, not enforced - // } - + // Deliberately no "more than one in_progress" check: the tool description + // asks for one in-progress task at a time, but it is guidance, not a + // validation rule, and rejecting the call would strand a model mid-plan. + // A counter and a commented-out `if` used to sit here saying so; the comment + // is the whole content, so it is a comment. return nil } diff --git a/scripts/generate-icons.py b/scripts/generate-icons.py index 57de4d06a..258960d71 100755 --- a/scripts/generate-icons.py +++ b/scripts/generate-icons.py @@ -5,8 +5,20 @@ Requires: pip install cairosvg pillow Master: web/public/logos/fleet-mark.svg (single flattened path, 512 viewBox) -Outputs: web/src/app/ favicon.ico, icon.svg, apple-icon.png +Outputs: web/src/app/ icon.svg, apple-icon.png web/public/app-icons/ favicon-16/32, icon-192/512, maskable-icon-512 + +Every output above IS committed, so this script only needs running when the +master mark changes — regenerate, then commit the diff alongside it. Nothing +calls it from CI or the Makefile on purpose: the icons are assets, not build +products, and a generator in the build path would make every CI run depend on +cairosvg. + +It used to also write web/src/app/favicon.ico, which was never committed and is +not gitignored either — an output that existed only on whoever last ran this. The +App Router serves icon.svg (crisp at any DPI) and apple-icon.png, with +favicon-16/32.png under public/ for anything that wants a raster, so the .ico +had no consumer. Dropped rather than left as drift. """ import io @@ -48,14 +60,6 @@ def render(px: int, scale: float = 1.0, bg: str | None = None) -> Image.Image: # --- classic favicons -------------------------------------------------------- render(16).save(PUB / "favicon-16.png") render(32).save(PUB / "favicon-32.png") -ico = [render(s) for s in (48, 32, 16)] -ico[0].save( - APP / "favicon.ico", - format="ICO", - append_images=ico[1:], - sizes=[(48, 48), (32, 32), (16, 16)], -) - # --- apple touch icon (iOS Add to Home Screen) ------------------------------- # Must be opaque: iOS fills transparency with black. iOS applies its own # corner mask, so this is a full-bleed square with the glyph inset. From e2ff6a66ae1929ca4f2fee544150832002ad1d9f Mon Sep 17 00:00:00 2001 From: Brad Flaugher Date: Sat, 22 Aug 2026 16:19:59 +0000 Subject: [PATCH 05/10] Attribute the fixtures that the own-rows edit gate now requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests broke on taskWritableByPrincipal, and all three for the same reason: their fixture tasks were inserted with neither CreatedBy nor CreatedByKeyID, so an unattributed row was being edited by a client-role key. That is precisely the shape the new gate refuses. None of them is about ownership: - TestUpdateTaskRunIfPersistsAndNormalizes and TestUpdateTaskKeepsGatedTaskOnSchedulerPath are about the run_if privilege boundary and the dispatch-state recompute. - TestTypedKeyRouteScope is about the #190 middleware type-scope gate. So the fix is the fixture, not the gate: each task is now attributed to the key that acts on it, which is also the realistic shape — a task a client key created really does carry that key's CreatedByKeyID. Left unattributed, these tests would have been asserting run_if and type-scope behavior through a path that the ownership check short-circuits first, which is a worse test than either intent. mustCreateTypedKeyWithID joins mustCreateRoleKeyWithID as the helper that returns the KeyID alongside the secret, with a note saying why a fixture wants it. Full Go suite green against a real Postgres 16 (`make test`, -p 1). Signed-off-by: Brad Flaugher --- internal/sched/handlers/run_if_authz_test.go | 20 ++++++++++++---- .../sched/handlers/typed_key_scope_test.go | 24 +++++++++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/internal/sched/handlers/run_if_authz_test.go b/internal/sched/handlers/run_if_authz_test.go index 01ebcf957..28222aa8d 100644 --- a/internal/sched/handlers/run_if_authz_test.go +++ b/internal/sched/handlers/run_if_authz_test.go @@ -154,19 +154,25 @@ func TestCreateTaskRunIfRequiresAdminPermission(t *testing.T) { func TestUpdateTaskRunIfPersistsAndNormalizes(t *testing.T) { r, keyMgr, store := setupRunIfAuthz(t) - clientKey := mustCreateRoleKey(t, keyMgr, "client") + clientKeyID, clientKey := mustCreateRoleKeyWithID(t, keyMgr, "client") // The gated tasks are seeded SCHEDULED: that is where a gate normally lives // (models.RunIf's enforcement contract parks every gated task on the // scheduler path), and gate changes on a pending task are refused outright // — the pending refusal has its own subtests below. + // + // They are also seeded ATTRIBUTED to clientKey. Editing a task is own-rows + // (taskWritableByPrincipal), so an unattributed fixture would be refused by + // the ownership check before reaching the run_if logic this test is about. addGatedWithStatus := func(t *testing.T, status models.TaskStatus) *models.Task { t.Helper() future := time.Now().UTC().Add(time.Hour) + keyID := clientKeyID task := &models.Task{ ID: uuid.New(), Prompt: "a gated task prompt that is long enough", Status: status, CreatedAt: time.Now().UTC(), ScheduledFor: &future, - RunIf: &models.RunIf{Command: "test -f /tmp/ready", ExitCodeIs: 2, TimeoutSeconds: 30}, + CreatedByKeyID: &keyID, + RunIf: &models.RunIf{Command: "test -f /tmp/ready", ExitCodeIs: 2, TimeoutSeconds: 30}, } if _, err := store.AddTask(task); err != nil { t.Fatalf("add task: %v", err) @@ -353,7 +359,10 @@ func TestUpdateTaskRunIfPersistsAndNormalizes(t *testing.T) { func TestUpdateTaskKeepsGatedTaskOnSchedulerPath(t *testing.T) { r, keyMgr, store := setupRunIfAuthz(t) - clientKey := mustCreateRoleKey(t, keyMgr, "client") + // Attributed fixtures throughout: editing is own-rows + // (taskWritableByPrincipal), and this test is about the dispatch-state + // recompute, not about ownership. + clientKeyID, clientKey := mustCreateRoleKeyWithID(t, keyMgr, "client") put := func(taskID uuid.UUID, tc models.TaskCreate) *httptest.ResponseRecorder { body, _ := json.Marshal(tc) req := httptest.NewRequest("PUT", "/tasks/"+taskID.String(), bytes.NewReader(body)) @@ -373,10 +382,11 @@ func TestUpdateTaskKeepsGatedTaskOnSchedulerPath(t *testing.T) { // Seeded the way NewTask parks an immediate gated create: scheduled, // with the parked timestamp in the past by the time the edit lands. past := time.Now().UTC().Add(-time.Minute) + keyID := clientKeyID task := &models.Task{ ID: uuid.New(), Prompt: "a gated task prompt that is long enough", Status: models.TaskStatusScheduled, CreatedAt: time.Now().UTC(), - ScheduledFor: &past, RunIf: gate, + ScheduledFor: &past, RunIf: gate, CreatedByKeyID: &keyID, } if _, err := store.AddTask(task); err != nil { t.Fatalf("add task: %v", err) @@ -411,6 +421,8 @@ func TestUpdateTaskKeepsGatedTaskOnSchedulerPath(t *testing.T) { TriggerType: models.TriggerTypeWebhook, RunIf: gate, }) + keyID := clientKeyID + template.CreatedByKeyID = &keyID if _, err := store.AddTask(template); err != nil { t.Fatalf("add template: %v", err) } diff --git a/internal/sched/handlers/typed_key_scope_test.go b/internal/sched/handlers/typed_key_scope_test.go index 9791cee6e..7875b98e2 100644 --- a/internal/sched/handlers/typed_key_scope_test.go +++ b/internal/sched/handlers/typed_key_scope_test.go @@ -19,11 +19,21 @@ import ( func mustCreateTypedKey(t *testing.T, keyMgr *apikeys.Manager, kt apikeys.KeyType, slugs []string) string { t.Helper() - _, raw, err := keyMgr.CreateTypedKey("test-"+string(kt), kt, slugs, 0, nil, "") + _, raw := mustCreateTypedKeyWithID(t, keyMgr, kt, slugs) + return raw +} + +// mustCreateTypedKeyWithID also returns the KeyID, so a test can attribute a +// fixture task to the key that will act on it (task.CreatedByKeyID). Mutating a +// task is own-rows (taskWritableByPrincipal), so an UNATTRIBUTED fixture now +// exercises the ownership check rather than whatever the test meant to assert. +func mustCreateTypedKeyWithID(t *testing.T, keyMgr *apikeys.Manager, kt apikeys.KeyType, slugs []string) (string, string) { + t.Helper() + key, raw, err := keyMgr.CreateTypedKey("test-"+string(kt), kt, slugs, 0, nil, "") if err != nil { t.Fatalf("create typed key: %v", err) } - return raw + return key.KeyID, raw } // TestTypedKeyRouteScope verifies the #190 middleware type-scope gate on the @@ -33,11 +43,15 @@ func TestTypedKeyRouteScope(t *testing.T) { store, keyMgr, r, cleanup := setupAuthzHandler(t) defer cleanup() - taskA := addTask(t, store, "task A") - readonlyKey := mustCreateTypedKey(t, keyMgr, apikeys.KeyTypeReadonly, nil) webhookKey := mustCreateTypedKey(t, keyMgr, apikeys.KeyTypeWebhook, []string{"pr-review"}) - taskKey := mustCreateTypedKey(t, keyMgr, apikeys.KeyTypeTask, nil) + taskKeyID, taskKey := mustCreateTypedKeyWithID(t, keyMgr, apikeys.KeyTypeTask, nil) + + // Attributed to the task key: this test is about the #190 type-scope gate, + // not about ownership, so the fixture is the realistic shape (a task the + // acting key created) rather than an unattributed row that would additionally + // trip the own-rows edit check. + taskA := addTaskCreatedByKey(t, store, "task A", taskKeyID) t.Run("readonly key may GET tasks", func(t *testing.T) { req := httptest.NewRequest("GET", "/tasks", nil) From 054e9f0f3a4f4823be01e388f4250e9f9bd99c37 Mon Sep 17 00:00:00 2001 From: Brad Flaugher Date: Sat, 22 Aug 2026 16:21:29 +0000 Subject: [PATCH 06/10] Trim the unlinked planning scratchpad to its one live plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/implementation-plans-enhancements.md had zero inbound links from any markdown, Go file, workflow or script, and three of its four plans had shipped. The shipped copies are the problem, not the file: a plan sitting next to the authoritative record is the worse of the two, and this one was phrased in the present tense. The #167 entry is why this matters for an audit rather than being tidy-up. It carried a section headed "OAuth control-plane tokens parent-readable" whose resolution — accepted for v1, threat model written down (parent compromise implies stored remote-MCP tokens; agent runs stay child-side per ADR-0040) — was already recorded in SECURITY.md and docs/MCP-BROKER-SCOPES.md. A second copy, phrased as an open decision, in an unlinked file, reads like an unresolved security gap to anyone who greps their way into it. It is not one. What remains is #984 (the Fleet <-> Buzz bridge), which genuinely has not shipped, under a header that says so — and that says explicitly that its unchecked acceptance boxes (including "Bot token not logged; secrets in env only") are criteria the feature must meet BEFORE it ships, on code that does not exist yet, not a list of open findings. That distinction is invisible in a bare checklist and is exactly what an auditor would otherwise have to ask about. Signed-off-by: Brad Flaugher --- docs/implementation-plans-enhancements.md | 132 ++++------------------ 1 file changed, 23 insertions(+), 109 deletions(-) diff --git a/docs/implementation-plans-enhancements.md b/docs/implementation-plans-enhancements.md index 28b213451..da73857d1 100644 --- a/docs/implementation-plans-enhancements.md +++ b/docs/implementation-plans-enhancements.md @@ -1,89 +1,29 @@ -# Implementation plans for open enhancements +# Implementation plan: #984 — Fleet ↔ Buzz bridge -Working notes for implementers. Prefer the linked issue comment/body when present. +**Status: NOT SHIPPED.** This is a forward-looking design note for one open +enhancement. Read it as a proposal, not as a description of fleet's behavior, and +in particular do not read the acceptance checklist below as a list of open +security findings — the unchecked boxes are criteria this feature must MEET +BEFORE it ships, on code that does not exist yet. -| Issue | Plan location | +Everything else that used to live in this file has been removed because it had +shipped, and a shipped plan sitting next to the authoritative record is the worse +of the two copies: + +| was | now recorded in | | --- | --- | -| #989 | [comment](https://github.com/ElcanoTek/fleet/issues/989#issuecomment-5198861451) | -| #988 | [issue body](https://github.com/ElcanoTek/fleet/issues/988) | -| #987 | [comment](https://github.com/ElcanoTek/fleet/issues/987#issuecomment-5198925257) — **shipped**; see `internal/clientconfig/builtin_skills/browserbase/`, `internal/tools/browserbase_live_view.go`, `docs/BROWSERBASE.md` | -| #986 | [issue body](https://github.com/ElcanoTek/fleet/issues/986) | -| #985 | Full plan below — **shipped**; see `internal/clientconfig/builtin_skills/bento-slides/`, `docs/SKILLS.md`, `docs/FEATURE-NOTES.md` | -| #984 | Full plan below (pending issue comment) | -| #167 | Full plan below — **all three residuals resolved**; see `docs/MCP-BROKER-SCOPES.md`, ADR-0042, `SECURITY.md` | - ---- - -## #985 — Bento built-in skill (good first issue, size S) - -[Bento](https://github.com/nyblnet/bento) decks are a **single HTML file** (viewer + editor + slides). Agent edits HTML in workspace → downloadable deck **without Gamma or any external API**. - -### Approach - -1. **Built-in skill** in `internal/clientconfig/builtin_skills/`: - - `bento-slides/SKILL.md` — when to use; copy template; structure slides; what not to break. - - `bento-slides/templates/starter.bento.html` — minimal legal template. -2. **Agent workflow:** copy template → `workspace/decks/.bento.html` → edit via file tools → user downloads and opens in browser. -3. **License / attribution:** confirm redistribution allowed; attribute in skill + NOTICE. -4. **Validation:** `ValidateSkills` frontmatter; optional eval "Create a 5-slide deck about X". -5. **Docs:** one line in `docs/SKILLS.md`. No new HTTP APIs. - -### Non-goals - -PPTX export; hosted collab editing; PowerPoint animation parity. - -### Acceptance — met - -- [x] Skill shows in Settings → Skills as Built-in — no code change needed; - `httpapi.skillSource` derives `builtin` from absence in the bundle dir. Asserted - in `web/e2e/live/skills-connections.spec.ts`. -- [x] `/bento-slides` loads instructions — `matchSkillInvocation` resolves any - roster name, so this came for free. -- [x] Agent produces openable `.bento.html` — via the bundled - `scripts/bento_doc.py`; round-trip, escaping and shell byte-identity are - covered by `internal/clientconfig/builtin_skills_bento_test.go`. -- [x] License/attribution settled — Bento is MIT (© 2026 The Bento authors). - Recorded in `templates/NOTICE.md` (pack-local; **no** root - `THIRD_PARTY_NOTICES.md` was added), and the shell carries upstream's own - `NOTICE` comment internally so it travels with every deck. -- [x] Works offline except model provider — the app is vendored and embedded, so - nothing is fetched at turn time, nothing is fetched to render a deck, and a - deck `new` creates makes **no** network request when opened — no update check - and no live collaboration. Multiplayer is off by construction: a CSP - `connect-src 'none'` meta the browser enforces, upstream's own offline switch, - and `set` refusing to write a `collab` block (which is not inert — carrying one - joins a live session on load). The vendored template stays byte-identical, and a - deck the user brought is reported by `validate` rather than rewritten. See - `templates/NOTICE.md` for the layer-by-layer rationale and the Chromium - verification matrix. - -### Deviations from the approach above - -1. **`templates/starter.bento.html` → `templates/Bento_Slides.bento.html`, the - full upstream v1.0.18 release artifact vendored unmodified (689KB, sha256 - pinned).** There is no "minimal legal template": a Bento deck's shell *is* the - application, so anything smaller would not open. -2. **The agent does not edit the HTML with file tools.** It uses a bundled - stdlib-only `scripts/bento_doc.py` (`new`/`get`/`set`/`validate`). The document - block sits at byte 6322 of a minified bundle, so `view_file` would spend ~125KB - of context reaching it; and the block's `<`-escaping rule fails silently rather - than loudly. The helper also makes `collab` private-key redaction and `docId` - preservation mechanical instead of instructions the model must remember. -3. **`ValidateSkills` does not cover this pack** — it reads - `Bundle.BundleSkillsDir`, i.e. the bundle's own skills, not the embedded pack. - The real gate is `TestBuiltinSkillsPackWellFormed` plus the new bento tests. -4. **No eval case.** Evals do not run in CI, need a live model plus podman, and - `evals.Case` has no skill field — the Go tests are the honest gate instead. - -### Scope discovered while shipping - -Bundle skills are **interactive-chat-only**: `internal/scheduledrun` emits no -bundle-skill roster, so scheduled tasks and `fleet task run` cannot discover this -(or any) bundle skill, even though the merged dir is bind-mounted for them. -`docs/SKILLS.md` previously implied taskrun picked the pack up unchanged; that -claim is now corrected there. - ---- +| #987 Browserbase skill | `internal/clientconfig/builtin_skills/browserbase/`, `internal/tools/browserbase_live_view.go`, `docs/BROWSERBASE.md` | +| #985 Bento built-in skill | `internal/clientconfig/builtin_skills/bento-slides/`, `docs/SKILLS.md`, `docs/FEATURE-NOTES.md` | +| #167 three residual decisions | `docs/MCP-BROKER-SCOPES.md`, [ADR-0042](adr/0042-child-side-mcp-scope-authorization.md), [ADR-0040](adr/0040-child-owned-remote-mcp-runtime.md), `SECURITY.md` | + +The #167 entry mattered most: it carried a section headed "OAuth control-plane +tokens parent-readable" whose resolution ("accepted for v1, threat model +documented — parent compromise implies stored remote-MCP tokens; agent runs stay +child-side") was already written down in `SECURITY.md` and +`docs/MCP-BROKER-SCOPES.md`. A second copy phrased as an open decision, in an +unlinked file, read like an unresolved gap. It is not one. + +For the current plan-of-record on anything else, prefer the GitHub issue. ## #984 — Fleet ↔ Buzz bridge @@ -127,29 +67,3 @@ Fleet hosting Buzz relay; full tool UI parity; every Buzz user → fleet user ma - [ ] Bot token not logged; secrets in env only Size: **M** if ACP external command is clean; **L** if deep Buzz harness embed needed. - ---- - -## #167 — Three residual decisions - -Delivered broker work (can't-read) is solid. Explicit decisions: - -### 1. Child-side authorization → **Implement** - -Parent-only gating is insufficient (Gate-2 proof). On `OpenScope`, pass policy snapshot; child enforces allowlists on every CallTool/discovery; restrict unscoped shared client for agent paths; tests for refused disallowed tools. Update `docs/MCP-BROKER-SCOPES.md`. - -### 2. Approval execution seat → **Persist staged scope** - -Preserve `{server, account}` at staging; reopen scope on approve; fail closed if account revoked; show account in UI. Unblocks #988. Tests: stage with B, approve later, assert B used. - -### 3. OAuth control-plane tokens parent-readable → **Accept v1 + document** - -Accept connect/callback/CRUD parent-side for v1; document threat model (parent compromise ⇒ remote MCP tokens). Agent runs stay child-side (ADR-0040). Optional v2: full control-plane behind child as separate issue. - -### Closing criteria — resolved - -| Residual | Resolution | -| --- | --- | -| 1 Child auth | **Implemented.** `cmd/fleet/mcp_broker_authz.go`; bundle-derived Gate-2 floor, `ScopeSpec.Policy` narrowing, child-side Gate-3, filtered scope catalogs, restricted unscoped client. ADR-0042; tests in `cmd/fleet/mcp_broker_authz_test.go`. | -| 2 Approval seat | **Implemented.** Migration 048 (`approvals.mcp_server` / `mcp_account`), `BindTurnMCPScope` at staging, `OpenApprovalMCPScope` at execution, fail-closed on a revoked seat, account badge on the card. Tests in `internal/httpapi/approvals_seat_test.go`, `internal/store/approval_seat_test.go`, `web/.../ApprovalCards.seat.test.tsx`. | -| 3 OAuth parent-readable | **Accepted + documented** (2026-08-14). `SECURITY.md` and `docs/MCP-BROKER-SCOPES.md` state the threat model: parent compromise ⇒ stored remote-MCP tokens. Agent runs stay child-side (ADR-0040). Full control-plane isolation would be a separate change. | From 68da69137fa98619f9413c059eb1e4a637905b8b Mon Sep 17 00:00:00 2001 From: Brad Flaugher Date: Sat, 22 Aug 2026 16:29:06 +0000 Subject: [PATCH 07/10] Give the tool-output redactor the connector secrets it never had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool-output scrubber's literal set was empty of connector credentials in every production build, so its own doc comment ("OPENROUTER_API_KEY, connector credentials, …") described coverage it did not have. The mechanism is an ordering accident, not a missing feature. agentcore's redactor is built lazily behind a sync.Once that fires on the first tool output, and it seeds itself from os.Environ(). But the MCP broker's boot path calls scrubParentConnectorState, which os.Unsetenv's every connector environment key, and that runs during broker startup — before the interactive engine is even constructed, let alone before any turn produces output. So by the time the snapshot is taken those values are gone, and RegisterEnvLiterals registered only what survives the scrub. That divestment is the entire point of the broker boundary and is not touched here. What it left uncovered is the return path: a connector that echoes its OWN credential back in a tool result or error string was scrubbed only if the value happened to match one of internal/redact's shape patterns (sk-*, ghp_*, AKIA*, Authorization:, marker=value). A novel bare token would have reached the model context, the SSE stream and the session log. internal/tools/browserbase_live_view.go is written as though the literal set were richer than it was — it survives only because it independently checks strings.Contains(liveView, apiKey) and refuses. Two wirings close it: - scrubParentConnectorState now hands each value to agentcore.RegisterSecretLiteral immediately BEFORE unsetting it, so the scrubber learns it at the last moment it is knowable. - cmd/fleet/main.go wires the PARENT's remotemcp service to the same hook, mirroring what the credential-owning child already does via mcpbroker.RegisterSecretLiteral (#1124). The parent unseals per-user api_key secrets and mints/refreshes OAuth bearers for its control plane — browserbase_live_view among them — and those are acquired at runtime, so no boot-time env snapshot could ever know them. That SetSecretObserver call was simply absent. RegisterSecretLiteral buffers values offered before the redactor exists and drains them at construction, both under the same mutex that guards the sharedRedactor nil-check — so a value offered concurrently with construction is either buffered and drained or added directly, never dropped between the two. Publication happens under that lock for the same reason; the unlocked read in toolRedactor is safe on sync.Once's happens-before. Verified with `go test -race` on internal/agentcore and internal/redact. Neither change widens what the parent can READ. A registered value is held solely as a redaction target and is never emitted, logged or persisted — and this is a backstop for what comes back across the broker boundary, not a substitute for the boundary. The test uses a token that matches none of the shape patterns, so it passes only if literal registration genuinely reached the redactor, and registers it before anything forces construction — the real boot order. Mutation-tested: neutering RegisterSecretLiteral makes it fail with the secret visible in the output. It also covers post-construction registration (the runtime-acquired case) and asserts that an empty registration does not turn the scrubber into a match-everything. Signed-off-by: Brad Flaugher --- cmd/fleet/main.go | 8 +++ cmd/fleet/mcp_broker_runtime.go | 14 ++++ internal/agentcore/redact.go | 67 ++++++++++++++++++- internal/agentcore/redact_integration_test.go | 44 ++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/cmd/fleet/main.go b/cmd/fleet/main.go index 015f7ae38..94751cf39 100644 --- a/cmd/fleet/main.go +++ b/cmd/fleet/main.go @@ -2575,6 +2575,14 @@ func setupRemoteMCP(cfg *config.Config, chatStore *store.Store) *remotemcp.Servi PublicBaseURL: cfg.PublicBaseURL, AllowInsecureHTTP: cfg.RemoteMCPAllowInsecureHTTP, }) + // Same wiring the credential-owning child does (see mcp_broker.go, #1124), + // for this process's own redactor. The parent's control-plane service + // unseals per-user api_key secrets and mints/refreshes OAuth bearers — + // e.g. for browserbase_live_view — and those are acquired at RUNTIME, so + // the boot-time env snapshot cannot know them. Without this the parent's + // literal set never learned them, and a connector echoing its own bare + // token back was scrubbed only if it happened to match a shape pattern. + svc.SetSecretObserver(agentcore.RegisterSecretLiteral) // Abandoned OAuth flow rows are reclaimed by the maintenance loop (see // runMaintenancePass), not by a daemon of their own. This used to be a // `for range ticker.C` goroutine with a context.Background() per sweep — diff --git a/cmd/fleet/mcp_broker_runtime.go b/cmd/fleet/mcp_broker_runtime.go index 736248e56..6e13dc113 100644 --- a/cmd/fleet/mcp_broker_runtime.go +++ b/cmd/fleet/mcp_broker_runtime.go @@ -465,6 +465,20 @@ func scrubParentConnectorState(bundle *clientconfig.Bundle, cfg *config.Config, keys := bundle.ConnectorEnvironmentKeys(os.Environ()) var errs []error for _, key := range keys { + // Hand the VALUE to the tool-output scrubber before it becomes + // unreachable. agentcore's redactor snapshots os.Environ() lazily, on the + // first tool output, which is long after this loop — so without this the + // literal set held no connector secret at all, and a connector echoing + // its own credential back in a tool result was caught only if the value + // happened to match one of internal/redact's shape patterns. A novel bare + // token would have reached the model context, the SSE stream and the + // session log. + // + // This does not weaken the divestment below or re-expose anything: the + // value is kept solely as a redaction target and is never emitted. It is + // the backstop for what comes BACK across the broker boundary, which the + // boundary itself cannot police. + agentcore.RegisterSecretLiteral(os.Getenv(key)) if err := os.Unsetenv(key); err != nil { errs = append(errs, fmt.Errorf("unset connector environment %s: %w", key, err)) } diff --git a/internal/agentcore/redact.go b/internal/agentcore/redact.go index d7aa7439c..ec713bb30 100644 --- a/internal/agentcore/redact.go +++ b/internal/agentcore/redact.go @@ -10,18 +10,81 @@ import ( // toolRedactor returns the process-wide secret scrubber applied to tool output // (in the tool wrappers + stream sink) and to the persisted session log. Built // once: the canonical pattern set plus literal redaction of secret-named env -// values (OPENROUTER_API_KEY, connector credentials, …) so a novel key format -// is still scrubbed by value. See internal/redact. +// values so a novel key format is still scrubbed by value. See internal/redact. +// +// NOTE ON WHAT THE ENV SNAPSHOT DOES AND DOES NOT COVER. This is lazy — the +// Once fires on the first tool output — and by then the parent has already +// divested its connector credentials: the MCP broker's boot path +// os.Unsetenv's every connector environment key (scrubParentConnectorState) +// long before any turn runs. So os.Environ() here no longer contains connector +// values, and this call alone registers only what survives the scrub, e.g. +// OPENROUTER_API_KEY. +// +// That divestment is the point of the broker boundary and is not being undone. +// But it also meant the literal set was EMPTY of connector secrets, so a +// connector echoing its own credential back in a tool result was caught only if +// the value happened to match one of internal/redact's shape patterns (sk-*, +// ghp_*, AKIA*, …) — a novel bare token would have reached the model context, +// the SSE stream and the session log. RegisterSecretLiteral below is how the +// boot path hands those values over BEFORE unsetting them, so defense-in-depth +// against an upstream echoing a credential does not depend on its format. func toolRedactor() *redact.Redactor { redactorOnce.Do(func() { r := redact.NewRedactor(nil) r.RegisterEnvLiterals(os.Environ()) + // Publish under pendingMu, and drain under the same lock: that is what + // makes RegisterSecretLiteral's nil-check safe against a racing + // construction, so a value offered concurrently is either buffered here + // and drained, or added directly — never dropped between the two. + pendingMu.Lock() + for _, v := range pendingLiterals { + r.AddLiteral(v) + } + pendingLiterals = nil sharedRedactor = r + pendingMu.Unlock() }) + // Safe unlocked: sync.Once establishes happens-before for every caller that + // returns from Do, so the write above is visible here. return sharedRedactor } +// RegisterSecretLiteral adds one secret VALUE to the process-wide tool-output +// scrubber, so it is redacted by exact match regardless of format. +// +// Call this with a value that is about to become unreachable — the boot path +// uses it for each connector credential immediately before os.Unsetenv removes +// it from the environment. Safe before or after the redactor is built: earlier +// calls are buffered and drained at construction, later ones go straight in +// (redact.Redactor.AddLiteral is mutex-guarded). Values shorter than the +// redactor's floor are ignored by AddLiteral, so a short or empty setting cannot +// turn the scrubber into a match-everything. +// +// This never widens what the parent can READ — the value is stored only as a +// scrub target and is never emitted. It is not a substitute for the broker +// boundary; it is the backstop for output that comes back from the other side of +// it. +func RegisterSecretLiteral(value string) { + if value == "" { + return + } + pendingMu.Lock() + if sharedRedactor == nil { + pendingLiterals = append(pendingLiterals, value) + pendingMu.Unlock() + return + } + pendingMu.Unlock() + sharedRedactor.AddLiteral(value) +} + var ( redactorOnce sync.Once sharedRedactor *redact.Redactor + + // pendingMu guards literals registered before the redactor is built. It + // also guards the sharedRedactor nil-check in RegisterSecretLiteral so a + // value cannot be dropped by racing construction. + pendingMu sync.Mutex + pendingLiterals []string ) diff --git a/internal/agentcore/redact_integration_test.go b/internal/agentcore/redact_integration_test.go index bc51299be..82abda177 100644 --- a/internal/agentcore/redact_integration_test.go +++ b/internal/agentcore/redact_integration_test.go @@ -37,3 +37,47 @@ func TestPolicyGuardedTool_RedactsToolOutput(t *testing.T) { t.Errorf("redaction ate surrounding output: %q", resp.Content) } } + +// TestRegisterSecretLiteralScrubsNovelFormat is the regression test for the gap +// that made the parent's literal set empty of connector secrets. +// +// The tool-output redactor snapshots os.Environ() lazily, on first use. The MCP +// broker's boot path unsets every connector environment key long before that, so +// the snapshot never saw those values, and a connector echoing its own +// credential back was scrubbed only if the value happened to match one of +// internal/redact's shape patterns. The token below deliberately matches NONE of +// them — no sk-/ghp_/AKIA prefix, no "key=value" shape — so it is scrubbed only +// if literal registration actually reached the redactor. +// +// Ordering matters and is the thing under test: RegisterSecretLiteral is called +// BEFORE anything forces the redactor into existence, which is the real boot +// order (scrubParentConnectorState runs during broker startup, the first tool +// output comes much later). +func TestRegisterSecretLiteralScrubsNovelFormat(t *testing.T) { + const novel = "Zq7Z2pLmVnT4rWxK9dCbYeHgJ1sAuF6o" + + RegisterSecretLiteral(novel) + + got := RedactSecrets("upstream said: token " + novel + " was rejected") + if strings.Contains(got, novel) { + t.Fatalf("novel-format secret survived redaction: %q", got) + } + if !strings.Contains(got, "upstream said") { + t.Fatalf("redaction ate the surrounding text: %q", got) + } + + // Registering after construction must work too — runtime-acquired + // credentials (OAuth bearers, unsealed api_keys) arrive mid-process via + // Service.SetSecretObserver, i.e. long after the redactor exists. + const later = "Rr8Y3qMnWoU5sXyL0eDcZfIhK2tBvG7p" + RegisterSecretLiteral(later) + if out := RedactSecrets("bearer " + later); strings.Contains(out, later) { + t.Fatalf("post-construction secret survived redaction: %q", out) + } + + // An empty registration must not turn the scrubber into a match-everything. + RegisterSecretLiteral("") + if out := RedactSecrets("ordinary text"); out != "ordinary text" { + t.Fatalf("empty literal corrupted redaction: %q", out) + } +} From 5003949d11944edf4c1737b5e6a9b770a272a70f Mon Sep 17 00:00:00 2001 From: Brad Flaugher Date: Sat, 22 Aug 2026 16:41:27 +0000 Subject: [PATCH 08/10] Correct every false and stale claim the audit found in the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An auditor reads the docs and then checks the code, so a doc that overstates is worse than one that says less. This repo has an explicit "Honesty in docs" invariant; these are the places it had drifted. The load-bearing corrections: - AGENTS.md said "Everything is at zero findings today; keeping it there is the point." False, and it was the one line in the agent-facing operating guide an auditor would hold up. The thresholds now differ by scanner, and the difference is stated: Semgrep, ruff and npm audit gate on any finding; CodeQL gates on the High band minus the reviewed register, with everything below it advisory. Also records the two facts an agent must not get wrong — a pull_request CodeQL run certifies a DIFF and never a tree, and `Dev gate` is not a required check on `dev`. - docs/CODEQL.md carried a whole Triggers section describing a workflow that no longer exists (it showed `push: [main]` and `pull_request: [main, dev]`; the file has only workflow_call, workflow_dispatch and schedule), plus the assertion that a push to dev "would re-analyze identical content". That reasoning is exactly the trap that broke dev — the push run is full-tree and the PR run is diff-informed, so they are not identical content — and it is now preserved as the error rather than the rule. Also removed "a CodeQL job with a hundred open alerts still exits 0 and reports green" (false since the Fail-on-findings step) and the sample log format that never matched what the workflow prints. - docs/SCANNING.md claimed the extended suite "reports zero findings on this tree (verified in CI across all four languages on Dev CI run 525)" and that a green check means "clean tree". Run 525 was a pull_request event. Corrected, with the run-527 numbers, and the known-gaps section rewritten — the dev-ruleset gap is now the FIRST gap, since it is the one that makes several other statements in the file conditional. - SECURITY.md had no SAST section at all, though CodeQL and Semgrep are the controls an enterprise auditor asks about by name. It now has one. Its Grype paragraph claimed the gate covers "the image's RPM or Python packages" at a fixable CRITICAL; the policy script selects `.artifact.type == "rpm"` and fires on CRITICAL *and* HIGH, so Python dist-info records are reported and deliberately do not gate — both halves were wrong in the direction that overstates coverage. Its supply-chain section omitted the npm CVE gate entirely. And "CI runs gitleaks on every push" is not true of a feature branch, which runs nothing. - docs/TESTING.md's lane table said the fast lane SKIPS CodeQL. It runs both scanners. The table also omitted four lanes that now block. - CONTRIBUTING.md contradicted itself inside one sentence: "fails the build on a fixable CRITICAL or HIGH CVE ... (HIGH and below are reported, not blocking)". - CHANGELOG.md's [Unreleased] section carried five overlapping entries from #1246 that contradicted each other — one said the scanners gate through ci-gate, another said "CodeQL and Semgrep stay advisory"; one said `ruff format` is reported but not gated, another said it gates; one said Semgrep ships only p/github-actions, another all four packs; one said the code-quality suite was restored when it was dropped. A reader could not tell which was current. Collapsed into one entry describing the shipped end state, and extended with this PR's work. - ruff.toml's "<- what we gate on" marker pointed at the default-only rule line while `select` includes B, SIM and S. - Makefile's .PHONY omitted lint-python. - README.md's documentation table did not list SCANNING.md or CODEQL.md — the two most audit-relevant docs, unreachable from the README — and its layout tree was abridged without saying so. ADR housekeeping, both mine: - ADR-0036 presents its host-side exception list as exhaustive, and an auditor reads it that way, so it has to be. It still named `fastio_upload` as a host-read exception; there is no such native tool any more (Fast.io is an MCP server behind the broker), so the ADR was claiming a hole that does not exist. Two real classes were missing: host `git worktree` management on the scheduled-run path, and the admin-gated host `podman` build for the rampart install. Neither is model-authored and neither weakens the invariant — but "is this exception in the ADR?" should have a reliable answer, and now does. - ADR-0048's two counts were off and are now measured, not recalled: 625 `_test.go` files (not 621) and 81 `//nolint:gosec // G706` sites (not 77 — four of the increase are this branch's own). Verification: make build, make lint (golangci-lint v2.13.1 + ruff + migration DDL lint) and make test all clean; web oxlint, tsc, vitest (1104 tests) and next build all clean. Every markdown link target in every file touched here was checked to resolve on disk. Two lint findings from my own earlier commits fixed here rather than left standing: four `//nolint:gosec // G706` directives I had added were UNUSED — once the value goes through logSafe, gosec stops flagging the line, so nolintlint was right and an unused suppression is worse than none — and a prealloc nit in the new gate-needs test. Signed-off-by: Brad Flaugher --- AGENTS.md | 49 +- CHANGELOG.md | 504 ++++++++++++------ CONTRIBUTING.md | 45 +- Makefile | 6 +- README.md | 14 +- SECURITY.md | 194 ++++++- docs/BENTO-PDF-EXPORT.md | 8 +- docs/CODEQL.md | 444 ++++++++++----- docs/SCANNING.md | 269 ++++++++-- docs/TESTING.md | 111 +++- ...boxed-file-tools-and-host-io-exceptions.md | 47 +- docs/adr/0048-codeql-severity-gating.md | 5 +- internal/sched/handlers/handlers.go | 2 - internal/sched/handlers/upload.go | 2 - ruff.toml | 16 +- scripts/check_gate_needs_test.go | 5 +- web/next-env.d.ts | 1 + 17 files changed, 1291 insertions(+), 431 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5466eec82..aaa527824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,13 +46,29 @@ gitleaks secret scan. **Every job must be green before merge.** Tests are deterministic without a live model: use the fake-LLM seam (`internal/fakellm` via `OPENROUTER_BASE_URL`), never a real key. -CodeQL (security queries) and Semgrep (Go/JS/Python SAST + Actions supply chain) -also run per PR, **fail on any finding**, and are **inside `ci-gate` and -`Dev gate`** — both are reusable workflows that ci.yml/dev-ci.yml call as jobs, -so a finding blocks the merge through the existing required check. `npm audit` -gates the web and rampart-service dependency trees the same way. Everything is -at zero findings today; keeping it there is the point. See -[`docs/SCANNING.md`](docs/SCANNING.md). +CodeQL (security queries, `security-extended`) and Semgrep (Go/JS/Python SAST + +Actions supply chain) also run per PR and are **inside `ci-gate` and `Dev gate`** +— both are reusable workflows that ci.yml/dev-ci.yml call as jobs. `npm audit` +(both npm trees, lockfile-only, any severity) and ruff (`check` **and** +`format --check`) gate the same way. + +Their thresholds differ, and the difference is load-bearing: + +- **Semgrep, ruff and `npm audit`: zero findings, gating on any finding.** +- **CodeQL: zero *blocking* findings.** It gates on the **High band** + (`security-severity >= 7.0`, or level `error`/`warning` for a rule publishing + no security-severity), minus a reviewed register of accepted `(rule, file)` + pairs in `.github/codeql-accepted-findings.json` — each with a written reason. + Below the band is **advisory**: printed in the job log and uploaded to the + Security tab, not blocking. So "CodeQL is green" means "no unwaived High-band + finding", not "no findings". The reasoning is [ADR-0048](docs/adr/0048-codeql-severity-gating.md). + +Two facts an agent must not get wrong here. **A `pull_request` CodeQL run is +diff-informed** — it evaluates every query over the full database, then reports +only results inside the PR's diff — so it certifies a *diff*, never a tree; only +push and scheduled runs give a tree-wide verdict. And **`Dev gate` is not a +required check on `dev`**, so on that branch a scanner failure is a red X beside a +mergeable PR. See [`docs/SCANNING.md`](docs/SCANNING.md) ("Known gaps"). ## Repository map @@ -124,8 +140,10 @@ same PR. `codecov.yml` were removed because the repo has no `CODECOV_TOKEN`, so the upload only ever produced a missing-token warning. Treat coverage as a quality signal, not a gate: add tests that catch real behavior, not to chase a - number. (The merge gates are build/vet/lint, the test suites, the - `-race` lane, govulncheck, Grype, the migration linter, and gitleaks.) + number. (The merge gates are build/vet/lint, ruff — `check` and + `format --check` — the test suites, the `-race` lane, govulncheck, Grype, + `npm audit` + `scripts/check-npm-overrides.sh`, CodeQL, Semgrep, the migration + linter, and gitleaks.) - **Match the surrounding code:** naming, idioms, and comment density. The `internal/agentcore` package comments explain *why* each governance invariant holds — preserve that level of explanation when you extend it. @@ -170,13 +188,16 @@ same PR. - **Testing strategy** (unit / fake-LLM / mocked + live Playwright / canary): [`docs/TESTING.md`](docs/TESTING.md) - **The scanning stack** (who checks what, why ruff owns Python lint, why - Semgrep is scoped to Actions supply chain after its broad packs scored 0/6, - what blocks vs what reports, and the known gaps): + Semgrep runs all four registry packs — `p/github-actions`, `p/golang`, + `p/javascript`, `p/python` — and blocks with 6 false positives waived at the + line, what blocks vs what only reports, and the known gaps, chief among them + that the `dev` ruleset requires no status checks): [`docs/SCANNING.md`](docs/SCANNING.md) - **CodeQL** (why default setup was replaced by an advanced-setup workflow, how - the Go toolchain is resolved, why it runs security queries only, and the - difference between a required status check and code scanning merge protection): - [`docs/CODEQL.md`](docs/CODEQL.md) + the Go toolchain is resolved, why it runs security queries only, why a + `pull_request` run certifies a diff and not a tree, and the High-band threshold + plus accepted-findings register): [`docs/CODEQL.md`](docs/CODEQL.md) + + [ADR-0048](docs/adr/0048-codeql-severity-gating.md) - **HTTP API versioning** (the `/v1` prefix + `X-Fleet-API-Version` + `/api-info` discovery + deprecation contract): [`docs/api-versioning.md`](docs/api-versioning.md) - **Database migrations** (the two runners, safe-DDL patterns, the migration DDL diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ac4943f..707592105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,233 @@ prior versions are listed because none have shipped. ### Fixed -- **Every remaining scanner follow-up closed: rule families widened and fixed, +- **Three own-rows authorization holes on the task surface.** The read path for + task rows was narrowed to own rows in #1082 and run logs in #980; three + surfaces never got the same treatment and authorized on a *permission* alone, + so any client-role principal reached every principal's rows: + + - `GET /tasks/paused` — `ListPausedTasks` selects on status alone with no + principal predicate in SQL, and the projection carries each task's prompt. + Its siblings (`/tasks/export`, `/tasks/upcoming`) both call `visibleTasks`; + this one did not. It leaked other principals' paused prompts and their task + UUIDs. + - `PUT /tasks/{id}` and `POST /tasks/{id}/tags` — loaded the task with the + unscoped `GetTask` and never checked ownership, so a client-role principal + could rewrite a teammate's pending run: `prompt`, `model`, `mcp_selection` + and `credential_allowlist` included. Only `run_if` was gated (admin-only). + - `POST /tasks/{id}/feedback` and `GET /tasks/{id}/learned-instructions` — + `taskFromPath` is lookup-only by contract ("a handler that needs an + authorization decision makes it on the returned task") and neither caller + made one. A down-vote with an attacker-authored critique fed `maybeDistill`, + which mints a proposal from the victim's prompt at unmetered model spend; + the GET disclosed their learned instructions. + + The write gate is a new `taskWritableByPrincipal`, deliberately **not** + `principal.ownsTask`: `ownsTask` resolves through `ownerID()`, which is nil for + every API-key principal, so it would deny a scoped intake-app key the right to + edit the task it just created. `taskCreatedByPrincipal` matches a creating user + **or** a creating key (`CreatedByKeyID`) — the model #980/#1082 established. A + write surface must be no looser than the read surface guarding the same row. + `TestScopedAPIKeyAuthorization` previously asserted "client key can edit an + editable task" against an unattributed row, which *was* the vulnerable + behaviour; it is split into the owned case (must keep working — the intake-app + path) and the unowned case (must 403). Every fix is mutation-tested: stripped, + the tests fail with the exploit visible. + +- **Secret material and untrusted text reaching error strings, logs and the + persisted transcript**, from the same audit sweep: + + - `internal/config/config.go`: `ValidateScheduled` interpolated the first 6 + bytes of `OPENROUTER_API_KEY` into a validation error — the only place in the + tree where secret material reached an error string. Removed. Its doc comment + also claimed "Called at startup" and has no production caller; corrected. + - `internal/agent/scheduled.go`: run-error strings now pass through + `agentcore.RedactSecrets` before both the log and the **persisted + transcript**. Tool output, the stream sink, hooks and the session log were + already scrubbed; run errors were the one path that skipped it, and the + transcript is the larger surface. + - Log-injection sinks carrying genuinely untrusted text are now `logSafe`/`%q`, + matching each line's already-sanitized sibling: the task-create log + (`task.Prompt`), the pre-validation client attachment path on the reject + branches, the client-echoed attachment `Name`, the upload filename, and the + API-key name. + - `web/e2e/test-auth-key.ts`: the Ed25519 private key was written to a fully + predictable path in the world-writable temp dir at default `0644`. Now + `O_EXCL` at `0600` with random bytes in the sibling name. + +- **Two input-validation gaps with a shell/URL surface.** + `internal/mcpoauth/discovery.go` now refuses a non-`http(s)` scheme on the + remote-derived discovery URLs (a `WWW-Authenticate` `resource_metadata` + pointer, a PRM-declared issuer) *before* the request — already contained by + `SafeHTTPClient` and the transport, so this makes the argument explicit rather + than dependent on transport behaviour. And `internal/sched/models/models.go` + validates `WorktreeConfig.BaseBranch`: it is the trailing positional of + `git worktree add -b ` with no `--` separator, so a + leading-dash value was parsed by git as an option — and `worktree_config` is + settable by any task creator, unlike `run_if`. + +- **Two stale claims that an auditor would have read as capabilities.** + `models.MaxLogSubmissionSize` declared a 24 MB body cap that **nothing + enforced** — the cap actually applied is `MaxJSONBodySize` (1 MB, wired through + `BodySizeLimitMiddleware`), so the real posture was 24× stricter than the + constant claimed. `config.DefaultFromEmail`'s doc comment called it "the + fallback From address for outgoing mail" with no code path consuming it. Both + deleted rather than left standing, along with four other exported-but-unreached + identifiers (`mcpoauth.IsInvalidClient`, `apikeys.Manager.LogAction` — worth + naming because it reads as API-key *audit* surface — and the two halves of the + retired v1 remote-worker protocol, `TaskAssignment`/`LogSubmission`) and the + tree's only commented-out code block. `golangci-lint`'s `unused` already makes + unexported dead code structurally zero, which is why every deletion here is an + exported identifier in `internal/` — the class `unused` deliberately does not + report. Confirmed with `deadcode -test -tags fleet_host_executor ./...`. + +- **CI permission gaps and an alarm that ignored the failure it was built for.** + `scan-cron-alarm.yml` only fired on `conclusion == 'failure'`, which ignores + `startup_failure` — the exact failure its own header describes as the + motivating incident, and the one where *no scanning ran at all* — and + `timed_out`, which matters given codeql.yml caps at 30 minutes and semgrep.yml + at 15. It now alarms on any conclusion that is not `success` or `skipped`, and + the daily real-model canary joins the watched list (it had no alarm at all). + Noted in the file: the watcher matches on workflow **display name**, so + renaming `name:` disarms it. Separately, `ci.yml` carried `pull-requests: read` + at *workflow* level for golangci-lint-action's `only-new-issues`, which is + explicitly `false` — a scope with no consumer that nonetheless reached every + job not overriding it, including `web`, `playwright` and `e2e-live`, which + npm-install and run thousands of third-party packages. +- **The CodeQL gate was armed on a measurement that could not mean what it was + read to mean, and it deadlocked `dev`.** The `Fail on findings` step shipped + with a threshold of *any finding at any severity*, justified by the security + suite reporting zero across all four languages. That zero was real and it was + measured — on **Dev CI run 525, a `pull_request` event**. On PR events the + CodeQL action runs **diff-informed**: it builds the full database, evaluates + every query, and then reports only results whose location falls inside the PR's + diff. Run 525's own log says both halves (`Persisted 204 diff range(s) across + 43 file(s)`; `file coverage information is only enabled when analyzing the + default branch and protected branches`). It measured the **diff**, not the tree. + + The first full-tree evaluation was therefore the **push** that merged that work: + **Dev CI run 527**, which reported **38 Go and 17 javascript-typescript + findings** and turned `Dev gate` red — with no PR-shaped way out, since a PR + into `dev` is scanned diff-informed and comes back green while `dev` itself + stays red. The generalisable rule, now written into the docs: **a PR-event + CodeQL run certifies a diff, not a tree.** + + All 55 were triaged individually against the code. **Four were reachable and are + fixed in code, not waived:** + + - `internal/sched/handlers/handlers.go` logged `task.Prompt` unsanitized on the + task-**create** path while the **update** path's twin line was already wrapped + in `logSafe`. `POST /tasks` is reachable with a scoped `create_task` key, so + this was genuine log forgery. + - `internal/httpapi/attachments.go` logged the raw, pre-validation client + attachment path with `%s` on the two branches where the containment guard had + just *failed* — precisely where the value is hostile by construction. + - `internal/agent/session.go` logged the client-echoed attachment `Name`, which, + unlike `Path`, is never re-sanitized on the `/chat` path. + - `web/e2e/test-auth-key.ts` wrote an Ed25519 private key to a fully predictable + path in the world-writable temp dir at default `0644`. + + **The gate was then redesigned rather than switched off** + ([ADR-0048](docs/adr/0048-codeql-severity-gating.md)). A finding blocks when it + is unwaived and either its rule publishes `security-severity >= 7.0` (CodeQL's + own High/Critical cut) or, for a rule publishing no security-severity, its SARIF + level is `error`/`warning`. Level is deliberately **not** used for rules that do + publish a security-severity: nearly every CodeQL security query is + `@problem.severity error` — `go/log-injection` at 6.1 included — so banding on + level would block on all 23 log-injection findings and reproduce the deadlock. + Below the band is **advisory**: printed and uploaded to the Security tab, not + blocking. + + The remaining 51 findings live in `.github/codeql-accepted-findings.json`, a + register of accepted `(rule, file)` pairs each with a mandatory written reason. + Per-**file** is the point: a `query-filters` exclude would switch a + security-severity 9.1 query (`go/request-forgery`) off repo-wide, while a + register entry leaves it live everywhere else. Severity alone does not separate + the true from the false positives here — that 9.1 fires on the deliberate `@url` + fetch tool behind `internal/netguard`'s resolve-then-dial SSRF guard, and a 7.5 + `go/weak-sensitive-data-hashing` fires on SHA-256 used as a lookup index over a + 32-byte `crypto/rand` token — which is exactly why the band ships *with* a + reviewed register rather than instead of one. An in-source `// codeql[rule-id]` + comment waives too. + + One classifier, `.github/codeql-gate.jq`, is consumed by **both** the summary + and the gate, so the report and the block can never disagree about what + "blocking" means; the job log prints three tiers (BLOCKING / ACCEPTED by name / + ADVISORY). It **fails closed** on a missing register, a missing filter file, + unparseable SARIF, and — a vacuity check — findings present with **zero rule + metadata resolved**. That last one is not hypothetical: CodeQL writes query + metadata to `tool.extensions[].rules[]`, not `tool.driver.rules[]`, and a first + cut of the filter read only the driver, resolved nothing, scored every finding + at severity 0 and reported "0 blocking" over a tree that was not clean. + `scripts/check_codeql_register_test.go` keeps the register honest in `make test`. + +- **Two action pins were the annotated tag object of a mutable major tag, not a + commit.** `git ls-remote` returns the *tag object's* SHA for `refs/tags/v4` on a + repository that publishes annotated tags — for `github/codeql-action` that is + `4c0873ef…`, while the commit (`refs/tags/v4^{}`) is `db488dde…`. A pin taken + from the unpeeled form is a 40-hex string that passes every "is it a SHA" check + and still resolves to a **moving major tag**, which is the exact defect the + pinning exercise existed to remove. Two distinct pins were in that state across + 7 usages; both are repinned to the peeled commits with exact `# vX.Y.Z` + comments, and `scripts/check_action_pins_test.go` now asserts the shape so the + next pin cannot be taken from the wrong ref. Count for the record: **13** + workflow files, 12 of which reference an action, **53** third-party action + references, all SHA-pinned. + +- **`ci.yml`'s docs-only classifier skipped the suite over compiled product + content, and `CI gate` reported green over it.** The classifier matched `*.md` + at any depth plus all of `docs/*`. `*` spans `/` in a shell `case` pattern, so + that swallowed the `go:embed`'d `internal/clientconfig/builtin_skills/*/SKILL.md` + files (asserted by three test files), the shipped + `config/default/system_prompts/{default,chat}.md` — which *are* the system + prompts `docs/PROMPT-CACHE-CONTRACT.md` exists to protect — and + `docs/openapi.yaml`, which `cmd/fleet/openapi_drift_test.go` asserts against the + Go models, plus `docs/scripts/*.py` and `docs/img/*.py`, which are inside the + ruff, Semgrep `p/python` and CodeQL python scopes. A PR touching only a shipped + prompt or the OpenAPI spec therefore skipped the tests that validate it while + the gate went green. Narrowed to an explicit prose allow-list, and **`ci-gate` + now refuses to pass over a `skipped` job unless the classifier actually said + docs-only** — previously a skip from any cause was read as the docs-only case. + +- **Dependabot could rewrite CI on a branch with no required checks.** + `.github/dependabot.yml` targets `dev` for the `github-actions` ecosystem daily + with **no `cooldown`** (Dependabot supports `cooldown` for gomod and npm only), + `auto-merge-dependabot.yml` auto-merged patch bumps, and a `github-actions` + bump *is a rewrite of `.github/workflows/*`*. Since `gh pr merge --auto` only + holds a merge for checks that are **required**, and the `dev` ruleset requires + none, a same-day third-party action patch could land on `dev` with no CI and no + review. Mitigated on the workflow side: the `github_actions` ecosystem is now + **excluded from auto-merge at any bump level**, the workflow carries an explicit + `branches: [main, dev]` filter so its central assumption cannot silently stop + holding, and its write scopes moved to the job. **The remaining fix is a + repo-settings action nobody can perform from a PR** — adding `Dev gate` to the + `dev` ruleset's required status checks — and it is now documented as an open + item in [`docs/SCANNING.md`](docs/SCANNING.md) ("Known gaps") rather than + implied away. + +- **`fleet_ref` is validated against an allow-list shape before checkout** in + `build-sandbox-image.yml` and `publish-sandbox-image.yml`. The `pin` step + refuses an empty value, any character outside `[A-Za-z0-9._/-]` (so a newline + cannot smuggle a second step-output line), an implausible ref name, + `refs/pull/*` / `pull/*`, and a raw commit SHA — fork PR commits are reachable + by SHA from this repository, so only named refs are accepted. Both workflows + execute the checked-out build script and one of them holds `packages: write`. + +- **Documentation corrected against the shipped workflows** for an + enterprise-security review. The scanner docs had accumulated claims that were + true of an intermediate state and false of the merged one: CodeQL "fails on any + finding" and "zero findings across all four languages"; `docs/CODEQL.md`'s + entire Triggers section (it documented `push`/`pull_request` triggers the + workflow does not have, and justified the missing `push` on `dev` with the + reasoning that the PR run analyzes "identical content" — the very trap that + broke `dev`); `docs/TESTING.md` claiming the fast lane *skips* CodeQL when it in + fact runs both scanners; the Grype threshold in three places; "12 workflows"; + and `ruff.toml`'s own gate marker contradicting its `select`. `SECURITY.md` + gained the SAST section it had never had, plus the npm CVE gate it had omitted, + and the `dev`-ruleset gap is now stated wherever a doc claims something + "blocks". + +- **Scanner follow-ups from the same effort: rule families widened and fixed, CodeQL at `security-extended`, grype tightened, override canary, cron alarms, and one reasoned rejection.** @@ -34,33 +260,32 @@ prior versions are listed because none have shipped. intent stated at each. The one `subprocess.Popen` carries a reasoned `# noqa: S603` (argv is `sys.executable` plus internal literals; mutation-tested — stripping the noqa re-fires the rule). Full Go suite - green on the result; the sandbox fileops and bridge behavior is covered by - its tests. - - - **CodeQL widened to the `security-extended` suite** on all four languages, - adopted the same way everything else was: the default suite measured zero, - so the broader set starts from a clean baseline and its findings on this - PR's own run are the measurement. That measurement found exactly one thing - — and it was real: `actions/untrusted-checkout/medium` on - `build-sandbox-image.yml`'s `fleet_ref`-fed checkout. Fixed, not waived - (the `actions` language has no `AlertSuppression.ql`, so a comment waiver - does not even exist): the workflow now **refuses `refs/pull/*` refs before - checkout** — a fork-PR ref would put fork-controlled code into a workflow - that executes the checked-out build script — and the identical hardening - went into `publish-sandbox-image.yml`, the *unflagged* twin that holds - `packages: write` and escaped the name-heuristic query only because its - ref plumbing was named differently. Extended suite then verified clean in - CI on all four languages (Dev CI run 525). The CodeQL/Semgrep log summaries - also now print **`file:line` per finding** (plus a database file count as - the coverage line), and the override canary is invoked via - `$GITHUB_WORKSPACE` so it survives the job's `working-directory: web`. + green on the result. + + - **CodeQL widened to the `security-extended` suite** on all four languages. + Its one `actions`-language finding was real: + `actions/untrusted-checkout/medium` on `build-sandbox-image.yml`'s + `fleet_ref`-fed checkout. Fixed, not waived (the `actions` language has no + `AlertSuppression.ql`, so a comment waiver does not even exist) — see the + `fleet_ref` entry above, and note that the identical hardening went into + `publish-sandbox-image.yml`, the *unflagged* twin that holds `packages: write` + and escaped the name-heuristic query only because its ref plumbing was named + differently. The suite's no-findings result in CI (Dev CI run 525) was a + `pull_request` run and is therefore a statement about that diff, not the tree + — see the first entry above for what the tree actually held. The + CodeQL/Semgrep log summaries also now print **`file:line` per finding** plus a + database file count as the coverage line, and the override canary is invoked + via `$GITHUB_WORKSPACE` so it survives the job's `working-directory: web`. - **Grype gate tightened to fixable CRITICAL + HIGH**, after measuring: the - published sandbox image carries zero fixable Critical/High RPM findings - (its only fixable findings are two Medium openssh advisories, which the - next routine image rebuild picks up). Policy change mutation-tested in - three directions: real scan passes, injected fixable High fails, injected - fixable Medium still passes. + published sandbox image carries zero fixable Critical/High RPM findings (its + only fixable findings are two Medium openssh advisories, which the next + routine image rebuild picks up). The policy stays **RPM-only** + (`.artifact.type == "rpm"`), because the Python `dist-info` Grype catalogs + alongside Fedora's RPMs carries upstream versions and advisories — gating on + it produced pip wheels layered over distro-owned files. Those records still + upload to SARIF. Policy change mutation-tested in three directions: real scan + passes, injected fixable High fails, injected fixable Medium still passes. - **`scripts/check-npm-overrides.sh`**: the rampart sharp/adm-zip overrides are forks of upstream's intent, correct only while upstream is broken — so @@ -85,38 +310,74 @@ prior versions are listed because none have shipped. this public MIT repo would be redistribution. The binary stays pinned; the rules stay registry-fetched with the failure mode documented. -- **The scanners gate through `ci-gate`/`Dev gate` themselves, npm dependencies - are audited, and the whole Python tree is ruff-formatted — with every finding - fixed, none deferred.** - - - **Gate wiring, corrected.** The previous entry said making CodeQL/Semgrep - merge-blocking needed a branch-protection click, reasoning from "`needs` - cannot cross workflow files". Incomplete: `codeql.yml` and `semgrep.yml` are - now **reusable workflows** (`on: workflow_call`) that ci.yml and dev-ci.yml - call as jobs, and those jobs sit in `ci-gate`'s / `Dev gate`'s `needs` — so a - scanner finding blocks a merge through the one existing required check, no - settings change anywhere. Their own push/pull_request triggers are removed - (nothing runs twice); the weekly re-scan crons and a workflow_dispatch stay. - - - **`npm audit` is a new blocking gate** for both npm trees, lockfile-only and - failing on any severity — the npm counterpart of the govulncheck gate. - `web/` was already clean. `scripts/rampart-service` **had no lockfile at - all**, and generating one exposed **5 high-severity vulnerabilities** it had - been hiding: `sharp <0.35.0` (four libvips CVEs) and `adm-zip <0.6.0` - (GHSA-xcpc-8h2w-3j85) via `onnxruntime-node`. No upstream release fixes - either — latest `@huggingface/transformers` still pins `sharp ^0.34.5`, and - npm's suggested "fix" was a breaking transformers downgrade — so the - package now carries `overrides` to `sharp ^0.35.3` and `adm-zip ^0.6.0`, - each the release immediately after the vulnerable line. The overridden - stack was installed and load-tested, not just resolved: sharp renders a PNG - through the new libvips, transformers loads on it, rampart exports its API, - adm-zip round-trips a zip. Both trees now audit at 0. - - - **`ruff format` applied and gated.** 9 of 13 Python files reformatted - (~3.7k lines), `ruff format --check` now blocks in both CI lanes and in - `make lint`. Validated by the full Go suite (the bento/fileops golden tests - exercise the reformatted scripts), byte-compilation of every file, and a - re-scan showing the fileops `nosemgrep` waiver survived the reformat. +- **The scanning stack, as shipped.** This entry supersedes four earlier ones that + described intermediate states of the same work and contradicted each other on + every threshold that matters — whether the scanners gate through `ci-gate` or + stay advisory, whether `ruff format` gates, whether Semgrep ships one pack or + four, and whether CodeQL's code-quality suite was restored or dropped. The + shipped end state, stated once: + + - **Python had no linter at all, and ruff is now a blocking gate.** Go had + golangci-lint and the web tier had oxlint; the tree's 13 Python files — the + sandbox FileOp helper, the python bridge, the bento-slides and data-profiler + skill scripts, MCP test servers, icon/doc generators — had nothing. Rule + selection is narrow on purpose and `ruff.toml` records the numbers: + `E4,E7,E9,F` found 3 real findings (an unused import, a lambda assignment, and + a **byte-identical duplicate `has_guard` definition** in `bento_doc.py` where + the second copy silently shadowed the first); `B`/`SIM`/`S` found 21 more, all + fixed and those families then enabled; a broad selection finds 333, of which + 176 are `%`-format style and 43 are magic values, so the style tiers stay out. + **`ruff format --check` also gates** — the whole tree was ruff-formatted in one + dedicated commit (9 of the 13 files, ~3.7k lines, validated by the full Go + suite, since the bento/fileops golden tests exercise these scripts). `F401` is + waived for `internal/mcp/testdata/*.py` and `cmd/fleet/testdata/*.py`, where an + unused import can be the point of the fixture. + + - **CodeQL narrowed to security queries only — the code-quality suite was + enabled, measured, and dropped.** It produced 32 findings, every one + note-level: for Go and the web tier it duplicated golangci-lint and oxlint, + which already block; 28 of the 32 were Python, now ruff's job at a fraction of + the runtime and with autofix; and 3 were false positives on correct code + (`value != value`, the idiomatic NaN test). CodeQL keeps the thing nothing else + here can do — interprocedural taint, which is the actual shape of "a credential + must not reach a log sink". + + - **Semgrep runs all four packs and blocks** — `p/github-actions`, `p/golang`, + `p/javascript`, `p/python`, with `--error` and no `continue-on-error`. The 6 + non-Actions findings are false positives, suppressed at the line with + `nosemgrep: ` plus a reason: three were *already* triaged and + suppressed for gosec, and one (`0o644` for a sandbox directory) would have been + a security regression if followed. Every suppression was mutation-tested — + removing it makes the finding reappear, so a green scan means the waivers work + rather than the rules having silently stopped matching. `p/github-actions` also + found the one real class nothing else here checks: **51 actions referenced by a + mutable tag**, all now SHA-pinned (see the pin correction above). + + - **`npm audit` is a blocking gate** for both npm trees, lockfile-only and + failing on any severity — the npm counterpart of the govulncheck gate. `web/` + was already clean. `scripts/rampart-service` **had no lockfile at all**, and + generating one exposed **5 high-severity vulnerabilities** it had been hiding: + `sharp <0.35.0` (four libvips CVEs) and `adm-zip <0.6.0` + (GHSA-xcpc-8h2w-3j85) via `onnxruntime-node`. No upstream release fixes either + — latest `@huggingface/transformers` still pins `sharp ^0.34.5`, and npm's + suggested "fix" was a breaking transformers downgrade — so the package carries + `overrides` to `sharp ^0.35.3` and `adm-zip ^0.6.0`, each the release + immediately after the vulnerable line. The overridden stack was installed and + load-tested, not just resolved: sharp renders a PNG through the new libvips, + transformers loads on it, rampart exports its API, adm-zip round-trips a zip. + Both trees now audit at 0. + + - **Gate wiring.** `codeql.yml` and `semgrep.yml` are **reusable workflows** + (`on: workflow_call`) that ci.yml and dev-ci.yml call as jobs, and those jobs + sit in `ci-gate`'s / `Dev gate`'s `needs` — so a scanner finding reaches the + aggregate check with no settings change. Their own push/pull_request triggers + are removed (nothing runs twice); the weekly re-scan crons and a + `workflow_dispatch` stay. An earlier entry here claimed this needed a + branch-protection click, reasoning from "`needs` cannot cross workflow files": + true of a job's `needs`, but a `workflow_call` brings the called jobs into the + caller's file. **Whether that red check blocks a merge is a separate, + branch-dependent fact** — `CI gate` is required on `main`; the `dev` ruleset + requires no status checks at all. - **All three semgrep parse errors fixed**, so no file is partially covered: `${{ steps.build.outcome }}` interpolated into a `run:` script in @@ -124,94 +385,15 @@ prior versions are listed because none have shipped. `${tag:-(…)}` expansion default whose bare paren choked the bash sub-parser (hoisted to a plain assignment), and an inline `import("@playwright/test")` type in fixtures.ts (now a named `import type`; web lint, tsc and all 1104 - vitest tests pass on it). The scanners' coverage lines now read - **0 parse/scan errors** alongside 0 findings. - -- **The scanners now block, and the repo passes them.** Turning a gate on over an - unfixed backlog is how a gate becomes something people route around, so - everything they reported was fixed or adjudicated first. - - - **All 53 action references pinned to commit SHAs.** Semgrep's - `github-actions-mutable-action-tag` found 51 instances of actions referenced - by a mutable tag (`actions/checkout@v7`); if a tag moves, - attacker-controlled code runs with this repo's `GITHUB_TOKEN`. Every `uses:` - across all 12 workflows is now `@<40-hex-sha> # ` — the form - Dependabot updates, and `.github/dependabot.yml` already watches the - `github-actions` ecosystem. Each SHA is the commit the previously-used tag - resolved to at pin time, so the pin does not smuggle in a version bump. - - - **Semgrep blocks over all four packs** (`p/github-actions`, `p/golang`, - `p/javascript`, `p/python`) with `--error` and no `continue-on-error`. The 6 - false positives are suppressed at the line with `nosemgrep: ` plus a - reason — three of them were *already* triaged and suppressed for gosec, and - one (`0o644` for a sandbox directory) would have been a security regression - if followed. Every suppression was mutation-tested: removing it makes the - finding reappear, so a green scan means the waivers work rather than the - rules having silently stopped matching. - - - **CodeQL fails on findings.** Previously the analyze step exited 0 whether it - found nothing or a hundred alerts, so a red check could only ever mean "the - scanner broke" — which is exactly how the Go toolchain break hid for weeks. - Threshold is any finding, safe because the security suite reports zero across - all four languages. - - Both scanners report as their own checks (`CodeQL gate`, `Semgrep scan`) rather - than through `ci-gate`, because a job's `needs` cannot reach across workflow - files. **Making a red check actually block a merge still requires adding those - two checks to the branch ruleset** — a workflow file cannot make itself - required. - - Two knock-on fixes found while doing this: SHA pinning broke two regexes in - `scripts/check_versions_test.go` that matched `golangci-lint-action@v\d+`, and - they fail *open* by skipping — so they were widened to tolerate a pinned ref - plus its trailing version comment, and mutation-tested to confirm they still - bite. And a standalone `nosemgrep` comment inside a Go import block breaks - `goimports`, so that one waiver is a trailing comment instead. - -- **Python had no linter, and two scanners were pointed at ground already - covered.** Reshaped the scanning stack so each tool owns one job - ([`docs/SCANNING.md`](docs/SCANNING.md)): - - - **ruff is new, and it blocks.** fleet ships 13 Python files — the sandbox - FileOp helper, the python bridge, the bento-slides and data-profiler skill - scripts, MCP test servers — and *nothing* linted any of them. Go had - golangci-lint, the web tier had oxlint, Python had neither. Rule selection is - narrow on purpose and `ruff.toml` records why: the default rules find 3 - findings on this tree, a broad selection finds 333, of which 176 are - `%`-format style and 43 are magic values. Three real findings were fixed to - make the gate clean on day one — an unused import, a lambda assignment, and a - **byte-identical duplicate `has_guard` definition** in `bento_doc.py` where - the second copy silently shadowed the first. `ruff format` is reported but - not gated (the tree has never been ruff-formatted). - - - **CodeQL narrowed to security queries only.** Its code-quality suite was - enabled, measured, and dropped: 32 findings, every one note-level, zero - security findings. For Go and the web tier it duplicated golangci-lint and - oxlint, which already block; 28 of the 32 were Python, now ruff's job; and 3 - were false positives on correct code (`value != value`, the idiomatic NaN - test). CodeQL keeps the thing nothing else here can do — interprocedural - taint, which is the actual shape of "a credential must not reach a log sink". - - - **Semgrep is new, scoped, and advisory.** The obvious move — point it at - `p/golang`/`p/javascript`/`p/python` — was measured and rejected: 6 of 6 - non-Actions findings were false positives, three of them *already* triaged - and suppressed for gosec, and one (`0o644` for a sandbox directory) would - have been a security regression if followed. What ships is - `p/github-actions`, which found 51 instances of one real issue nothing else - checks: actions pinned to mutable tags rather than commit SHAs. Advisory - because all 51 are real and repinning is its own PR, not because they are - doubted. - - - **Both scanners now print findings to the job log** and the step summary, and - Semgrep uploads raw JSON as an artifact. A CodeQL run otherwise reports - nothing about what it found to its own log — it writes SARIF, uploads it, and - exits 0 either way — which made outcomes invisible to `gh run view` and to - any agent holding the log but not the code-scanning API. - - Also added: an aggregate `CodeQL gate` job, so making CodeQL blocking later is - one required check rather than four per-language checks needing manual - re-pointing whenever the matrix changes. Nothing here is wired into `ci-gate` - beyond ruff; CodeQL and Semgrep stay advisory. + vitest tests pass on it). The scanners' coverage lines read **0 parse/scan + errors**. + + - Two knock-on fixes found while doing this: SHA pinning broke two regexes in + `scripts/check_versions_test.go` that matched `golangci-lint-action@v\d+`, and + they fail *open* by skipping — so they were widened to tolerate a pinned ref + plus its trailing version comment, and mutation-tested to confirm they still + bite. And a standalone `nosemgrep` comment inside a Go import block breaks + `goimports`, so that one waiver is a trailing comment instead. - **CodeQL had stopped analyzing the repo's Go code, and then stopped analyzing anything.** Default setup's Go analysis failed on every main-targeting PR from @@ -232,26 +414,26 @@ prior versions are listed because none have shipped. at all in the interim. Replaced with an advanced-setup workflow, `.github/workflows/codeql.yml`, which - restores security analysis over go, python, javascript-typescript and actions - plus the code-quality query suite over the first three, and resolves Go's - interpreter from `go.mod` via `actions/setup-go` — never a literal version, the - bug class #1240 and #1241 already fixed twice for node. - - Two things the first cut got wrong, both of which ran **green**: `analysis-kinds` - turns out to be GitHub-internal and unusable in a custom workflow (it logged - `##[error]` and silently continued with security only), and Go extraction - missed exactly one file — `internal/sandbox/host.go`, the unsandboxed host - executor, invisible to the default build behind `//go:build - fleet_host_executor`. Fixed with `queries: code-quality` and - `GOFLAGS: -tags=fleet_host_executor`, the same tag `ci.yml` and `dev-ci.yml` - already pass to `go vet` and `go test`. + restores security analysis over go, python, javascript-typescript and actions, + and resolves Go's interpreter from `go.mod` via `actions/setup-go` — never a + literal version, the bug class #1240 and #1241 already fixed twice for node. + (The code-quality suite was also restored at this point, then measured and + dropped — see "The scanning stack, as shipped" above for where that landed.) + + Two things the first cut got wrong, both of which ran **green**: + `analysis-kinds` turns out to be GitHub-internal and unusable in a custom + workflow (it logged `##[error]` and silently continued with security only), and + Go extraction missed exactly one file — `internal/sandbox/host.go`, the + unsandboxed host executor, invisible to the default build behind + `//go:build fleet_host_executor`. Fixed with `queries: code-quality` (at the + time) and `GOFLAGS: -tags=fleet_host_executor`, the same tag `ci.yml` and + `dev-ci.yml` already pass to `go vet` and `go test`. Verified from the extractor's own output rather than the check mark: `extraction succeeded for all 2 discovered project(s)`, 916 packages, 426 `.go` files including `host.go`, and distinct queries evaluated rising from 72→116 (go), 90→292 (python) and 178→374 (javascript-typescript) as the quality suite - came in, with `actions` unchanged at 36 by design. CodeQL remains advisory — - these jobs are deliberately not wired into `ci-gate`. See + came in, with `actions` unchanged at 36 by design. See [`docs/CODEQL.md`](docs/CODEQL.md). - **`fleet update` built the web tier on the node it had just refused.** Every diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8bb50ef1d..0d16cd114 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,19 +98,50 @@ each runs, and the `make` targets that mirror them locally (`make ci-go`, Every pull request must be green before merge. CI runs: - **Go** — `go build`, `go vet`, `golangci-lint` (full gate — fails on any - finding), and `go test`. -- **Web** — `npm run lint`, vitest, and `npm run build`. + finding), `go test`, and a `-race` lane. +- **Python** — `ruff check` **and** `ruff format --check` over the tree's Python + files (`make lint-python` runs both, and skips loudly if ruff is not installed). +- **Web** — `npm run lint` (oxlint), `npm run typecheck` (`tsc --noEmit`), vitest, + and `npm run build`. - **Playwright** — the mocked suite, plus a live suite against a real backend with a stubbed LLM (no OpenRouter spend). - **Secret scan (gitleaks)** — fails the build on any new, un-ignored secret. -- **Container image scan (Grype)** — fails the build on a fixable CRITICAL or HIGH CVE in - the sandbox image built from `config/default/sandbox/Containerfile` (HIGH and - below are reported, not blocking). Findings upload to GitHub Security → Code - scanning. A separate weekly scheduled scan (non-blocking) catches new CVEs - against the existing image between PRs. (Grype, not Trivy: the image's +- **SAST (CodeQL and Semgrep)** — both are reusable workflows called by `ci.yml` + and `dev-ci.yml`, so they sit inside the aggregate gate. **Semgrep** fails on + any unsuppressed finding across `p/github-actions`, `p/golang`, `p/javascript` + and `p/python`; a false positive is waived at the line with + `nosemgrep: ` plus a reason. **CodeQL** (`security-extended` over go / + python / javascript-typescript / actions) fails on an unwaived finding in the + **High band** — `security-severity >= 7.0`, or level `error`/`warning` for a + rule that publishes no security-severity — with lower-severity findings + reported as advisory. A false positive is waived either by an in-source + `// codeql[rule-id]` comment or by an entry in + `.github/codeql-accepted-findings.json` **with a written reason**; both are + reviewable in the diff, and fixing the code is always preferred. See + [`docs/SCANNING.md`](docs/SCANNING.md), [`docs/CODEQL.md`](docs/CODEQL.md) and + [ADR-0048](docs/adr/0048-codeql-severity-gating.md). +- **Dependency CVEs** — `govulncheck` for the Go module, and + `npm audit --audit-level=low` (lockfile-only, **any** severity) for both + `web/` and `scripts/rampart-service`, alongside + `scripts/check-npm-overrides.sh`, which fails once upstream ships fixes that + make the pinned security `overrides` droppable. +- **Container image scan (Grype)** — fails the build on a fixable **CRITICAL or + HIGH** CVE in an **RPM** of the sandbox image built from + `config/default/sandbox/Containerfile`. MEDIUM and below are reported, not + blocking, and the Python `dist-info` records Grype catalogs alongside the RPMs + never block whatever their severity (they are uploaded to SARIF — the rationale + is in [`docs/TESTING.md`](docs/TESTING.md)). Findings upload to GitHub Security + → Code scanning. A separate weekly scheduled scan (non-blocking) catches new + CVEs against the existing image between PRs. (Grype, not Trivy: the image's `fedora-minimal` base has no Trivy advisory feed, so Trivy would scan none of its packages; Grype matches its RPM + Python packages against NVD/GHSA.) +One qualifier on "must be green", because it differs by branch: `CI gate` is a +**required** status check on `main`, so a red lane genuinely blocks the merge +there. The `dev` ruleset requires no status checks, so on `dev` a red `Dev gate` +is a signal rather than a block — please treat it as one anyway. See +[`docs/SCANNING.md`](docs/SCANNING.md) ("Known gaps"). + If golangci-lint flags something, either fix it or add a `//nolint` with a reason (the `nolintlint` linter requires the reason). The lint backlog is at zero — please keep it there. diff --git a/Makefile b/Makefile index 4a53dfaa5..6686d860f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build compile bins fleet-bench install test test-race test-cover lint lint-go lint-migrations fmt tidy clean help \ +.PHONY: all build compile bins fleet-bench install test test-race test-cover lint lint-go lint-python lint-migrations fmt tidy clean help \ govulncheck ci-go ci-web ci-e2e-mocked ci-local # GOTOOLCHAIN=auto — the operator does NOT have to hand-install the pinned Go. @@ -35,7 +35,9 @@ help: @echo " make test run the Go test suite" @echo " make test-race run the Go test suite with the race detector" @echo " make test-cover run the Go test suite with coverage (writes coverage.out)" - @echo " make lint run golangci-lint + the migration DDL linter" + @echo " make lint run golangci-lint + ruff (check & format) + the migration DDL linter" + @echo " make lint-go golangci-lint only" + @echo " make lint-python ruff check + ruff format --check (skips loudly if ruff is absent)" @echo " make lint-migrations reject dangerous DDL in changed migration files (#256)" @echo " make fleet-bench build the load-testing tool (cmd/fleet-bench, #296)" @echo " make fmt gofmt the tree" diff --git a/README.md b/README.md index 20a112fec..9c66d7bad 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,11 @@ and status codes remain documentary. ## Repository layout +Abridged — the load-bearing directories, not every package. `internal/` alone +holds roughly forty packages; `cmd/` also carries the test/bench helpers +(`fake-llm`, `fleet-bench`), and `scripts/` and `.github/` hold the operator +scripts and the CI definition. + ``` cmd/ fleet/ the one unified binary — server (`fleet serve`: chat HTTP/SSE + orchestrator HTTP + scheduler + worker pool) AND operator CLI (every other verb) @@ -214,6 +219,9 @@ internal/ sched/ orchestrator/scheduler (was moc) + its migrations httpapi/ chat HTTP/SSE/auth layer config/ unified configuration (env loading; the MCP catalog comes from the bundle) + ... (~30 more: agentcore's neighbours, netguard, mcpoauth, observability, ...) +scripts/ bootstrap / update / doctor, the sandbox image build, and the CI policy checks +.github/ workflows (the CI + SAST gates), CODEOWNERS, dependabot, the CodeQL gate filter web/ one Next.js app: /chat and /orchestrator config/default/ the GENERIC client bundle baked into the repo (runs bare), including config/default/sandbox/Containerfile — the sandbox @@ -353,6 +361,8 @@ Deep references live in [`docs/`](docs/) so this README stays an orientation, no | [`docs/SERVER-STATS.md`](docs/SERVER-STATS.md) | Admin Server tab — lightweight CPU, memory, disk, network, and uptime status | | [`docs/BACKUP_RESTORE.md`](docs/BACKUP_RESTORE.md) | Disaster recovery — backup + restore of both databases | | [`docs/WEBHOOK-SIGNING.md`](docs/WEBHOOK-SIGNING.md) · [`docs/TESTING.md`](docs/TESTING.md) | Webhook HMAC signing · the test suite + fake-LLM seam | +| [`docs/SCANNING.md`](docs/SCANNING.md) | The scanning stack — which of golangci-lint / ruff / govulncheck / Grype / gitleaks / npm audit / CodeQL / Semgrep owns what, what actually blocks a merge, and the known gaps | +| [`docs/CODEQL.md`](docs/CODEQL.md) | CodeQL specifics — advanced setup, the four-language matrix, the High-band gate + accepted-findings register, and why a PR-event run certifies a diff rather than a tree | | [`docs/BUILDING-ON-FLEET.md`](docs/BUILDING-ON-FLEET.md) | The HTTP API as an automation substrate — keys, kicking off jobs, consuming structured output | | [`docs/MCP-CATALOG.md`](docs/MCP-CATALOG.md) | The connector catalog — bundled vs third-party trust classes | | [`docs/adr/`](docs/adr/) | Architecture Decision Records — the *why* behind the non-negotiable invariants | @@ -417,7 +427,9 @@ standards. Our thanks to the teams and communities behind them: Python data stack installed as **signed Fedora RPMs** instead of `pip` at runtime — one audited supply chain, not a thousand PyPI tarballs. fleet deliberately tracks the rolling tag so every on-box rebuild picks up the - current patches, and per-PR + weekly Grype scans keep the claim honest. + current patches, and Grype scans keep the claim honest — on every main-targeting + PR that is not docs-only, plus a weekly scheduled re-scan of the existing image + (PRs into `dev` get no image scan; it runs at the dev→main promotion). - **[Model Context Protocol](https://modelcontextprotocol.io)** and its SDKs — the open standard fleet speaks (stdio + HTTP) to reach tools and data through a credential-brokered MCP catalog. diff --git a/SECURITY.md b/SECURITY.md index 14525ae01..a3ee7176a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,17 +39,114 @@ yet. Please reproduce against current `main` before reporting. ## Secret scanning -CI runs [gitleaks](https://github.com/gitleaks/gitleaks) on every push and pull -request and fails the build on any new, un-ignored secret. If you are -contributing, never commit real credentials — the generic `config/default` -bundle ships with no connector secrets, and all deployment secrets live in an -operator-managed `0600` env file outside the repo (see the README). +CI runs [gitleaks](https://github.com/gitleaks/gitleaks) — `gitleaks dir . +--redact --exit-code 1`, over the whole working tree, not just the diff — and +fails the build on any new, un-ignored secret. It is the one lane that is +deliberately **not** gated on the docs-only classifier, because a secret can be +pasted into a markdown file. + +To be precise about coverage, since "on every push" would overstate it: the scan +runs on **pull requests into `dev` and `main`, and on pushes to `dev` and +`main`**. Those are the only events either CI workflow subscribes to, so a push +to a personal feature branch runs no CI at all — including no secret scan — until +a PR is opened against `dev`. Treat pre-PR local hygiene accordingly. + +If you are contributing, never commit real credentials — the generic +`config/default` bundle ships with no connector secrets, and all deployment +secrets live in an operator-managed `0600` env file outside the repo (see the +README). + +## Static analysis (SAST) + +Two static analyzers gate merges, and an auditor will want them by name. The full +design notes are [`docs/SCANNING.md`](docs/SCANNING.md) (who checks what, and what +actually gates) and [`docs/CODEQL.md`](docs/CODEQL.md); the threshold decision is +[ADR-0048](docs/adr/0048-codeql-severity-gating.md). + +**CodeQL — advanced setup, four languages, `security-extended`.** +`.github/workflows/codeql.yml` analyzes `go`, `python`, `javascript-typescript` +and `actions`, each with the `security-extended` query suite (the broader security +set, not the default one). Go builds via `autobuild` with +`GOFLAGS=-tags=fleet_host_executor`, so `internal/sandbox/host.go` — the +unsandboxed host executor, fenced out of the default build — is inside the +database rather than the one file the analysis cannot see. It is a **reusable** +workflow: `ci.yml` (main) and `dev-ci.yml` (dev) call it as a job, so its result +lands in the caller's aggregate gate. There is also a weekly schedule, because a +CodeQL verdict is a function of the query pack as well as the commit. + +Four properties of that gate matter for an audit, and each is a deliberate +limit rather than an oversight: + +- **The threshold is the High band, not "any finding".** A finding blocks when its + rule publishes `security-severity >= 7.0` (CodeQL's own High/Critical cut), or — + for a rule that publishes no security-severity — when its SARIF level is + `error`/`warning`. Level is deliberately not used for rules that *do* publish a + security-severity: nearly every CodeQL security query is + `@problem.severity error`, `go/log-injection` at severity 6.1 included, so + banding on level would block on everything. Findings below the band are + **advisory** — printed in the job log and uploaded to the Security tab, not + blocking. +- **Accepted findings are a reviewed register, not a disabled query.** + `.github/codeql-accepted-findings.json` holds accepted `(rule, file)` pairs, + each with a mandatory written reason that must say why the finding is not + exploitable *there*. It is per-**file** on purpose: a `query-filters` exclude + would switch a security-severity 9.1 query off repo-wide, whereas a register + entry leaves it live everywhere else. An in-source `// codeql[rule-id]` comment + is the second waiver route. Widening the register appears in the PR diff, and + `scripts/check_codeql_register_test.go` fails the test suite on an entry that + names a missing file, lacks a reason, or has become decoupled from the workflow. +- **A `pull_request` run certifies a diff, not a tree.** On PR events the CodeQL + action runs **diff-informed**: it builds the full database and evaluates every + query, then reports only results inside the PR's diff. Tree-wide verdicts come + only from the push and scheduled runs. Any "the scanners are green, therefore + the tree is clean" claim resting on a PR run is unsound — this repo learned that + the expensive way, and ADR-0048 records it. +- **`_test.go` files are outside the Go database.** `autobuild` builds packages, + not tests, so every `_test.go` file in the tree (625 at the time of writing) is + unanalyzed. Unchanged from GitHub's default setup; bringing them in would + require `build-mode: manual`. + +The gate **fails closed**: a missing register, a missing filter file, unparseable +SARIF, or findings present with zero rule metadata resolved all fail the job +rather than reporting clean. One shared classifier (`.github/codeql-gate.jq`) +feeds both the report and the gate, so the two cannot disagree, and the job log +prints three tiers — BLOCKING, ACCEPTED (by name), ADVISORY. Note that +**dismissing an alert in the Security tab does not turn the check green**: the +gate reads the run's own SARIF and never consults the code-scanning API. + +**Semgrep — all four registry packs, blocking on any finding.** +`.github/workflows/semgrep.yml` runs `p/github-actions`, `p/golang`, +`p/javascript` and `p/python` with `--error` and no `continue-on-error`, also as a +reusable workflow inside both gates, also with a weekly schedule. The tree is at +zero unsuppressed findings; six false positives are waived at the line with +`nosemgrep: ` plus a reason, and each waiver was mutation-tested +(removing it makes the finding reappear, so a green scan means the waivers work +rather than the rules having silently stopped matching). One honest limitation: +the rule packs are fetched from the registry at scan time and cannot be vendored — +the Semgrep Rules License v1.0 forbids redistribution, and this is a public MIT +repo — so a registry-side rule addition can turn the lane red with no commit to +blame. The binary version is pinned; the rules are not. + +**All 53 third-party action references across the 13 workflow files are pinned to +40-hex commit SHAs** with the version in a trailing comment, which is also the +form Dependabot updates. Two of those pins were subtly wrong — taken from the +*annotated tag object* of a mutable major tag rather than the commit it points at, +so they looked like commit pins while resolving to a moving tag — and both are now +the peeled commit, with `scripts/check_action_pins_test.go` asserting the shape. + +**Where enforcement actually lands.** All of the above is wired into the branches' +aggregate gate jobs, but a gate job only blocks a merge where it is a *required* +status check. `CI gate` is required on `main`. The `dev` ruleset requires no +status checks at all, so `Dev gate` — and every scanner inside it — is +red-but-not-required on `dev`. That gap, and what it interacts with, is written up +under "Known gaps" in [`docs/SCANNING.md`](docs/SCANNING.md). ## Supply-chain security (dependencies) -Fleet pulls third-party code from two ecosystems — Go modules at the repo root -and npm packages under `web/` — and relies on several deliberate controls to -keep a compromised or fresh-and-unvetted release from reaching `main`: +Fleet pulls third-party code from three ecosystems — Go modules at the repo root, +npm packages under `web/` and under `scripts/rampart-service`, and Fedora RPMs +inside the sandbox image — and relies on several deliberate controls to keep a +compromised or fresh-and-unvetted release from reaching `main`: - **Go module integrity is verified, with the defaults intact.** The repo commits a complete `go.sum`, and the build does **not** set any of `GOFLAGS`, `GOPROXY`, @@ -64,29 +161,72 @@ keep a compromised or fresh-and-unvetted release from reaching `main`: - **Dependency-CVE scanning.** CI runs `govulncheck` against the Go module on every PR (the `govulncheck` job in `.github/workflows/ci.yml`), failing the build on a known-vulnerable dependency that fleet actually calls into. +- **npm dependency-CVE scanning.** `npm audit --audit-level=low` runs in the + `web` job of **both** CI lanes, lockfile-only (before the install, so a + vulnerable lockfile fails fast) and against **both** npm trees — `web/` and + `scripts/rampart-service` — failing the build on **any** severity. Like + govulncheck, its verdict is a function of the clock as well as the commit: a + newly published advisory can redden an unchanged tree, which is the point. + + Two `overrides` in `scripts/rampart-service/package.json` are load-bearing and + worth disclosing: `sharp ^0.35.3` and `adm-zip ^0.6.0`, each the release + immediately after a vulnerable range that **no upstream release yet fixes** + (`@huggingface/transformers` still pins `sharp ^0.34.5`; `adm-zip` arrives + under `onnxruntime-node`). An override is a fork of upstream's intent, correct + only while upstream is broken — so `scripts/check-npm-overrides.sh` runs beside + the audit in both lanes and **fails the build with removal instructions the day + upstream's own ranges reach the patched lines**. A registry flake skips with a + notice rather than delivering a verdict; the audit above is the CVE gate. - **Container-image CVE scanning.** CI also scans the rootless-Podman sandbox - image (built from `config/default/sandbox/Containerfile`) with Grype on every - non-docs PR (the `grype-scan` job), failing the build on a *fixable* CRITICAL - CVE in the image's RPM or Python packages — a surface `govulncheck` (Go modules - only) cannot see. Findings upload to GitHub Security → Code scanning, and a - weekly scheduled scan (`.github/workflows/grype-scheduled.yml`) catches - newly-disclosed CVEs against the existing image between PRs. -- **Release cooldown.** `.github/dependabot.yml` applies a `cooldown` to the gomod - and npm surfaces so Dependabot waits a few days (3 for patch, 7 for minor, 14 - for major) before proposing a freshly published release. This blunts fast - typosquat / account-takeover attacks, where a malicious version is published and - then yanked once the ecosystem flags it. It matters most for **patch** bumps, - which `.github/workflows/auto-merge-dependabot.yml` auto-merges once the full CI - gate is green: without a cooldown a minutes-old patch could be proposed and - auto-merged before any scrutiny. Cooldown applies to version updates only — - Dependabot **security** updates are never delayed, so urgent CVE fixes still - flow immediately. + image (built from `config/default/sandbox/Containerfile`) with Grype in the + `grype-scan` job, a surface `govulncheck` (Go modules only) cannot see. + `scripts/check-grype-policy.sh` fails the build on a *fixable* **CRITICAL or + HIGH** CVE — and only in the image's **RPM** packages. Be precise about that + restriction, because it is a real, deliberate limit: Grype also catalogs the + Python `dist-info` that Fedora RPMs ship as independent PyPI artifacts, and + those records use upstream versions and advisories, so one can claim a fix + exists when Fedora has already backported it or has not published an RPM + update. Those language records are **uploaded to SARIF but do not gate**; + treating them as a merge gate previously produced hand-maintained pip + replacements layered over a coherent distro package set. MEDIUM and below are + reported, not blocking. Findings upload to GitHub Security → Code scanning + (category `grype-sandbox-image`), and a weekly scheduled scan + (`.github/workflows/grype-scheduled.yml`) catches newly-disclosed CVEs against + the existing image between PRs. + + Scope note: `grype-scan` lives only in `ci.yml`, so it runs on main-targeting, + non-docs-only events. PRs into `dev` get no image scan; the image is scanned at + the dev→main promotion and weekly. +- **Release cooldown — on the two ecosystems that support it.** + `.github/dependabot.yml` applies a `cooldown` to the gomod and npm surfaces so + Dependabot waits a few days (3 for patch, 7 for minor, 14 for major) before + proposing a freshly published release. This blunts fast typosquat / + account-takeover attacks, where a malicious version is published and then yanked + once the ecosystem flags it. It matters most for **patch** bumps, which + `.github/workflows/auto-merge-dependabot.yml` auto-merges once the CI gate is + green: without a cooldown a minutes-old patch could be proposed and auto-merged + before any scrutiny. Cooldown applies to version updates only — Dependabot + **security** updates are never delayed, so urgent CVE fixes still flow + immediately. + + **The `github-actions` ecosystem is the exception, and it is the one where a + cooldown would matter most.** Dependabot supports `cooldown` for gomod and npm + only, so the one ecosystem whose "dependency" is *the CI definition itself* — + a `github-actions` bump rewrites `.github/workflows/*` and therefore changes + what CI executes — cannot be made to wait, and it is configured daily against + `dev`. Because `dev` additionally has no required status checks (see "Static + analysis" above), that combination is not something to auto-merge, so + `auto-merge-dependabot.yml` **excludes `github_actions` at any bump level** and + those PRs take a human. The workflow also carries an explicit + `branches: [main, dev]` filter, so it can never silently begin applying to some + other branch, and declares its write scopes on the job rather than the workflow. The cooldown reduces the window for a fast attack but is **not** a guarantee: a patient attacker who waits out the cooldown, or a compromise the ecosystem -never flags, would still slip through. The committed `go.sum` + checksum DB and -`govulncheck` are the stronger, always-on controls; the cooldown is -defense-in-depth on top of the auto-merge path. +never flags, would still slip through. The committed `go.sum` + checksum DB, +`govulncheck` and `npm audit` are the stronger, always-on controls; the cooldown +is defense-in-depth on top of the auto-merge path, and it does not cover +`github-actions` at all. ## CSRF protection (cookie-authenticated routes) diff --git a/docs/BENTO-PDF-EXPORT.md b/docs/BENTO-PDF-EXPORT.md index 03349e48a..f44db03c3 100644 --- a/docs/BENTO-PDF-EXPORT.md +++ b/docs/BENTO-PDF-EXPORT.md @@ -34,9 +34,11 @@ rejected: - **~400MB** added to an image built on `fedora-minimal`. - **The Grype gate.** `scripts/check-grype-policy.sh` fails CI on any fixable - CRITICAL Fedora RPM in the sandbox image. Chromium is the most CVE-heavy RPM in - any distro, so this would become a recurring gate that blocks every merge in - the repository, not just Bento work. + CRITICAL **or HIGH** Fedora RPM in the sandbox image. Chromium is the most + CVE-heavy RPM in any distro, so this would become a recurring gate that blocks + every merge in the repository, not just Bento work — and at the HIGH threshold + the argument is stronger than it was when this was written against CRITICAL + alone. - **A driver.** `--print-to-pdf` prints the page, not the app's print DOM, so driving the real export needs CDP. There is no Node in the sandbox, so that means a hand-rolled WebSocket/CDP client — more moving parts than the renderer diff --git a/docs/CODEQL.md b/docs/CODEQL.md index 8c7cc8b11..a085d0667 100644 --- a/docs/CODEQL.md +++ b/docs/CODEQL.md @@ -87,9 +87,12 @@ enough", not "unset the pin". Neither `env: GOTOOLCHAIN: auto` nor **Security queries only — at the `security-extended` tier.** The code-quality suite was enabled, measured, and deliberately removed (see "Why code quality -was dropped" below); the *security* side was then widened from the default -suite to `security-extended` once the default measured clean, so the broader -set also started from a zero baseline. +was dropped" below); the *security* side was then widened from the default suite +to `security-extended`. The widening was done on the belief that the default +suite had "measured clean" and that the broader set therefore also started from a +zero baseline. **That belief was an artifact of measuring on a PR run** — see +"The threshold, and the measurement that was misread" below. The suite choice +still stands; the baseline claim did not. Adopting the extended suite was a measurement, and it produced exactly **one finding across all four languages**: `actions/untrusted-checkout/medium` on the @@ -108,10 +111,12 @@ that is strictly more dangerous (it holds `packages: write`) but escaped the query because its ref plumbing was named differently. A heuristic query's silence is not evidence of safety; the flagged file just pointed at the class. -The extended suite then verified **clean in CI on all four languages** — Dev CI -run 525 (`32580031374`), the same run that exercises the hardened `actions` -lane — so the fail-on-findings gate holds at the extended tier, not just the -default one. +The extended suite then reported **no findings in CI on all four languages** — +Dev CI run 525 (`32580031374`), the same run that exercises the hardened +`actions` lane. Read that sentence narrowly: run 525 was a `pull_request` event, +so what it establishes is that the extended suite found nothing **inside that +PR's diff**. The tree-wide numbers came later and were not zero. The full account +is in "The threshold, and the measurement that was misread". `build-mode: none` is [not supported for Go](https://docs.github.com/en/code-security/reference/code-scanning/codeql/build-options-for-compiled-languages) @@ -140,27 +145,44 @@ $ go test -count=1 -run TestWorkflowsDeclareVersionsByFile ./scripts ### Triggers +`codeql.yml` has **no `push` and no `pull_request` trigger of its own.** It is a +reusable workflow: + ```yaml -push: branches: [main] -pull_request: branches: [main, dev] -schedule: - cron: '0 10 * * 1' +on: + workflow_call: # ci.yml (main) and dev-ci.yml (dev) each call it as a job + workflow_dispatch: # manual re-run + schedule: + - cron: '0 10 * * 1' # Monday 10:00 UTC ``` -`push` on `main` mirrors `ci.yml` and produces the alert set of record for the -default branch. `pull_request` on `main` matches what default setup covered. - -`dev` on `pull_request` is the **one place this covers more than default setup -did**, and the expansion is deliberate. Every change lands on `dev` first; `main` -only ever receives a promote merge. Scanning `main` alone means a finding -surfaces for the first time on a promote commit — the same complaint -`dev-ci.yml`'s own header already makes about compilation ("a branch whose job is -to integrate should not be where compilation is first attempted"). It is also -what made this change provable before merge: with main-only triggers, the first -real run of a workflow written to fix a silent-failure bug would have happened -*after* it merged. - -There is no `push` trigger on `dev`: a push to `dev` is the merge of a PR that -was just scanned, so it would re-analyze identical content. +Per-change runs therefore arrive through `workflow_call`: `ci.yml` fires on +push/PR against `main`, `dev-ci.yml` on push/PR against `dev`, and each calls +this workflow as a job. Every branch event is covered exactly once, and — because +a called workflow's jobs land in the *caller's* graph — the result feeds +`CI gate` / `Dev gate` directly. Scanning `dev` PRs at all is the one place this +exceeds old default setup, which never ran on them. + +**An earlier revision of this section was wrong in a way worth preserving, +because the error is instructive.** It described a `push: [main]` / +`pull_request: [main, dev]` trigger set and justified omitting `push` on `dev` +like this: *"a push to `dev` is the merge of a PR that was just scanned, so it +would re-analyze identical content."* + +Both halves are false, and the second is the exact trap that later broke `dev`. +The PR run and the push run **do not analyze identical content**: on a +`pull_request` event the CodeQL action runs **diff-informed** — it builds the +full database and evaluates every query, then reports only results whose location +falls inside the PR's diff. A push run has no diff to scope to and reports the +whole tree. So the two events differ in the most consequential way an analysis +can differ: one certifies a diff, the other certifies a tree. Treating them as +interchangeable is how an any-finding gate got armed on a zero that had never +seen the tree. See [ADR-0048](adr/0048-codeql-severity-gating.md), and "The +threshold, and the measurement that was misread" below. + +The current shape has no such hole: `dev-ci.yml` calls this workflow on **pushes +to `dev` as well as PRs into it**, so `dev` gets a full-tree verdict on every +merge, and any direct push that bypassed a PR is covered too. The weekly cron exists because a CodeQL verdict is a function of the query pack as well as the commit — new queries ship continuously, and without a schedule the @@ -253,6 +275,13 @@ until you look at what they found. **What it found: 32 findings, every one note-level, and zero security findings.** +(The "zero security findings" half of that sentence was measured on `pull_request` +runs and is therefore a statement about those diffs, not about the tree — see +"The threshold, and the measurement that was misread". It does not change the +drop decision, which rests on the 32 quality findings and where they were: those +were *reported* results, and the argument against them is that other blocking +tools already cover the same ground.) + | language | findings | what they were | | --- | --- | --- | | `go` | 2 | `go/useless-assignment-to-field` | @@ -351,26 +380,31 @@ with go1.25.1 and cannot lint the tree. ## What was NOT verified, and what is deliberately out of scope -- **No push-on-`main` or scheduled run has executed.** Both triggers are - unexercised until this merges and is promoted. They are ordinary trigger - syntax, and the `pull_request` path shares every step with them, but the cron - expression itself has not fired. It is a weekly cron, so its first real proof - is up to a week after promotion. -- **`_test.go` files are not analyzed.** 621 test files are outside the - database, because `autobuild` builds packages, not tests. Default setup did - not analyze them either, so this is not a regression — it is an unchanged - limit, stated because "CodeQL covers the Go code" would otherwise overclaim. - Bringing tests in would need `build-mode: manual`. +- **No push-on-`main` or scheduled run had executed when this section was first + written.** That is no longer true of the push path: `dev-ci.yml` calls this + workflow on pushes to `dev`, and the first such run (Dev CI run 527) is the + full-tree measurement described below — it is also what proved the section + above wrong. The **weekly cron** is still the one trigger whose own first proof + is up to a week away; the `schedule` path shares every step with the others, so + what is unexercised is the cron expression, not the analysis. +- **`_test.go` files are not analyzed.** Every `_test.go` file is outside the + database (625 in this tree; the count moves with the suite), because + `autobuild` builds packages, not tests. Default setup did not analyze them + either, so this is not a regression — it is an unchanged limit, stated because + "CodeQL covers the Go code" would otherwise overclaim. Bringing tests in would + need `build-mode: manual`. - **The lines-of-code metric value was not read.** `Summary/LinesOfCode.ql` evaluates, but CodeQL does not print the number to the job log; it lands in a `.bqrs`. File and package counts are what was actually observed, so they are what is reported here. No line count is claimed. -- **Alert counts on `main` are not claimed.** The zero-security-findings result - above was measured on a PR run of this branch. PR-run file-coverage detail is - suppressed by CodeQL ("To speed up pull request analysis, file coverage +- **Alert counts on `main` are still not claimed, and the reason turned out to + matter far more than expected.** The no-findings result above was measured on a + **PR run**, where CodeQL is diff-informed and additionally suppresses + file-coverage detail ("To speed up pull request analysis, file coverage information is only enabled when analyzing the default branch and protected - branches"), so the default-branch alert set is not established until this - merges and a promote lands on `main`. + branches"). So it never established a tree-wide baseline for `dev`, let alone + for `main`. The `dev` tree-wide numbers now exist (run 527, below); the + default-branch alert set is established when a promote lands on `main`. - **`build-mode: manual` was not built.** `autobuild` works, so the more complex option was not needed. If `autobuild` regresses, manual mode plus the repo's own `go build ./...` is the fallback — and it is also the route to @@ -380,90 +414,214 @@ with go1.25.1 and cannot lint the tree. own SARIF independently of CodeQL configuration; breaking them would silently drop CVE findings from the Security tab. +## The threshold, and the measurement that was misread + +This is the most load-bearing correction in this document, and it generalises +beyond CodeQL. The decision is recorded as +[ADR-0048](adr/0048-codeql-severity-gating.md); what follows is the short form. + +The `Fail on findings` step originally failed the job on **any finding at any +severity**, with this justification written into the workflow: + +> Threshold is ANY finding, deliberately. The security suite currently reports +> ZERO across go/python/javascript-typescript/actions, so there is no backlog to +> grandfather and no severity line to argue about — a finding here is new. + +That zero was real, and it was measured — **on a `pull_request` event** (Dev CI +run 525). On `pull_request` events the CodeQL action runs **diff-informed**: it +builds the full database and evaluates every query, then reports only results +whose location falls inside the PR's diff. Run 525's own log says both halves: + +``` +Computing PR diff ranges... +Persisted 204 diff range(s) across 43 file(s). +codeql database run-queries ... --extension-packs=codeql-action/pr-diff-range +``` +``` +To speed up pull request analysis, file coverage information is only enabled +when analyzing the default branch and protected branches. +``` + +The database held every file and the queries that later fired did run — +`LogInjection.ql`, `TaintedPath.ql`, `RequestForgery.ql` and +`WeakSensitiveDataHashing.ql` are all listed as "Interpreted" in that run. The +SARIF was empty because the results were filtered to the PR's 43 changed files. + +So the first full-tree evaluation of `security-extended` against this repository +was the **push** that merged that work: **Dev CI run 527**, which reported **38 Go +and 17 javascript-typescript findings** and turned `Dev gate` red. The gate then +blocked every subsequent push to `dev` — including a push that would have fixed +it — with no PR-shaped way out, because a PR into `dev` is scanned diff-informed +and comes back green while `dev` itself stays red. + +**The rule to carry away: a PR-event CodeQL run certifies a diff, not a tree.** +Any claim of the form "the scanners are green, therefore the tree is clean" that +rests on a `pull_request` run is unsound, and that is a permanent property of +diff-informed analysis rather than a bug awaiting a fix. Tree-wide verdicts come +from push and scheduled runs. + +### What the threshold is now + +A finding **blocks** when it is not waived and either: + +- its rule publishes `security-severity >= 7.0` — CodeQL's own High/Critical cut, + and what GitHub's code-scanning merge protection bands on; or +- its rule publishes **no** security-severity at all, in which case the fallback + is the SARIF level `error` or `warning`. + +Level is deliberately **not** consulted for a rule that does publish a +security-severity. Nearly every CodeQL security query is +`@problem.severity error` — `go/log-injection` is `error` at security-severity +**6.1** — so banding on level would put all 23 log-injection findings in the +blocking tier and reproduce the deadlock. For orientation, the severities that +actually appear on this tree: + +| rule | security-severity | tier | +| --- | --- | --- | +| `go/request-forgery` | 9.1 | High band (waived per-file) | +| `go/clear-text-logging` | 7.5 | High band (waived per-file) | +| `go/path-injection` | 7.5 | High band (waived per-file) | +| `go/weak-sensitive-data-hashing` | 7.5 | High band (waived per-file) | +| `js/remote-property-injection` | 7.5 | High band (waived per-file) | +| `js/insecure-temporary-file` | 7.0 | High band (fixed, then waived) | +| `go/log-injection` | 6.1 | advisory | +| `js/client-side-request-forgery` | 5.0 | advisory | + +Everything below the band is **advisory**: printed in the job log and the step +summary, uploaded to the Security tab, not blocking. + +Note what that table shows: **severity alone does not separate the true positives +from the false ones.** The 9.1 `go/request-forgery` fires on `web_fetch.go`, the +deliberate `@url` fetch tool, which dials through `internal/netguard`'s +resolve-then-dial SSRF guard. The 7.5 `go/weak-sensitive-data-hashing` fires on +SHA-256 used as a lookup index over a 32-byte `crypto/rand` bearer token — the +recommended construction. A pure severity line would block both. That is why the +band comes with a register rather than instead of one. + +### The register, and why it is per-file + +`.github/codeql-accepted-findings.json` lists accepted `(rule, file)` pairs, each +with a **mandatory written reason** that must say why the finding cannot be +exploited *there* — not that the rule is noisy. + +Per-**file** is the whole point of preferring it to a `query-filters` exclude. An +`exclude: {id: go/request-forgery}` switches a security-severity 9.1 query off +for the entire repository; a register entry waives it in +`internal/tools/web_fetch.go` and `internal/mcpoauth/discovery.go` and leaves the +query live everywhere else, including elsewhere in those same packages. An +in-source `// codeql[rule-id]` comment is the second waiver route — CodeQL emits +it as a `suppressions` array on the result; the comment must sit on its own line +and covers the line immediately below it. + +Of the 55 findings run 527 surfaced, **four were reachable and were fixed in +code**: an unsanitized `task.Prompt` in the task-create log (its update-path twin +was already wrapped in `logSafe`), the raw pre-validation client attachment path +logged on the two branches where containment had just failed, the client-echoed +attachment `Name` on the `/chat` path, and an Ed25519 private key written to a +predictable world-writable temp path at `0644` in `web/e2e/test-auth-key.ts`. The +other 51 are in the register. + +### One classifier, three tiers, and it fails closed + +`.github/codeql-gate.jq` does the banding and the waiver lookup, and **both** the +summary step and the gate step run it through `jq -f`. Two copies of a SARIF +filter is two copies that can disagree about what "blocking" means, and the +report disagreeing with the gate is worse than either being wrong alone. + +The job log prints three tiers from that single classification — **BLOCKING**, +**ACCEPTED** (by name, because a waiver that is invisible in CI output is a waiver +nobody re-reads) and **ADVISORY**. + +It fails the job rather than reporting clean when: the register is missing, the +filter file is missing, the SARIF will not parse, or — the subtle one — findings +exist but **zero rule metadata resolved**. That last is a vacuity check with real +provenance: CodeQL writes query metadata into `tool.extensions[].rules[]`, not +`tool.driver.rules[]`. A first cut of the filter read only the driver, resolved +nothing, scored every finding at security-severity 0 — `go/request-forgery`'s 9.1 +included — and reported "0 blocking" over a tree that was not clean. That is the +green-but-vacuous outcome this entire workflow exists to rule out, so it is now +an explicit failure mode rather than a silent one. + +Three anti-rot controls sit on top: `scripts/check_codeql_register_test.go` (in +`make test`) requires every entry to name a file that exists, carry a substantive +reason, use a plausible rule id, and be unique — and asserts that `codeql.yml` +still references the register at all; the weekly scheduled scan surfaces entries +that no longer match any finding, so a stale waiver gets removed rather than +quietly widening coverage loss; and widening the register shows up in a PR diff +where the reviewer is expected to check the reason against the code. + ## Two different things can gate, and they are not the same lever -This distinction is the one most worth internalizing, because a status check on -the CodeQL job does **not** gate on findings: +This distinction is still worth internalizing, because the two mechanisms answer +different questions: | you want to block a merge when… | the mechanism | where it lives | | --- | --- | --- | -| the analysis **failed or did not run** | a required status check on `CodeQL gate` | branch protection / ruleset | -| CodeQL **found alerts** at/above a severity | **code scanning merge protection** | ruleset → "Code scanning" rule | - -The second is the one people mean by "gate on CodeQL", and the first does not -give it to you. **A CodeQL job with a hundred open alerts still exits 0 and -reports green** — the job's success only says extraction and query evaluation -worked. That is exactly why the toolchain break was able to hide for weeks behind -a red-but-not-required check, and equally why a green check is not evidence of a -clean codebase. - -fleet is a **public** repository, so code scanning merge protection is available -at no cost (on private repos it requires GitHub Advanced Security). To turn it -on: Settings → Rules → the "Main" ruleset → add the **Code scanning** rule → -add tool **CodeQL** → set the alert thresholds. Two independent knobs there: -*Security alerts* (the CWE/security queries — the only ones this workflow runs) -and *Alerts* (everything else, which would be where a code-quality suite landed -if one were enabled; it is not). Since the security suite currently reports zero -findings on this tree, a **High or higher** security threshold can go on without -inheriting a backlog. - -## Merge gating today — unchanged, with the lever put within reach - -**A finding now turns the check red.** A `Fail on findings` step fails the job on -any finding, at a threshold of *any* — safe to set because the security suite -currently reports zero on this tree, so there is no backlog to grandfather. -Without that step the analyze step exits 0 whether it found nothing or a hundred -alerts, so a red check could only ever mean "the scanner broke" — which is -exactly how the toolchain break hid for weeks. - -**A red check now blocks the merge too**, and through the *existing* required -check rather than a new one: `codeql.yml` is a reusable workflow -(`on: workflow_call`) that `ci.yml` and `dev-ci.yml` call as a job, and that -calling job sits in `ci-gate`'s / `Dev gate`'s `needs`. A correction worth -keeping: an earlier revision claimed this half needed a repo-settings click, -reasoning from "`needs` cannot cross workflow files" — true, but a -`workflow_call` brings the jobs into the caller's file, which is the standard -mechanism and what ships. - -It *cannot* be folded into `ci-gate`: a job's `needs` cannot reach across -workflow files. So `codeql.yml` carries its own aggregate **`CodeQL gate`** job, -mirroring `ci.yml`'s `CI gate` and `dev-ci.yml`'s `Dev gate`. That job is the one -deliberate piece of forward work here, and it is worth being clear that it -changes nothing on its own: - -- It does **not** make CodeQL required. Requiring a check is a repo-settings - action, deliberately not expressible from a workflow file. -- What it buys is that **flipping the switch later is one check, not four.** - Naming `Analyze (go)`, `Analyze (python)`, `Analyze (javascript-typescript)` - and `Analyze (actions)` individually in branch protection would mean - re-pointing branch protection by hand every time the matrix gains or loses a - language — and the failure mode of getting that wrong is the dangerous - direction: a required check that never reports again blocks every PR, or a - removed one silently stops gating. One aggregate check has neither problem. - -No ruleset action is required for any of this: the gate wiring above is entirely -in the workflow files. (`CodeQL gate` still exists as the aggregate job — the -weekly scheduled run's single verdict — and could additionally be named in the -ruleset as belt-and-braces, but nothing depends on that.) - -**On sequencing:** Not out of -caution for its own sake — because of this specific incident. The analysis spent -weeks red for a toolchain reason unrelated to any diff, and a required check in -that state blocks *every* merge, including the promote PR that would carry the -fix. Requiring it also means a `dev`-PR CodeQL failure blocks `dev`, a heavier -posture than that lane's stated "does it compile, lint, and pass tests" job. The -sequence with the least chance of self-inflicted deadlock is: merge this, watch a -few promotions go green, then add `CodeQL gate` to the ruleset. - -What makes that sequencing *safer than it was*: with code quality dropped, the -security suite is all that runs, and it currently reports **zero findings** on -this tree (see "Why code quality was dropped"). So there is no pre-existing -backlog for a required gate to trip over — which is the usual reason turning one -on hurts. The remaining risk is the one this whole document is about: a toolchain -or extractor regression going red for reasons unrelated to any diff. - -No repo-settings or API change to code-scanning configuration was attempted as -part of this change. +| the analysis **failed, did not run, or found something in the blocking band** | the `Fail on findings` step, reaching a required aggregate check | this repo's workflow files | +| CodeQL **found alerts** at/above a severity, judged from the Security tab's alert set | **code scanning merge protection** | ruleset → "Code scanning" rule | + +The first row is what ships, and the wording has been corrected: an earlier +revision of this document said **"a CodeQL job with a hundred open alerts still +exits 0 and reports green"**, and that is no longer true. It was true of the +`analyze` step alone, and it is precisely why the `Fail on findings` step exists. +The step reads the run's **own SARIF** and never consults the code-scanning API, +which has one consequence worth stating plainly: **dismissing an alert in the +Security tab does not turn this check green.** The honest routes are a code +change, an in-source `// codeql[rule-id]` comment, or a register entry with a +reason. + +The second row remains available and nothing depends on it. fleet is a **public** +repository, so code scanning merge protection is free (on private repos it needs +GitHub Advanced Security): Settings → Rules → the "Main" ruleset → add the **Code +scanning** rule → add tool **CodeQL** → set the thresholds. Two independent knobs +there: *Security alerts* (the CWE/security queries — the only ones this workflow +runs) and *Alerts* (everything else, which is where a code-quality suite would +land if one were enabled; it is not). Note that a **High or higher** threshold +there would *not* start from a clean slate: the tree carries High-band findings +that are accepted in the register, and merge protection has no view of that +register. It bands on the Security tab's alert set, so those alerts would need +dismissing individually in the UI. + +## Merge gating today + +**A finding in the blocking band turns the check red.** The `Fail on findings` +step is what makes that true; the threshold is the High band described above, not +"any finding". Without the step, `analyze` exits 0 whether it found nothing or a +hundred alerts, so a red check could only ever mean "the scanner broke" — which +is exactly how the toolchain break hid for weeks. + +**A red check reaches the aggregate gate**, through the *existing* check rather +than a new one: `codeql.yml` is a reusable workflow (`on: workflow_call`) that +`ci.yml` and `dev-ci.yml` call as a job, and that calling job sits in +`ci-gate`'s / `Dev gate`'s `needs`. A correction worth keeping: an earlier +revision claimed this half needed a repo-settings click, reasoning from "`needs` +cannot cross workflow files" — true of a job's `needs`, but a `workflow_call` +brings the called jobs *into* the caller's file, which is the standard mechanism +and what ships. (An even earlier revision of this section then went on to repeat +the original error two paragraphs later. Both are corrected here.) + +**Whether a red check blocks a merge is a separate, branch-dependent fact, and on +`dev` the answer is no.** `CI gate` is a required status check on `main`, so +there the routing closes. The `dev` ruleset requires **no status checks at all** — +its only rules are `deletion` and `non_fast_forward` — so `Dev gate` is +red-but-not-required, and a CodeQL failure on `dev` is a red X beside a mergeable +PR. Adding `Dev gate` to the `dev` ruleset is a repo-settings action that no pull +request can perform; it is tracked as an open item in +[`SCANNING.md`](SCANNING.md) ("Known gaps"). + +`codeql.yml` also carries its own aggregate **`CodeQL gate`** job. In the +`workflow_call` path it is redundant — the caller's `needs: codeql` already rolls +up every matrix leg — so what it is for is the standalone schedule/dispatch runs +(one legible verdict per weekly re-scan instead of four boxes) and as a stable +single check name should anyone want to name one in a ruleset. Naming +`Analyze (go)`, `Analyze (python)`, `Analyze (javascript-typescript)` and +`Analyze (actions)` individually would mean re-pointing branch protection by hand +every time the matrix gains or loses a language, and the failure mode of getting +that wrong runs in the dangerous direction: a required check that never reports +again blocks every PR, and a removed one silently stops gating. + +No repo-settings or API change to code-scanning configuration was made as part of +this work. ## Where findings appear — and why the job log now says @@ -479,19 +637,41 @@ API. So the analyze step now also writes SARIF locally (`output:`) and a following step jq-summarizes it into both the job log and the step summary — the same thing `govulncheck-scheduled.yml` already does with its SARIF: +The counts and line numbers below are placeholders — they move with every commit. +What is fixed is the format: + ``` ### CodeQL findings — go -2 [error] go/clear-text-logging -1 [warning] go/incomplete-hostname-regexp -1 [note] go/redundant-assignment --- -total findings: 4 +BLOCKING — High band (security-severity >= 7.0), not waived (): + none + +ACCEPTED — High band, waived in codeql-accepted-findings.json or in-source (): + [error] sec-sev=9.1 go/request-forgery internal/tools/web_fetch.go: + [error] sec-sev=7.5 go/clear-text-logging cmd/fleet/main.go: + ... + +ADVISORY — below the High band; triage in the Security tab (): + [error] sec-sev=6.1 go/log-injection : + ... + +totals: finding(s) — blocking, accepted, advisory +rule metadata resolved: +files in the go database: ``` -It is reporting only and never fails the job; blocking on findings is merge -protection's job, above. When no SARIF was written it says so explicitly rather -than printing "No findings." — reporting a clean result you did not observe is -the error this repo keeps having to write down. +Three properties of that listing are deliberate. Every line carries the +finding's **`file:line`**, so an agent reading the log can go straight to the +site. The **ACCEPTED tier is printed by name**, because a waiver invisible in CI +output is a waiver nobody re-reads. And the two trailing counts are coverage +lines rather than verdicts: `rule metadata resolved` is what the vacuity check +reads, and `files in the … database` is what distinguishes "no findings" from +"analyzed nothing". + +The summary step is reporting only — a **separate** `Fail on findings` step does +the blocking, from the same classification (see "The threshold" above), so the +report and the gate cannot disagree. When no SARIF was written the step says so +and **fails**, rather than printing "No findings." — reporting a clean result you +did not observe is the error this repo keeps having to write down. `security-events: write` plus the analyze step's upload is the code-scanning ingestion path, so results also land in the repo's **Security → Code scanning**. @@ -508,9 +688,11 @@ Analysis upload status is complete. Two practical consequences worth stating, because they explain an empty-looking Security tab rather than a broken one: -- **The Security tab's alert list is the DEFAULT BRANCH's.** This workflow's only - `push` trigger is `main`, so that list refreshes when a promote merge lands on - `main` — not when a PR is scanned. +- **The Security tab's alert list is the DEFAULT BRANCH's.** This workflow has no + `push` trigger of its own at all; push-event runs reach it through `ci.yml` + (on `main`) and `dev-ci.yml` (on `dev`). So the default-branch list refreshes + when a promote merge lands on `main` — not when a PR is scanned, and not when + `dev` moves. - **PR runs report on the PR**, not into the default-branch alert list, and CodeQL additionally suppresses file-coverage detail there: *"To speed up pull request analysis, file coverage information is only enabled when analyzing the diff --git a/docs/SCANNING.md b/docs/SCANNING.md index 93064ba83..397b3275e 100644 --- a/docs/SCANNING.md +++ b/docs/SCANNING.md @@ -13,15 +13,22 @@ security queries only) and [`TESTING.md`](TESTING.md) (the rest of the ladder). | `oxlint` + `tsc` | web tier lint + types | ~5s | **blocks** (`ci-gate`) | job log | | **`ruff`** | **Python lint** | **~1s** | **blocks** (`ci-gate`) | job log | | `govulncheck` | Go dependency CVEs (called symbols) | ~30s | **blocks** (`ci-gate`) | job log + Security tab | -| `grype` | sandbox image CVEs (fixable **CRITICAL + HIGH**) | ~1m | **blocks** (`ci-gate`) | job log + Security tab | +| `grype` | sandbox image CVEs (fixable **CRITICAL + HIGH**, **RPMs only**) | ~1m | **blocks** (`ci-gate`) | job log + Security tab | | `gitleaks` | secrets, every branch | ~10s | **blocks** (`ci-gate`) | job log | | **`npm audit`** | npm dependency CVEs (web + rampart-service) | ~5s | **blocks** (`ci-gate`) | job log | -| CodeQL | **interprocedural taint / `security-extended`** | ~2m | **blocks** (`ci-gate`/`Dev gate` via workflow_call) | job log + Security tab | -| **Semgrep** | **Go/JS/Python SAST + Actions supply chain** | ~40s | **blocks** (`ci-gate`/`Dev gate` via workflow_call) | job log + artifact | +| CodeQL | **interprocedural taint / `security-extended`** | ~2m | **blocks** on an unwaived High-band finding (`ci-gate`/`Dev gate` via workflow_call) | job log + Security tab | +| **Semgrep** | **Go/JS/Python SAST + Actions supply chain** | ~40s | **blocks** on any unsuppressed finding (`ci-gate`/`Dev gate` via workflow_call) | job log + artifact | Two things were added here (**ruff**, **Semgrep**) and one was narrowed (**CodeQL**, to security queries only). +**Read "blocks" with one caveat, and it is a big one.** Every lane above reaches +its branch's aggregate gate job — but a gate job only *blocks a merge* where it +is a **required status check**. On `main` it is (`CI gate`). On `dev` the ruleset +requires no status checks at all, so `Dev gate` is red-but-not-required there. +The "gates?" column describes the wiring, which is real; the enforcement half is +branch-dependent. See ["Known gaps"](#known-gaps-deliberately-not-closed-here). + ## Why each tool is where it is **The design rule: one owner per job.** A second tool over ground an existing @@ -62,12 +69,20 @@ violation is a regression rather than noise in a backlog: the only finding here that was arguably a latent bug. - `bento_pdf.py` — a lambda assigned to a name (`E731`), rewritten as a `def`. +One narrowing is worth naming, because the rule set is otherwise uniform across +all 13 files: `ruff.toml`'s `[lint.per-file-ignores]` waives **`F401` (unused +import) for `internal/mcp/testdata/*.py` and `cmd/fleet/testdata/*.py`**. Those +are deliberately minimal MCP stand-ins that exist to be spawned and to misbehave +in specific ways, so an import that nothing uses can be the point of the +fixture. Nothing else is waived anywhere, and the waiver is per-path and +per-rule — `F` still bites everywhere else in those directories. + `ruff format --check` is **also gated** (CI and `make lint`): the whole tree was ruff-formatted in one dedicated commit (9 files, ~3.7k lines, validated against the full Go suite — the bento/fileops golden tests exercise these scripts), so the gate started clean and a failure means one new file. -### CodeQL owns interprocedural taint (narrowed, fails on findings) +### CodeQL owns interprocedural taint (narrowed, gates on the High band) CodeQL is the only tool in this stack that does cross-function dataflow, and that is exactly the shape of fleet's headline invariants: *a credential must not reach @@ -78,21 +93,86 @@ So CodeQL keeps its security queries and gives up everything else — the qualit suite duplicated `golangci-lint`/`oxlint` for Go and JS, and ruff is a better fit for Python. Full reasoning and measurements in [`CODEQL.md`](CODEQL.md). -It runs the **`security-extended`** suite — the broader security set, adopted -after the default suite measured clean — and reports **zero findings** on this -tree (verified in CI across all four languages on Dev CI run 525), which is -what makes it safe to gate: a `Fail on findings` step now fails the job on any -finding, so a red `Analyze (…)` check means the *code* has a problem rather than -just "the scanner broke". That distinction is the whole reason the Go toolchain -break sat unnoticed for weeks. - -Getting the extended suite to zero was itself a fix, not a rubber stamp: its -one finding across all four languages was `actions/untrusted-checkout/medium` -on `build-sandbox-image.yml`'s `fleet_ref`-fed checkout. Rather than waive it -(the `actions` language has no `AlertSuppression.ql`, so there is no in-code -waiver anyway), the workflow now **refuses `refs/pull/*` refs** before checking -out — a fork-PR ref would put fork-controlled code into a workflow that runs -the checked-out build script — and the identical hardening went into +It runs the **`security-extended`** suite — the broader security set — and a +`Fail on findings` step fails the job on an unwaived finding in the **High band** +(`security-severity >= 7.0`; for a rule that publishes no security-severity, the +fallback is SARIF level `error`/`warning`). So a red `Analyze (…)` check means +the *code* has a problem rather than just "the scanner broke", which is the +distinction the Go toolchain break survived weeks inside of. Findings below the +band are **advisory**: printed in the job log and step summary, uploaded to the +Security tab, not blocking. The threshold and the reasoning behind it are +[ADR-0048](adr/0048-codeql-severity-gating.md). + +**The threshold used to be "any finding", and correcting that is the most +instructive thing in this document.** The any-finding gate was armed on a +measured zero — Dev CI run 525, across all four languages. That run was a +`pull_request` event, and on `pull_request` events the CodeQL action runs +**diff-informed**: it builds the full database and evaluates every query, then +reports only results whose location falls inside the PR's diff. Run 525's own +log says both halves out loud — `Persisted 204 diff range(s) across 43 file(s)` +and `file coverage information is only enabled when analyzing the default branch +and protected branches`. The zero measured the **diff**, not the tree. + +The first full-tree evaluation was therefore the **push** that merged that work: +Dev CI run 527, which reported **38 Go and 17 javascript-typescript findings** and +turned `Dev gate` red — with no PR-shaped way out, because a PR into `dev` is +scanned diff-informed and comes back green while `dev` itself stays red. + +The generalisable rule, worth carrying to any scanner that supports diff-scoped +analysis: **a PR-event CodeQL run certifies a diff, not a tree.** Any claim of +the form "the scanners are green, therefore the tree is clean" that rests on a +`pull_request` run is unsound. Tree-wide verdicts come from the push and +scheduled runs. + +Of those 55, **four were reachable and were fixed in code, not waived**: an +unsanitized `task.Prompt` in the task-create log (its update-path twin was +already wrapped in `logSafe`), the raw pre-validation client attachment path +logged on the two branches where the containment guard had just *failed*, the +client-echoed attachment `Name` on the `/chat` path, and an Ed25519 private key +written to a predictable world-writable temp path at `0644` in +`web/e2e/test-auth-key.ts`. The remaining 51 are false positives in fleet's +threat model, and severity alone does not separate them — `go/request-forgery` +is 9.1 and fires on the deliberate `@url` fetch tool behind `internal/netguard`'s +resolve-then-dial SSRF guard; `go/weak-sensitive-data-hashing` is 7.5 and fires +on SHA-256 used as a lookup index over a 32-byte `crypto/rand` token, which is +the recommended construction. + +Those 51 live in **`.github/codeql-accepted-findings.json`**, a register of +accepted `(rule, file)` pairs each carrying a mandatory written reason. It is +per-**file**, not per-rule, and that is the whole point of preferring it to a +`query-filters` exclude: excluding `go/request-forgery` would switch a +security-severity 9.1 query off for the entire repository, whereas a register +entry waives it in the two files that were read and leaves the query live +everywhere else. An in-source `// codeql[rule-id]` comment waives too (CodeQL +emits it as a `suppressions` array on the result; the comment must sit on its own +line and covers the line below it). Widening the register is a security decision +that appears in the PR diff, and `scripts/check_codeql_register_test.go` fails +`make test` on an entry naming a file that does not exist, a missing reason, or a +register that `codeql.yml` has stopped referencing. + +**One classifier, two consumers.** `.github/codeql-gate.jq` does the banding and +the waiver lookup, and both the summary step and the gate step run it via +`jq -f` — two copies of a SARIF filter is two copies that can disagree about +what "blocking" means, and the report disagreeing with the gate is worse than +either being wrong alone. The job log prints **three tiers** from that one +classification: BLOCKING, ACCEPTED (by name — a waiver nobody re-reads is worse +than no waiver) and ADVISORY. + +**It fails closed.** A missing register, a missing filter file, SARIF that will +not parse, and — the subtle one — findings present with **zero rule metadata +resolved** all fail the job rather than reporting clean. That last is a vacuity +check with a real provenance: CodeQL puts query metadata in +`tool.extensions[].rules[]`, not `tool.driver.rules[]`, and a first cut of the +filter read only the driver, resolved nothing, scored every finding at +security-severity 0 and reported "0 blocking" over a tree holding findings. + +Getting the extended suite adopted was itself a fix, not a rubber stamp: the one +`actions`-language finding was `actions/untrusted-checkout/medium` on +`build-sandbox-image.yml`'s `fleet_ref`-fed checkout. Rather than waive it (the +`actions` language has no `AlertSuppression.ql`, so there is no in-code waiver +anyway), the workflow now **refuses `refs/pull/*` refs** before checking out — a +fork-PR ref would put fork-controlled code into a workflow that runs the +checked-out build script — and the identical hardening went into `publish-sandbox-image.yml`, the *unflagged* twin that holds `packages: write` and only escaped the (name-heuristic) query because its plumbing was named differently. Details in [`CODEQL.md`](CODEQL.md). @@ -112,8 +192,9 @@ fixing every real finding and adjudicating every false one. `p/github-actions` found one issue class nothing else in this repo checks — actions referenced by a **mutable tag** (`actions/checkout@v7`) instead of an immutable commit SHA. If a tag moves, attacker-controlled code runs with this -repo's `GITHUB_TOKEN`. Every one of the **53** action references across all 12 -workflows is now pinned: +repo's `GITHUB_TOKEN`. There are **13** workflow files, **12** of which reference +an action at all (`scan-cron-alarm.yml` has no `uses:`), and every one of the +**53** third-party action references across them is now pinned: ```yaml uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -125,6 +206,18 @@ not smuggle in a version bump. The trailing version comment is also the form Dependabot reads and updates, and `.github/dependabot.yml` already watches the `github-actions` ecosystem, so these stay current without hand-editing. +**Two of those pins were not what they looked like, and the failure is silent.** +`git ls-remote` on a repository that publishes *annotated* tags returns the tag +**object's** SHA for `refs/tags/v4`, not the commit it points at — for +`github/codeql-action` that is `4c0873ef…` for `refs/tags/v4` and `db488dde…` +for `refs/tags/v4^{}`. A pin taken from the unpeeled form is a 40-hex string +that looks exactly like a commit pin, satisfies every "is it a SHA" check, and +resolves to a **mutable major tag** — the precise defect the pinning exercise +existed to remove. Two distinct pins were in that state, across 7 usages; both +are now the peeled commit with an exact `# vX.Y.Z` comment, and +`scripts/check_action_pins_test.go` asserts the shape so the next pin cannot be +taken from the wrong ref. + Two `uses:` lines are deliberately left on `@main`: both are inside **comments** in `build-sandbox-image.yml` / `publish-sandbox-image.yml`, documenting how a downstream bundle repo calls fleet's reusable workflows. `@main` is the right @@ -202,19 +295,41 @@ wrong; exit 127 on the first CI run taught that one.) Both scanners print a per-rule summary into the job log **and** the step summary: +The shape of it — the counts and line numbers below are placeholders, since they +move with every commit; what is fixed is the format: + ``` -### CodeQL findings — actions -[warning] actions/untrusted-checkout/medium .github/workflows/build-sandbox-image.yml:106 --- -total findings: 1 -files in the actions database: 13 +### CodeQL findings — go +BLOCKING — High band (security-severity >= 7.0), not waived (): + none +ACCEPTED — High band, waived in codeql-accepted-findings.json or in-source (): + [error] sec-sev=9.1 go/request-forgery internal/tools/web_fetch.go: + [error] sec-sev=7.5 go/clear-text-logging cmd/fleet/main.go: + ... +ADVISORY — below the High band; triage in the Security tab (): + [error] sec-sev=6.1 go/log-injection : + ... + +totals: finding(s) — blocking, accepted, advisory +rule metadata resolved: +files in the go database: ``` -Each line carries the **`file:line`** of the finding — an agent reading the log -can go straight to the site — and the `files in the … database` count is the -coverage line: "No findings." over an empty database is the green-but-vacuous -outcome this workflow exists to rule out, and the two are indistinguishable -without it. +Three properties of that listing are deliberate. Each line carries the +**`file:line`** of the finding, so an agent reading the log can go straight to +the site. The **ACCEPTED tier is printed by name**, because a waiver that is +invisible in CI output is a waiver nobody re-reads. And the two trailing counts +are the coverage lines: `rule metadata resolved` is what the gate's vacuity check +reads (findings present with zero metadata resolved fails the job), and +`files in the … database` distinguishes "no findings" from "analyzed nothing" — +the green-but-vacuous outcome this whole stack exists to rule out. + +For the one measurement that is worth quoting rather than illustrating: the Go +database holds **426** of the tree's 427 non-test `.go` files, the missing one +being `host_disabled.go` — `host.go` and `host_disabled.go` carry mutually +exclusive build tags, and the `fleet_host_executor` tag passed to autobuild +deliberately trades which of the two is analyzed in favour of the real +unsandboxed-execution logic. See [`CODEQL.md`](CODEQL.md). This exists because a CodeQL run reports **nothing** about what it found to its own log — it writes SARIF, uploads it, exits 0, findings or not. Verified by @@ -236,13 +351,16 @@ paren choked the bash sub-parser (hoisted to a plain assignment), and an inline `import("@playwright/test")` type in `fixtures.ts` (a named `import type`, validated by `tsc`). -## What gates — everything, through the gates that already exist +## What gates — the wiring, and where enforcement actually lands Every lane in the table reaches the branch's aggregate gate: - `ci-gate` (the single required status check on `main`) `needs` the lint, test and build jobs — **and the two scanners**. -- `Dev gate` does the same on `dev`. +- `Dev gate` `needs` the same set on `dev` — but nothing in the `dev` ruleset + requires `Dev gate` to be green, so on that branch it is a red check rather + than a closed gate. That gap is the first item under "Known gaps" and it is + the single most important qualifier on this whole document. The scanners get there because `codeql.yml` and `semgrep.yml` are **reusable workflows** (`on: workflow_call`): `ci.yml` and `dev-ci.yml` each call them as a @@ -258,13 +376,29 @@ ships now. The scanners' own `push`/`pull_request` triggers were removed so nothing runs twice; each keeps its weekly `schedule` (new queries/rules against unchanged code) and a `workflow_dispatch`. -**Both scanners fail their job on any finding.** That is what makes a green -check mean "clean tree" rather than "the scanner ran" — the analyze step alone -exits 0 whether it found nothing or a hundred alerts, which is how the Go -toolchain break survived weeks behind a red-but-not-required check. Failing on -*any* finding is only defensible because the tree is at zero unsuppressed -findings everywhere — verified before the switch was flipped. A gate turned on -over an existing backlog is a gate people route around. +**Both scanners fail their job on a finding, but not on the same threshold, and +the difference is deliberate.** + +- **Semgrep: any unsuppressed finding.** `--error`, no `continue-on-error`. That + is defensible because the tree is at zero unsuppressed findings across all four + packs, with the 6 false positives waived at the line and mutation-tested. +- **CodeQL: an unwaived finding in the High band** (`security-severity >= 7.0`, + or level `error`/`warning` for a rule that publishes no security-severity), + with the accepted-findings register applied. Below the band is advisory. It was + "any finding" for exactly one merge, and [ADR-0048](adr/0048-codeql-severity-gating.md) + records why that could not hold: nearly every CodeQL security query is + `@problem.severity error` — `go/log-injection` is `error` at security-severity + 6.1 — so banding on level would block on all 23 log-injection findings, and the + zero the any-finding gate was armed on came from a diff-informed PR run. + +What both thresholds buy is the same thing: a green check that means something +about the *code*, not just that the scanner ran. The analyze step alone exits 0 +whether it found nothing or a hundred alerts, which is how the Go toolchain break +survived weeks behind a red-but-not-required check. + +What neither buys is enforcement on a branch whose ruleset requires no checks. +Wiring and enforcement are two different levers, and only one of them lives in +this repo. (Code scanning merge protection — the ruleset's alert-severity rule — remains available on top as a belt-and-braces option, but nothing depends on it now.) @@ -273,8 +407,59 @@ available on top as a belt-and-braces option, but nothing depends on it now.) Stated rather than left for rediscovery: -- **`_test.go` files are outside CodeQL's database** (621 files) — `autobuild` - builds packages, not tests. Unchanged from default setup. +- **Nothing in `dev-ci.yml` is a required check on `dev`, so every job in it — + CodeQL and Semgrep included — is red-but-not-required there.** This is the + largest gap on the page and it cannot be closed from a pull request, so it is + written down rather than implied away. + + The `dev` ruleset's only rules are `deletion` and `non_fast_forward`. There is + no `pull_request` rule and no `required_status_checks` block, so there is no + status check for GitHub to hold a merge on. `main` is the branch that does + require one (`CI gate`). Every sentence in this document about a scanner + "blocking" describes wiring that is genuinely in place — the `workflow_call` + jobs really do sit in `Dev gate`'s `needs` — and on `dev` that wiring produces + a red X beside a mergeable PR. + + Two things compound it, and together they are the actual risk: + + 1. `.github/dependabot.yml` points the `github-actions` ecosystem at `dev` on a + **daily** interval with **no `cooldown`** — Dependabot supports `cooldown` + for `gomod` and `npm` only, so the one ecosystem whose "dependency" is the + CI definition itself is also the one that cannot be made to wait. + 2. A `github-actions` bump **is a rewrite of `.github/workflows/*`**: it + changes what CI executes. + + So the pre-existing shape was: a same-day patch bump to a third-party action, + auto-merged into a branch with no required checks, rewriting the workflows that + are supposed to check it. Three workflow-side mitigations ship alongside this + document — `auto-merge-dependabot.yml` now **excludes the `github_actions` + ecosystem** whatever the bump level, carries an explicit + `branches: [main, dev]` filter so it can never silently start applying to an + unprotected branch, and declares its write scopes on the job rather than the + workflow. Those narrow the blast radius; they do not make `Dev gate` required. + + **The remaining fix is a repo-settings action and belongs to the owner:** add + `Dev gate` to the `dev` ruleset's required status checks. Nothing in a workflow + file can make itself required, so no PR can close this item. + +- **`_test.go` files are outside CodeQL's database** (625 files in this tree — + the count moves with the suite) — `autobuild` builds packages, not tests. + Unchanged from default setup, and stated because "CodeQL covers the Go code" + would otherwise overclaim. +- **The accepted-findings register keys on `(rule, file)`, not + `(rule, file, line)`.** Deliberate: line numbers churn on every edit, and a + register that fails on unrelated refactors is a register people delete. The + cost is that a *second*, genuinely bad instance of an already-waived rule in an + already-waived file would not block. That is the sharpest edge in the CodeQL + gate, and it is why each reason string names the specific call sites and the + guard that makes them safe. See [ADR-0048](adr/0048-codeql-severity-gating.md). +- **A note-level regression no longer fails the build.** A 24th + `go/log-injection` sink on genuinely untrusted input would appear in the + advisory tier and the Security tab, not in a red check. The class is not + unguarded — `gosec`'s G706 covers it inside `golangci-lint`, which *does* block + through `ci-gate`, and carries a reviewed `//nolint:gosec` annotation at each + of the ~80 sites where it was adjudicated — but the CodeQL lane is not what + would stop it. - **Semgrep's rule packs are registry-fetched and cannot be pinned by vendoring** — investigated and rejected on license grounds, not neglect. The Semgrep Rules License v1.0 grants use for "your own internal business diff --git a/docs/TESTING.md b/docs/TESTING.md index dca9c52ef..83336744d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -9,13 +9,23 @@ delegate to the same commands the workflows run, so "make it green locally" and "make CI green" are the same act. The source of truth is, and remains, the workflow files themselves: -- [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) — the PR gates - (every job must be green to merge). +- [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) — the full gate on + `main` (every job must be green to merge; `CI gate` is the required check). +- [`.github/workflows/dev-ci.yml`](../.github/workflows/dev-ci.yml) — the fast + lane on `dev`. Same shape, fewer lanes — and its aggregate `Dev gate` is **not** + a required check, see "Which lanes run where" below. +- [`.github/workflows/codeql.yml`](../.github/workflows/codeql.yml) and + [`.github/workflows/semgrep.yml`](../.github/workflows/semgrep.yml) — the two + SAST lanes. Both are **reusable** workflows (`on: workflow_call`) with no + push/PR triggers of their own: `ci.yml` and `dev-ci.yml` call them as jobs, so + they land in the caller's gate. Each also keeps a weekly `schedule`. - [`.github/workflows/e2e-canary.yml`](../.github/workflows/e2e-canary.yml) — the nightly real-model canary (never a PR gate). - [`.github/workflows/grype-scheduled.yml`](../.github/workflows/grype-scheduled.yml) - — a weekly, non-blocking container-image vulnerability scan (never a PR - gate). + and + [`.github/workflows/govulncheck-scheduled.yml`](../.github/workflows/govulncheck-scheduled.yml) + — scheduled, non-blocking re-scans of unchanged code (never PR gates), because + a CVE/advisory verdict is a function of the clock as well as the commit. If a command here ever disagrees with those files, the workflow wins — please fix this doc (and the `make` targets) to match. @@ -27,13 +37,17 @@ fix this doc (and the `make` targets) to match. | Secret scan | `gitleaks` | No secrets committed | `gitleaks dir . --redact --exit-code 1` | | Go build | `go` | Release binary compiles (host executor fenced out) | `make compile` | | Go vet | `go` | `go vet` clean (tagged) | part of `make ci-go` | -| Go lint | `go` | `golangci-lint` full gate (zero findings) | `make lint` | +| Go lint | `go` | `golangci-lint` full gate (zero findings) | `make lint-go` | +| Python lint | `python` | `ruff check` **and** `ruff format --check` over the 13 Python files | `make lint-python` | | Go test | `go` | Unit + integration suites + coverage profile (needs Postgres) | `make test` | | Go coverage | `go` | Coverage profile summarised in the log + job summary (advisory, no threshold) | `make test-cover` | | Go test -race | `go` | Race detector on the same suites | `make test-race` | | govulncheck | `go` | Dependency CVEs reachable from fleet | `make govulncheck` | -| Grype (image) | `grype-scan` | CVEs in the sandbox container image (fail on a fixable CRITICAL or HIGH) | see below | -| Web lint/test/build | `web` | ESLint + vitest + `next build` | `make ci-web` | +| Grype (image) | `grype-scan` | CVEs in the sandbox container image (fail on a fixable CRITICAL or HIGH **Fedora RPM**) | see below | +| CodeQL | `codeql` (called workflow) | `security-extended` taint analysis over go / python / javascript-typescript / actions; fails on an unwaived **High-band** finding | not wrapped (see [`CODEQL.md`](CODEQL.md)) | +| Semgrep | `semgrep` (called workflow) | `p/github-actions` + `p/golang` + `p/javascript` + `p/python`; fails on **any** unsuppressed finding | `semgrep scan --config …` (see [`SCANNING.md`](SCANNING.md)) | +| npm CVE audit | `web` | `npm audit --audit-level=low`, lockfile-only, over `web/` **and** `scripts/rampart-service` — fails on any severity; plus `scripts/check-npm-overrides.sh` | `npm audit --audit-level=low` in each tree | +| Web lint/test/build | `web` | oxlint + `tsc --noEmit` + vitest + `next build` | `make ci-web` | | Playwright (mocked) | `playwright` | Deterministic browser e2e, no backend | `make ci-e2e-mocked` | | Playwright (live) | `e2e-live` | Real stack + rootless-Podman sandbox, fake LLM | `npm run test:e2e:live` | | Playwright (canary) | `canary` (nightly) | Real cheap OpenRouter model, drift detection | `npm run test:e2e:canary` | @@ -53,13 +67,44 @@ divide the work like this: | | `dev-ci.yml` (fast lane) | `ci.yml` (full gate) | | --- | --- | --- | | **Fires on** | PRs into `dev`, and pushes to `dev` | PRs into `main`, and pushes to `main` — in practice, the dev→main promotion PR | -| **Runs** | Go compile / vet / lint / test (with Postgres), web lint / test / build, migration DDL lint, gitleaks | everything in the table above | -| **Skips** | `-race`, govulncheck, Grype, both Playwright suites, CodeQL | nothing | +| **Runs** | Go compile / vet / lint / test (with Postgres), Python lint (ruff check + format), **CodeQL**, **Semgrep**, web lint / typecheck / test / build **plus the npm CVE audit and the override canary**, migration DDL lint, gitleaks | everything in the table above | +| **Skips** | `-race`, govulncheck, the Grype image scan, both Playwright suites | nothing | | **Aggregate check** | `Dev gate` | `CI gate` | - -The split is "does it compile, lint, and pass tests" on `dev`; "is it safe to -ship" on the promotion. The skipped lanes are the slow ones, and none of them is -what a routine change breaks. +| **Is that aggregate a *required* check?** | **No** — see the caveat below | Yes | + +The split is "does it compile, lint, pass tests, and pass the SAST scanners" on +`dev`; "is it safe to ship" on the promotion. The skipped lanes are the slow ones, +and none of them is what a routine change breaks. + +**CodeQL and Semgrep used to be on that skipped list. They are not any more** — +both are reusable workflows that `dev-ci.yml` calls as jobs, so they sit in +`Dev gate`'s `needs` and run on every push to `dev` and every PR into it. An +earlier revision of this table said the fast lane skipped CodeQL, which was the +opposite of what shipped. + +> **The caveat that qualifies this whole section: `Dev gate` is not a required +> status check.** The `dev` ruleset's only rules are `deletion` and +> `non_fast_forward` — there is no `pull_request` rule and no +> `required_status_checks` — so every job in `dev-ci.yml`, the two scanners +> included, is *red-but-not-required* on `dev`. A failing fast lane produces a red +> X beside a mergeable PR. `main` is the branch that genuinely gates, on +> `CI gate`. Making `Dev gate` required is a repo-settings action that no pull +> request can perform; it is tracked as an open item in +> [`SCANNING.md`](SCANNING.md) ("Known gaps"). + +**One more thing worth knowing about `ci.yml`, because it decides whether the +suite runs at all:** a `changes` job classifies each push/PR as docs-only, and the +heavy jobs (`go`, `python`, `codeql`, `semgrep`, `web`, both Playwright lanes, +`grype-scan`) skip when it says yes. That classifier used to match `*.md` at any +depth plus all of `docs/*`, which swallowed compiled product content — the +`go:embed`'d `builtin_skills/*/SKILL.md` files, the shipped +`config/default/system_prompts/*.md`, and `docs/openapi.yaml` (asserted by +`cmd/fleet/openapi_drift_test.go`) — so a PR touching only a shipped system +prompt or the OpenAPI spec skipped the very tests that validate it while +`CI gate` reported green. It is now an explicit prose allow-list, and `ci-gate` +additionally **refuses to pass over a `skipped` job unless the classifier +actually said docs-only**, so a skip produced by any other cause fails the gate +instead of passing silently. > **Both triggers on the fast lane matter.** The `pull_request` trigger was added > after a period when PRs into `dev` were gated by nothing but CodeQL, which made @@ -443,14 +488,25 @@ supply chain matters as much as what it scans. The per-PR scan collects and uploads **all** findings, including unfixed and non-blocking language-package records. A separate repository-owned policy -(`scripts/check-grype-policy.sh`) fails only on a **CRITICAL Fedora RPM** with a -non-empty fix version. This distinction is intentional: Fedora RPMs sometimes -also expose Python `dist-info`, which Grype catalogs as a second PyPI artifact; -an upstream PyPI fix does not mean Fedora has published an installable RPM. The -generic image follows Fedora latest, so an actionable failure should be fixed by -rebuilding/updating the RPM rather than by layering a pip wheel over files owned -by the distro. The weekly scan uses the same complete reporting model (see -below). Narrow, reviewed suppressions live in [`.grype.yaml`](../.grype.yaml) +(`scripts/check-grype-policy.sh`) fails on a **CRITICAL *or* HIGH Fedora RPM** +with a non-empty fix version — that is, `severity in {critical, high}` **and** +`.artifact.type == "rpm"` **and** a non-empty `fix.versions`. MEDIUM and below are +reported, not blocking, and a non-RPM record never blocks whatever its severity. + +Both halves of that filter are deliberate. HIGH was added to the gate *after* +measuring rather than before: the published image at the time carried zero fixable +Critical or High RPM findings (its only fixable findings were two Medium openssh +advisories), so the tightened gate started clean instead of arming over a backlog. +And the RPM restriction is there because Fedora RPMs also ship Python +`dist-info`, which Grype catalogs as a second, independent PyPI artifact using +upstream versions and advisories — so such a record can claim a fix exists when +Fedora has already backported it or has not published an RPM update yet. Treating +those language records as a merge gate previously led to hand-maintained pip +replacements layered over a coherent distro package set. They are still uploaded +to SARIF; they just do not gate. The generic image follows Fedora latest, so an +actionable failure should be fixed by rebuilding/updating the RPM rather than by +layering a pip wheel over files owned by the distro. The weekly scan uses the same +complete reporting model (see below). Narrow, reviewed suppressions live in [`.grype.yaml`](../.grype.yaml) (one `ignore:` entry per CVE, with a rationale comment); Grype auto-reads it from the repository root. @@ -468,10 +524,15 @@ SARIF. Reproduce locally (needs podman + the Grype binary): # Build the same image the job scans, and export it to a docker-archive tarball. IMAGE_NAME=localhost/fleet-sandbox scripts/build-sandbox-image.sh latest podman save --format docker-archive -o sandbox-image.tar localhost/fleet-sandbox:latest -# Install grype first (see .github/workflows/ci.yml for the pinned version+sha), -# then scan exactly as the gate does: -grype docker-archive:sandbox-image.tar --only-fixed --fail-on critical \ - --output table --output sarif=grype-results.sarif +# Install grype first (see .github/workflows/ci.yml for the pinned version+sha). +# The CI job scans with NO --fail-on and NO --only-fixed: it reports everything, +# then hands the JSON to the policy script, which is where the gate lives. +grype docker-archive:sandbox-image.tar \ + --output table \ + --output json=grype-results.json \ + --output sarif=grype-results.sarif +# The gate itself — fixable CRITICAL/HIGH Fedora RPMs only: +scripts/check-grype-policy.sh grype-results.json ``` There is no `make` target for this lane because it boots a podman image build; diff --git a/docs/adr/0036-sandboxed-file-tools-and-host-io-exceptions.md b/docs/adr/0036-sandboxed-file-tools-and-host-io-exceptions.md index 18c68c54c..ff8a7195e 100644 --- a/docs/adr/0036-sandboxed-file-tools-and-host-io-exceptions.md +++ b/docs/adr/0036-sandboxed-file-tools-and-host-io-exceptions.md @@ -76,8 +76,7 @@ host-brokered credentials/network that by invariant never enter the sandbox: - **Host network / brokered fetch**: `web_fetch`, `web_search`, `tavily_search`, `smart_search`, `download_url` (HTTP fetch), - `generate_image` (provider API), `fastio_upload` / Fast.io find, - `browserbase_live_view` (#987 — one authenticated GET to a fixed public + `generate_image` (provider API), `browserbase_live_view` (#987 — one authenticated GET to a fixed public vendor host that converts a hosted browser session id into a live-view URL for a HUMAN; it drives no browser, so ADR-0044's "browser automation is a connector" stands. Registered per turn only when a credential is actually @@ -85,11 +84,10 @@ host-brokered credentials/network that by invariant never enter the sandbox: `BROWSERBASE_API_KEY`; see `docs/BROWSERBASE.md`). These use host-side credentials and the egress-proxy/allowlist posture; running them in the sandbox would either leak credentials in or lose the host broker. -- **Host workspace staging** (path-validated legacy exceptions): - `fastio_upload` reads bytes for an outbound upload; `publish_artifact` stats - a confined path and records a pointer rather than opening arbitrary content. - Neither invokes a shell, dynamic import, or template executor; all - model-selected paths pass the workspace/pathsec allowlist. This class +- **Host workspace staging** (path-validated legacy exception): + `publish_artifact` stats a confined path and records a pointer rather than + opening arbitrary content. It invokes no shell, dynamic import, or template + executor; every model-selected path passes the workspace/pathsec allowlist. This class originally also covered `download_url` (writing fetched bytes), `generate_image` (reading reference images, writing provider output), and `xlsx` (a host zip read/rewrite) — those three were migrated in #1083: they @@ -121,6 +119,41 @@ host-brokered credentials/network that by invariant never enter the sandbox: spills and agent-history overflow breadcrumbs are removed; governed recovery bytes are written only through the bound sandbox FileOp capability. +**Amended 2026-08-22 (enterprise security audit).** Three corrections to the +enumeration above, because it presents itself as exhaustive and an auditor will +read it that way: + +- **`fastio_upload` is gone from both lists.** There is no native Go + `fastio_upload` tool any more — Fast.io is an MCP server, gated by + `internal/agentcore/mcp_fastio_guard.go` and reached through the broker like + any other connector. Leaving it enumerated as a host-read exception claimed a + hole that does not exist, which is its own kind of inaccuracy. (The "Deferred" + section still names it as a migration candidate; that entry is historical.) + +- **Host `git` worktree management** was not enumerated and should have been. + `internal/scheduledrun/worktree.go` and `internal/worktree/worktree.go` run + `git worktree add/remove` and `git branch -D` on the host via + `exec.CommandContext`. This is not model-authored: the argv is + fleet-constructed, no shell is involved, and the only externally-influenced + component is `WorktreeConfig.BranchPrefix`/`BaseBranch`, both of which are now + validated as git ref-name fragments with a leading-dash refusal + (`models.WorktreeConfig.Validate`) — `BaseBranch` reaches `git worktree add` as + a trailing positional with no `--` separator, so a leading dash would + otherwise have been parsed as an option. It belongs in the control-plane class, + named rather than implicit. + +- **Admin-triggered host `podman` build/run** was not enumerated either. + `internal/rampartinstall/installer.go` shells out to `podman` with fixed + arguments behind `POST /admin/pii-redaction/install`, which is admin-gated + (`internal/httpapi/routes.go`) and not model-callable. Same reasoning: fixed + argv, no shell, operator-initiated — a control-plane operation, but one this + ADR should have listed. + +Neither addition weakens the invariant: the sandbox is still mandatory for every +agent tool call's data-plane execution, and neither of these is an agent tool. +What changes is that the enumeration is now actually complete, so "is this +exception in the ADR?" is a question with a reliable answer. + ## Consequences ADR-0002 now states the enforceable boundary precisely: general model-authored diff --git a/docs/adr/0048-codeql-severity-gating.md b/docs/adr/0048-codeql-severity-gating.md index e4866b32e..ea16bd9e5 100644 --- a/docs/adr/0048-codeql-severity-gating.md +++ b/docs/adr/0048-codeql-severity-gating.md @@ -139,7 +139,8 @@ not stop them; it will appear in the advisory tier and in the Security tab. This is a deliberate trade: the alternative, as demonstrated above, is a gate that blocks every push and therefore gets routed around or switched off. `gosec`'s G706 covers the same log-injection class in `golangci-lint`, which **does** block -via `ci-gate`, and carries 77 reviewed per-site annotations — so this class is +via `ci-gate`, and carries 81 reviewed per-site `//nolint:gosec // G706` +annotations at the time of writing — so this class is not unguarded, it is guarded by the instrument that was already there. **What is now load-bearing.** Widening the register is a security decision that @@ -147,7 +148,7 @@ shows up in a PR diff, and reviewers are expected to check the reason against th code rather than the reason's existence. That is a process control, and process controls decay; the tests above are what make the decay visible. -**Known limitation, stated rather than fixed.** The 621 `_test.go` files remain +**Known limitation, stated rather than fixed.** The 625 `_test.go` files remain outside the Go database (autobuild builds packages, not tests) — unchanged from default setup and from #1246. And the register keys on `(rule, file)` rather than `(rule, file, line)` deliberately: line numbers churn on every edit, and a diff --git a/internal/sched/handlers/handlers.go b/internal/sched/handlers/handlers.go index e0d60a5ff..5e5c21cda 100644 --- a/internal/sched/handlers/handlers.go +++ b/internal/sched/handlers/handlers.go @@ -547,7 +547,6 @@ func (h *Handlers) CreateTask(w http.ResponseWriter, r *http.Request) { return } - //nolint:gosec // G706: untrusted fields are sanitized via logSafe (strips CR/LF); gosec's taint tracker cannot see through the helper. task.ID is a uuid.UUID. log.Printf("Task created: %s (prompt: %.50s...)", task.ID, logSafe(task.Prompt)) localizeTask(task) writeJSON(w, http.StatusOK, task) @@ -2365,7 +2364,6 @@ func (h *Handlers) CreateAPIKey(w http.ResponseWriter, r *http.Request) { } } - //nolint:gosec // G706: key.Name is unvalidated body text sanitized via logSafe (strips CR/LF), matching the sibling key handlers; key.KeyID is server-minted. log.Printf("Created API key: %s (%s)", key.KeyID, logSafe(key.Name)) resp := key.ToResponse() diff --git a/internal/sched/handlers/upload.go b/internal/sched/handlers/upload.go index 9ce7ee265..06994e217 100644 --- a/internal/sched/handlers/upload.go +++ b/internal/sched/handlers/upload.go @@ -194,11 +194,9 @@ func (h *Handlers) HandleUpload(w http.ResponseWriter, r *http.Request) { checksumPath := filepath.Join(tempDir, ".checksums", filename+".sha256") if err := os.WriteFile(checksumPath, []byte(checksum), 0600); err != nil { // Non-critical error, just log it - //nolint:gosec // G706: filename is sanitized via logSafe (strips CR/LF) and already passed sanitizeFilename; gosec's taint tracker cannot see through the helper. log.Printf("Failed to save checksum sidecar for %s: %v", logSafe(filename), err) } - //nolint:gosec // G706: filename is sanitized via logSafe (strips CR/LF) and already passed sanitizeFilename; size is an int and checksum is hex. log.Printf("File uploaded: %s (size: %d, checksum: %s)", logSafe(filename), size, checksum) writeJSON(w, http.StatusOK, map[string]interface{}{ diff --git a/ruff.toml b/ruff.toml index ed18d40f6..bd87d8c1c 100644 --- a/ruff.toml +++ b/ruff.toml @@ -15,9 +15,14 @@ # RULE SELECTION IS DELIBERATELY NARROW, and the numbers behind that are worth # recording so nobody widens it by accident. Measured on this tree: # -# default (E4,E7,E9,F) -> 3 findings <- what we gate on +# default (E4,E7,E9,F) -> 3 findings +# + B,SIM,S (bandit) -> 21 more, all fixed <- the gate today # E,F,W,I,UP,B,SIM,ISC,PLR,PLW,S -> 333 findings # +# What we gate on is the [lint] `select` at the bottom of this file: +# E4, E7, E9, F, B, SIM, S. The middle line is the current gate; the third line +# is the measurement that keeps the style tiers out. +# # The 330-finding difference is almost entirely style churn, not defects: # 176 UP031 (%-format vs f-string), 43 PLR2004 (magic values), 35 E501 (line # length). Gating on that would mean a 300-commit reformat for no correctness @@ -69,8 +74,13 @@ exclude = [ [lint] # The default rule set: pycodestyle errors (E4 imports, E7 statements, # E9 syntax/IO) plus Pyflakes (F — undefined names, unused imports, redefined -# names, unused locals). This is the "is it actually broken" tier; see the -# header for why the style tiers are not enabled. +# names, unused locals). This is the "is it actually broken" tier. +# +# Plus three families that were measured, fixed, and then ENABLED rather than +# left as a documented backlog (see the header): B (flake8-bugbear), SIM +# (flake8-simplify) and S (bandit — the security tier, which is why an S finding +# on a new line is a real question to answer rather than pre-existing noise). +# The style tiers (E501, UP, PLR, …) stay out; the header records why. select = ["E4", "E7", "E9", "F", "B", "SIM", "S"] [lint.per-file-ignores] diff --git a/scripts/check_gate_needs_test.go b/scripts/check_gate_needs_test.go index ec8cd5256..3a681070e 100644 --- a/scripts/check_gate_needs_test.go +++ b/scripts/check_gate_needs_test.go @@ -54,8 +54,9 @@ func TestAggregateGateNeedsEveryJob(t *testing.T) { } jobsBlock := text[jobsAt:] - var jobs []string - for _, m := range jobKeyRe.FindAllStringSubmatch(jobsBlock, -1) { + matches := jobKeyRe.FindAllStringSubmatch(jobsBlock, -1) + jobs := make([]string, 0, len(matches)) + for _, m := range matches { jobs = append(jobs, m[1]) } if len(jobs) < 2 { diff --git a/web/next-env.d.ts b/web/next-env.d.ts index 9edff1c7c..ce4e94a6b 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 8e17eba7477f74a44593aa36a99be6b5c3f85bd2 Mon Sep 17 00:00:00 2001 From: Brad Flaugher Date: Sat, 22 Aug 2026 16:42:21 +0000 Subject: [PATCH 09/10] Revert generated next-env.d.ts churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js rewrites web/next-env.d.ts on every `next build`, and my verification run swept the added root-params reference into the docs commit. The file's own header says it should not be edited, and this branch changes nothing that would legitimately alter it — so it goes back to dev's version rather than carrying a build artifact through review. Signed-off-by: Brad Flaugher --- web/next-env.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/web/next-env.d.ts b/web/next-env.d.ts index ce4e94a6b..9edff1c7c 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,7 +1,6 @@ /// /// import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From a88dd9d9b9a70d5787d015983c23436d407a2088 Mon Sep 17 00:00:00 2001 From: Brad Flaugher Date: Sat, 22 Aug 2026 16:45:02 +0000 Subject: [PATCH 10/10] Record why the forced final summary is exempt from the ceiling guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit streamForceFinalSummary does not apply in.GuardStep while its sibling streamLeakedToolCallRetry does, and the asymmetry reads as an oversight — it was flagged as one during the audit. It is deliberate, so the reason now sits next to the code instead of in someone's head: the retry runs WITH tools and can buy an unbounded number of paid completions, so it must be held to the run's ceilings. The forced summary is a single tool-less completion bounded by tc.MaxTokens, it is metered via in.RecordUsage in OnStepFinish like any other call, and it only runs on the canFinish path — a run stopped by ErrCostCeilingExceeded never reaches Finalize, so it cannot be entered after a ceiling has already tripped mid-run. Worst case is one bounded, accounted completion of overshoot when a ceiling is reached on the final step, which is the price of returning a usable answer rather than a truncated one. The comment says explicitly that this stops being true if the function ever grows tools or a loop. No behavior change. Signed-off-by: Brad Flaugher --- internal/agent/interactive.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/agent/interactive.go b/internal/agent/interactive.go index fdb1ff6d7..5027d9541 100644 --- a/internal/agent/interactive.go +++ b/internal/agent/interactive.go @@ -391,6 +391,23 @@ func streamLeakedToolCallRetry(ctx context.Context, tc TurnConfig, in agentcore. // here, but production never populated TurnHistory, so this recovery saw prior // turns only and fabricated from stale context — #1117. The loop's own message // slice is the single source of truth; TurnConfig no longer duplicates it.) +// NO in.GuardStep HERE, AND THAT IS DELIBERATE — unlike +// streamLeakedToolCallRetry above, which needs it. Recording the reasoning +// because the asymmetry looks like an oversight and was flagged as one during +// the enterprise security audit: +// +// - The retry runs WITH tools and can therefore buy an unbounded number of +// paid completions, so it has to be held to the run's ceilings. +// - This one is a SINGLE tool-less completion, bounded by tc.MaxTokens, and it +// is still metered (RecordUsage), so it lands in the ledger like any other +// call. It also only runs on the canFinish path: a run stopped by +// ErrCostCeilingExceeded never reaches Finalize (agentcore/run.go), so this +// cannot be reached after a ceiling has already tripped mid-run. +// +// The worst case is therefore one bounded, accounted completion of overshoot +// when a ceiling is reached on the final step — which is the price of returning +// a usable answer instead of a truncated one. If this ever grows tools or a +// loop, it needs the guard. func streamForceFinalSummary(ctx context.Context, tc TurnConfig, in agentcore.FinalizeInput) (string, error) { convo := append(append([]fantasy.Message{}, in.Messages...), fantasy.NewUserMessage(interactiveForceFinalSummaryNudge)) agent := fantasy.NewAgent(tc.Model,