From 87af52018c2234ad4fad678957888e6cae7264f4 Mon Sep 17 00:00:00 2001 From: rafter/crew/vera Date: Tue, 8 Sep 2026 11:15:29 -0700 Subject: [PATCH 1/3] docs: say what the hosted scanner actually does with .rafter.yml ignore rules (sable-u5ij) CLI_SPEC promised that R-XXXXX ids are honoured remotely and that remote runs emit suppressed.json; the production backend did neither until securable-backend sb-u14j (same day), and it never suppresses must-fix or secret-scanner findings, which no doc said. A customer read the spec, wrote a correct file, and concluded the scanner ignores .rafter.yml (ra-mw1). - Rule selectors: native id, hashed R-XXXXX display id, or title; unmatched selectors are listed in suppressed.json. - Key spelling: camelCase and snake_case both accepted on every engine. - Hosted-scanner invariant: must-fix / secret-scanner / unclassified critical-high findings are never suppressed by repo config; such matches land in suppressed.json under `blocked`. Suppression is project-owned and a separate surface from commandPolicy (sable-nz4y bounds that, not this). - suppressed.json: where it lives, its `_suppressed` / `blocked` / `unmatched` / `applied` / `error` fields. - suppress_finding tool text (Python and Node) says the same. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CKZyh4yEZBJnrjmoyPeSpG --- node/src/commands/mcp/server.ts | 2 +- python/rafter_cli/commands/mcp_server.py | 7 +++++-- shared-docs/CLI_SPEC.md | 14 ++++++++++++-- shared-docs/CONFIG.md | 8 ++++++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/node/src/commands/mcp/server.ts b/node/src/commands/mcp/server.ts index 48ffd24..d82978b 100644 --- a/node/src/commands/mcp/server.ts +++ b/node/src/commands/mcp/server.ts @@ -152,7 +152,7 @@ export function createServer(): Server { rules: { type: "array", items: { type: "string" }, - description: "Specific rules to suppress, matched case-insensitively against a finding's rule name OR rule id — e.g. 'AWS Access Key' (local pattern name) or 'R-6D5E2' (remote SAST/SCA rule id). Omit to suppress all rules for the path. Honored by both local scans and remote `rafter run`.", + description: "Specific rules to suppress, matched case-insensitively against a finding's rule name OR rule id — e.g. 'AWS Access Key' (local pattern name) or 'R-6D5E2' (the hashed id the hosted report shows; the native rule id works too). Omit to suppress all rules for the path. Honored by both local scans and remote `rafter run`; the hosted scanner never suppresses must-fix or secret-scanner findings and lists such matches in suppressed.json under `blocked`.", }, reason: { type: "string", description: "Why this is a false positive — persisted with the rule. Strongly recommended." }, }, diff --git a/python/rafter_cli/commands/mcp_server.py b/python/rafter_cli/commands/mcp_server.py index 9ba7fb3..87fd3d5 100644 --- a/python/rafter_cli/commands/mcp_server.py +++ b/python/rafter_cli/commands/mcp_server.py @@ -373,8 +373,11 @@ def suppress_finding( path: File path or glob to suppress findings in (e.g. 'test/fixtures/**'). rules: Specific rules to suppress, matched case-insensitively against a finding's rule name OR rule id — e.g. 'AWS Access Key' (local pattern - name) or 'R-6D5E2' (remote SAST/SCA rule id). Omit to suppress all - rules for the path. Honored by both local scans and remote `rafter run`. + name) or 'R-6D5E2' (the hashed id the hosted report shows; the native + rule id works too). Omit to suppress all rules for the path. Honored by + both local scans and remote `rafter run`; the hosted scanner never + suppresses must-fix or secret-scanner findings and lists such matches + in suppressed.json under `blocked`. reason: Why this is a false positive — persisted with the rule. Strongly recommended. """ return json.dumps(handle_suppress_finding(path, rules, reason)) diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index ba6e070..fb66b8d 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -532,7 +532,13 @@ When `.rafter.yml` `ignore:` rules (or `.rafterignore`) hide one or more finding Exit code is unaffected by suppression — exit `1` is returned only when at least one *non-suppressed* finding remains. -Remote `rafter run` emits the same suppression data as a separate `suppressed.json` artifact (alongside `findings.json`, which is unaffected), using this identical per-entry shape; its `source` is `".rafter/config.yml"` (the backend's config filename). So a finding hidden by an `ignore` rule is recoverable whether the scan ran locally or remotely. +Remote `rafter run` (the hosted scanner) writes the same data as a separate `suppressed.json` artifact beside `findings.json` (`scan-results/{org}/{scan}/{mode}/suppressed.json`, written whenever a config file was present), with this per-entry shape under `_suppressed`; `source` is the repo-relative config file it actually read (`.rafter.yml`, `.rafter/config.yml`, …). It carries three things a local scan does not need: + +- `blocked` — findings an ignore rule matched but the hosted scanner kept anyway, each with `blocked_by`: `must-fix`, `secret-scanner`, or `unclassified-high`. The hosted scanner never lets repo config hide those (see *Hosted-scanner invariant* under `ignore:` below). +- `unmatched` — every `paths`/`rules` selector that matched no finding. This is the usual answer to "my config is ignored": a hashed id from a different scan, a typo, or a glob that does not reach the file. +- `applied` / `error` — `applied: false` with the parser's message when the file was rejected; findings are then reported unfiltered. + +So a finding hidden by an `ignore` rule is recoverable whether the scan ran locally or remotely, and a rule that hid nothing says why. ### rafter agent exec COMMAND [OPTIONS] @@ -1354,7 +1360,11 @@ These rules are honored identically by the **local** CLI engines (Node and Pytho - A relative glob (no leading `/`, not starting with `**`) is auto-anchored to match **anywhere** along the absolute scan path, so `tests/fixtures/**` matches `/abs/project/tests/fixtures/foo`. - Path matching is case-sensitive. -*Rule selectors (`rules:`)* — each entry matches a finding when it equals (case-insensitively) **either** the finding's rule **name/title** (e.g. `AWS Access Key`) **or** its **rule id** (e.g. `R-6D5E2` / `rules.autogrep.json.vuln-…`). Use the name for local pattern findings and the id for remote SAST/SCA findings. Non-existent selectors are harmless (they just never match). +*Rule selectors (`rules:`)* — each entry matches a finding when it equals (case-insensitively) **either** the finding's rule **name/title** (e.g. `AWS Access Key`) **or** its **rule id** — the hashed `R-XXXXX` id the hosted report and PR comment show (e.g. `R-6D5E2`) or the scanner-native id (`rules.autogrep.json.vuln-…`); the hosted scanner accepts all three. Use the name for local pattern findings and either id for remote SAST/SCA findings. Non-existent selectors are harmless (they just never match); on a remote scan they are listed in `suppressed.json` under `unmatched`. + +*Key spelling* — every engine accepts the camelCase keys shown here and the hosted scanner's snake_case (`exclude_paths`) alike; if a file carries both, snake_case wins on the hosted scanner. + +*Hosted-scanner invariant* — the remote backend never suppresses a **must-fix**, **secret-scanner**, or unclassified **critical/high** finding, whatever `ignore:` says: the match is recorded in `suppressed.json` under `blocked` with `blocked_by`, and the finding stays in the report. Local engines apply no such floor. Suppression is the project's decision on both sides; it is a separate surface from `commandPolicy`, which the global config may bound. --- diff --git a/shared-docs/CONFIG.md b/shared-docs/CONFIG.md index 699bf39..5dbe5a6 100644 --- a/shared-docs/CONFIG.md +++ b/shared-docs/CONFIG.md @@ -60,8 +60,12 @@ docs: [ ... ] # repo security docs (see CLI_SPEC) ``` Backend-compatibility: top-level `exclude_paths:` / `custom_patterns:` (the flat -shape the cloud scanner reads from `.rafter/config.yml`) are also accepted; nested -`scan.*` wins on collision. Keys accept either `camelCase` or `snake_case`. +snake_case shape the hosted scanner also reads — it looks for `.rafter.yml` first, +then `.rafter/config.yml`) are also accepted; nested `scan.*` wins on collision. +Keys accept either `camelCase` or `snake_case` on every engine. The hosted scanner +honours `scan.excludePaths` and `ignore:` but never suppresses a must-fix or +secret-scanner finding; what it hid, kept, or could not match is written to +`suppressed.json` beside the scan's `findings.json` (see CLI_SPEC, *Ignore rules*). > `.rafter.yml` does **not** contain `environments`, `components`, `outputFiltering`, > `skills`, `notifications`, or `hooks` — those are global-only (by design for `hooks`). From 8643f545dd67409a204f3fc31a2e38a1e6283137 Mon Sep 17 00:00:00 2001 From: rafter/crew/vera Date: Tue, 8 Sep 2026 12:20:56 -0700 Subject: [PATCH 2/3] docs: reason is required on hosted scans; protected findings suppressible with one (sb-d5ld = B) Rome's decision on sb-d5ld: the hosted scanner allows an ignore rule to hide must-fix and secret-scanner findings on all scans, provided the rule carries a non-empty reason; a missing or blank reason is a schema error that rejects the whole .rafter.yml. Every protected suppression is listed at the top of suppressed.json. exclude_paths cannot cross that floor (no reason slot). Spec, CONFIG.md and the suppress_finding tool text (Python + Node) now say so, replacing the "never suppresses" wording from the previous commit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CKZyh4yEZBJnrjmoyPeSpG --- node/src/commands/mcp/server.ts | 2 +- python/rafter_cli/commands/mcp_server.py | 10 ++++++---- shared-docs/CLI_SPEC.md | 15 +++++++++------ shared-docs/CONFIG.md | 11 +++++++---- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/node/src/commands/mcp/server.ts b/node/src/commands/mcp/server.ts index d82978b..56442e7 100644 --- a/node/src/commands/mcp/server.ts +++ b/node/src/commands/mcp/server.ts @@ -152,7 +152,7 @@ export function createServer(): Server { rules: { type: "array", items: { type: "string" }, - description: "Specific rules to suppress, matched case-insensitively against a finding's rule name OR rule id — e.g. 'AWS Access Key' (local pattern name) or 'R-6D5E2' (the hashed id the hosted report shows; the native rule id works too). Omit to suppress all rules for the path. Honored by both local scans and remote `rafter run`; the hosted scanner never suppresses must-fix or secret-scanner findings and lists such matches in suppressed.json under `blocked`.", + description: "Specific rules to suppress, matched case-insensitively against a finding's rule name OR rule id — e.g. 'AWS Access Key' (local pattern name) or 'R-6D5E2' (the hashed id the hosted report shows; the native rule id works too). Omit to suppress all rules for the path. Honored by both local scans and remote `rafter run`. With a reason, the hosted scanner will hide even a must-fix or secret-scanner finding and lists it prominently in suppressed.json under `protected_suppressions`. A reason is REQUIRED by the hosted scanner: a rule without one rejects the whole .rafter.yml on remote scans.", }, reason: { type: "string", description: "Why this is a false positive — persisted with the rule. Strongly recommended." }, }, diff --git a/python/rafter_cli/commands/mcp_server.py b/python/rafter_cli/commands/mcp_server.py index 87fd3d5..f553f72 100644 --- a/python/rafter_cli/commands/mcp_server.py +++ b/python/rafter_cli/commands/mcp_server.py @@ -375,10 +375,12 @@ def suppress_finding( finding's rule name OR rule id — e.g. 'AWS Access Key' (local pattern name) or 'R-6D5E2' (the hashed id the hosted report shows; the native rule id works too). Omit to suppress all rules for the path. Honored by - both local scans and remote `rafter run`; the hosted scanner never - suppresses must-fix or secret-scanner findings and lists such matches - in suppressed.json under `blocked`. - reason: Why this is a false positive — persisted with the rule. Strongly recommended. + both local scans and remote `rafter run`. With a reason, the hosted + scanner will hide even a must-fix or secret-scanner finding and lists + it prominently in suppressed.json under `protected_suppressions`. + reason: Why this is a false positive — persisted with the rule. REQUIRED by + the hosted scanner: a rule without one rejects the whole .rafter.yml + on remote scans, so always give one. """ return json.dumps(handle_suppress_finding(path, rules, reason)) diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index fb66b8d..2493f19 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -534,9 +534,10 @@ Exit code is unaffected by suppression — exit `1` is returned only when at lea Remote `rafter run` (the hosted scanner) writes the same data as a separate `suppressed.json` artifact beside `findings.json` (`scan-results/{org}/{scan}/{mode}/suppressed.json`, written whenever a config file was present), with this per-entry shape under `_suppressed`; `source` is the repo-relative config file it actually read (`.rafter.yml`, `.rafter/config.yml`, …). It carries three things a local scan does not need: -- `blocked` — findings an ignore rule matched but the hosted scanner kept anyway, each with `blocked_by`: `must-fix`, `secret-scanner`, or `unclassified-high`. The hosted scanner never lets repo config hide those (see *Hosted-scanner invariant* under `ignore:` below). +- `protected_suppressions` (and `protected_suppressed`, its count) — every must-fix, secret-scanner or unclassified critical/high finding an `ignore:` rule hid, with its `reason`. Each also appears in `_suppressed` with `protected` set. Read this list first (see *Protected findings* under `ignore:` below). +- `blocked` — findings `excludePaths` matched but the hosted scanner kept, each with `blocked_by` (`must-fix`, `secret-scanner`, `unclassified-high`) and a `hint`: a path exclusion carries no reason, so it cannot hide a protected finding; an ignore rule can. - `unmatched` — every `paths`/`rules` selector that matched no finding. This is the usual answer to "my config is ignored": a hashed id from a different scan, a typo, or a glob that does not reach the file. -- `applied` / `error` — `applied: false` with the parser's message when the file was rejected; findings are then reported unfiltered. +- `applied` / `error` — `applied: false` with the parser's message when the file was rejected (a missing `reason:` is one such error); findings are then reported unfiltered. So a finding hidden by an `ignore` rule is recoverable whether the scan ran locally or remotely, and a rule that hid nothing says why. @@ -1126,7 +1127,7 @@ Start MCP server over stdio transport. Exposes 11 tools and 3 resources. "allowed": true, "risk_level": "low", "requires_approval": false, - "reason": "optional explanation string" + "reason": "why this is a false positive — required by the hosted scanner" } ``` @@ -1145,7 +1146,7 @@ Start MCP server over stdio transport. Exposes 11 tools and 3 resources. **`suppress_finding` inputs:** - `path` (required, string) — file path or glob to suppress findings in (e.g. `test/fixtures/**`) - `rules` (optional, string[]) — specific rule/pattern names to suppress (e.g. `["AWS Access Key"]`); omit to suppress all rules for the path -- `reason` (optional, string) — why this is a false positive; persisted with the rule and surfaced in `_suppressed` output +- `reason` (string; **required by the hosted scanner**, optional locally) — why this is a false positive; persisted with the rule and surfaced in `_suppressed` output. A rule written without one is rejected, with the whole `.rafter.yml`, on the next remote scan — always give one. **`suppress_finding` output schema:** `{ ok, file, action, entry, suppression_count }` where `action` is `"created"` (new `.rafter.yml` written), `"appended"` (rule added to an existing file), or `"updated"` (an existing rule with the same path+rules scope had its reason refreshed). `entry` is the persisted ignore rule `{ paths, rules?, reason? }`. The tool resolves the existing policy file via the loader's precedence; if none exists it creates a canonical `.rafter.yml` at the git root. It never appends a duplicate rule for the same path+rules scope. @@ -1348,7 +1349,7 @@ Precedence: policy file overrides `~/.rafter/config.json`. Arrays replace, not a **URL caching:** URL-backed docs are cached at `~/.rafter/docs-cache/` keyed by `sha256(url)[:32]`. Default TTL is 86400 seconds. On network failure, a stale cached copy is served and a warning is printed. `docs list` never fetches; `docs show` fetches on miss/expired or when `--refresh` is set. -**Ignore rules (`ignore:`):** suppress findings without removing them from the audit trail. Each entry needs `paths:` (a non-empty list of globs); `rules:` is optional (omitting it suppresses every rule on the matched paths) and `reason:` is surfaced verbatim in the JSON `_suppressed` output. First entry that matches wins, so put more specific entries earlier. +**Ignore rules (`ignore:`):** suppress findings without removing them from the audit trail. Each entry needs `paths:` (a non-empty list of globs) and, on the hosted scanner, a non-empty `reason:` (local engines accept a rule without one; write one anyway — the same file is read by both); `rules:` is optional (omitting it suppresses every rule on the matched paths) and `reason:` is surfaced verbatim in the JSON `_suppressed` output. First entry that matches wins, so put more specific entries earlier. These rules are honored identically by the **local** CLI engines (Node and Python) and by the **remote `rafter run`** backend — they read the same `.rafter.yml` (and `.rafter/config.yml`) `ignore:` block. The matching contract is fixed and the same on every engine: @@ -1364,7 +1365,9 @@ These rules are honored identically by the **local** CLI engines (Node and Pytho *Key spelling* — every engine accepts the camelCase keys shown here and the hosted scanner's snake_case (`exclude_paths`) alike; if a file carries both, snake_case wins on the hosted scanner. -*Hosted-scanner invariant* — the remote backend never suppresses a **must-fix**, **secret-scanner**, or unclassified **critical/high** finding, whatever `ignore:` says: the match is recorded in `suppressed.json` under `blocked` with `blocked_by`, and the finding stays in the report. Local engines apply no such floor. Suppression is the project's decision on both sides; it is a separate surface from `commandPolicy`, which the global config may bound. +*`reason:` is required on the hosted scanner* — every `ignore:` entry must carry a non-empty `reason`. A missing, null or blank reason is a **schema error**: the whole `.rafter.yml` is rejected, the scan reports every finding unfiltered, and `suppressed.json` says so (`applied: false`, `error` names the entry, e.g. `ignore.1.reason`). Local engines accept a rule without a reason, so write one always. + +*Protected findings* — an `ignore:` rule may hide a **must-fix**, **secret-scanner**, or unclassified **critical/high** finding on any scan (default branch or PR head alike); the reason is the price. Every such suppression is written to `suppressed.json` twice — in `_suppressed` with `protected` set to `must-fix`, `secret-scanner` or `unclassified-high`, and in the top-level `protected_suppressions` list with a count — so it cannot go unnoticed. The accepted trade (decision sb-d5ld, 2026-09-08): a committer can hide a planted secret in the same PR that suppresses it; it is visible in the diff and in the audit trail. `scan.excludePaths` cannot hide a protected finding, because it carries no reason: such matches are listed under `blocked` with a hint to use an ignore rule. Suppression is the project's decision on both sides; it is a separate surface from `commandPolicy`, which the global config may bound. --- diff --git a/shared-docs/CONFIG.md b/shared-docs/CONFIG.md index 5dbe5a6..8c72b42 100644 --- a/shared-docs/CONFIG.md +++ b/shared-docs/CONFIG.md @@ -51,7 +51,7 @@ scan: ignore: # suppress findings (top-level, NOT under scan:) - paths: ["tests/fixtures/**"] rules: ["AWS Access Key ID"] # omit to suppress all rules for those paths - reason: "test fixtures, not real keys" + reason: "test fixtures, not real keys" # required by the hosted scanner audit: retentionDays: 30 logLevel: info # debug | info | warn | error @@ -63,9 +63,12 @@ Backend-compatibility: top-level `exclude_paths:` / `custom_patterns:` (the flat snake_case shape the hosted scanner also reads — it looks for `.rafter.yml` first, then `.rafter/config.yml`) are also accepted; nested `scan.*` wins on collision. Keys accept either `camelCase` or `snake_case` on every engine. The hosted scanner -honours `scan.excludePaths` and `ignore:` but never suppresses a must-fix or -secret-scanner finding; what it hid, kept, or could not match is written to -`suppressed.json` beside the scan's `findings.json` (see CLI_SPEC, *Ignore rules*). +honours `scan.excludePaths` and `ignore:`, and requires a non-empty `reason:` on +every `ignore:` entry (a rule without one rejects the whole file). With a reason an +ignore rule may hide even a must-fix or secret-scanner finding; every such +suppression, everything else hidden, and every selector that matched nothing is +written to `suppressed.json` beside the scan's `findings.json` (see CLI_SPEC, +*Ignore rules*). > `.rafter.yml` does **not** contain `environments`, `components`, `outputFiltering`, > `skills`, `notifications`, or `hooks` — those are global-only (by design for `hooks`). From 39d998eed1e759a149eb9900f98db693b57b3eec Mon Sep 17 00:00:00 2001 From: rafter/crew/vera Date: Tue, 8 Sep 2026 12:28:02 -0700 Subject: [PATCH 3/3] docs: reason is required only to hide a protected finding; reason-less rules keep working (sb-d5ld, delta B) Grandfathered per the mayor's call: a rule without a reason still hides ordinary findings on the hosted scanner and is held at the floor for must-fix / secret-scanner ones (listed under `blocked` with a hint), rather than rejecting the whole file. Spec, CONFIG.md and the suppress_finding tool text say so. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CKZyh4yEZBJnrjmoyPeSpG --- node/src/commands/mcp/server.ts | 2 +- python/rafter_cli/commands/mcp_server.py | 6 +++--- shared-docs/CLI_SPEC.md | 14 ++++++-------- shared-docs/CONFIG.md | 14 +++++++------- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/node/src/commands/mcp/server.ts b/node/src/commands/mcp/server.ts index 56442e7..c1fbac6 100644 --- a/node/src/commands/mcp/server.ts +++ b/node/src/commands/mcp/server.ts @@ -152,7 +152,7 @@ export function createServer(): Server { rules: { type: "array", items: { type: "string" }, - description: "Specific rules to suppress, matched case-insensitively against a finding's rule name OR rule id — e.g. 'AWS Access Key' (local pattern name) or 'R-6D5E2' (the hashed id the hosted report shows; the native rule id works too). Omit to suppress all rules for the path. Honored by both local scans and remote `rafter run`. With a reason, the hosted scanner will hide even a must-fix or secret-scanner finding and lists it prominently in suppressed.json under `protected_suppressions`. A reason is REQUIRED by the hosted scanner: a rule without one rejects the whole .rafter.yml on remote scans.", + description: "Specific rules to suppress, matched case-insensitively against a finding's rule name OR rule id — e.g. 'AWS Access Key' (local pattern name) or 'R-6D5E2' (the hashed id the hosted report shows; the native rule id works too). Omit to suppress all rules for the path. Honored by both local scans and remote `rafter run`. With a reason, the hosted scanner will hide even a must-fix or secret-scanner finding and lists it prominently in suppressed.json under `protected_suppressions`. Without a reason the rule still hides ordinary findings but is held at the floor for must-fix and secret-scanner ones, so always give one.", }, reason: { type: "string", description: "Why this is a false positive — persisted with the rule. Strongly recommended." }, }, diff --git a/python/rafter_cli/commands/mcp_server.py b/python/rafter_cli/commands/mcp_server.py index f553f72..4424d6f 100644 --- a/python/rafter_cli/commands/mcp_server.py +++ b/python/rafter_cli/commands/mcp_server.py @@ -378,9 +378,9 @@ def suppress_finding( both local scans and remote `rafter run`. With a reason, the hosted scanner will hide even a must-fix or secret-scanner finding and lists it prominently in suppressed.json under `protected_suppressions`. - reason: Why this is a false positive — persisted with the rule. REQUIRED by - the hosted scanner: a rule without one rejects the whole .rafter.yml - on remote scans, so always give one. + reason: Why this is a false positive — persisted with the rule. Required + for the hosted scanner to hide a must-fix or secret-scanner finding + (a rule without one is held at the floor for those), so always give one. """ return json.dumps(handle_suppress_finding(path, rules, reason)) diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index 2493f19..ac677c2 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -535,9 +535,9 @@ Exit code is unaffected by suppression — exit `1` is returned only when at lea Remote `rafter run` (the hosted scanner) writes the same data as a separate `suppressed.json` artifact beside `findings.json` (`scan-results/{org}/{scan}/{mode}/suppressed.json`, written whenever a config file was present), with this per-entry shape under `_suppressed`; `source` is the repo-relative config file it actually read (`.rafter.yml`, `.rafter/config.yml`, …). It carries three things a local scan does not need: - `protected_suppressions` (and `protected_suppressed`, its count) — every must-fix, secret-scanner or unclassified critical/high finding an `ignore:` rule hid, with its `reason`. Each also appears in `_suppressed` with `protected` set. Read this list first (see *Protected findings* under `ignore:` below). -- `blocked` — findings `excludePaths` matched but the hosted scanner kept, each with `blocked_by` (`must-fix`, `secret-scanner`, `unclassified-high`) and a `hint`: a path exclusion carries no reason, so it cannot hide a protected finding; an ignore rule can. +- `blocked` — protected findings a rule matched but the hosted scanner kept, each with `blocked_by` (`must-fix`, `secret-scanner`, `unclassified-high`), `matched_by` (`exclude_paths`, or `ignore` for an ignore rule with no `reason`) and a `hint`: only an ignore rule that states a reason can hide a protected finding. - `unmatched` — every `paths`/`rules` selector that matched no finding. This is the usual answer to "my config is ignored": a hashed id from a different scan, a typo, or a glob that does not reach the file. -- `applied` / `error` — `applied: false` with the parser's message when the file was rejected (a missing `reason:` is one such error); findings are then reported unfiltered. +- `applied` / `error` — `applied: false` with the parser's message when the file was rejected; findings are then reported unfiltered. So a finding hidden by an `ignore` rule is recoverable whether the scan ran locally or remotely, and a rule that hid nothing says why. @@ -1127,7 +1127,7 @@ Start MCP server over stdio transport. Exposes 11 tools and 3 resources. "allowed": true, "risk_level": "low", "requires_approval": false, - "reason": "why this is a false positive — required by the hosted scanner" + "reason": "why this is a false positive — required to suppress a must-fix or secret finding remotely" } ``` @@ -1146,7 +1146,7 @@ Start MCP server over stdio transport. Exposes 11 tools and 3 resources. **`suppress_finding` inputs:** - `path` (required, string) — file path or glob to suppress findings in (e.g. `test/fixtures/**`) - `rules` (optional, string[]) — specific rule/pattern names to suppress (e.g. `["AWS Access Key"]`); omit to suppress all rules for the path -- `reason` (string; **required by the hosted scanner**, optional locally) — why this is a false positive; persisted with the rule and surfaced in `_suppressed` output. A rule written without one is rejected, with the whole `.rafter.yml`, on the next remote scan — always give one. +- `reason` (optional, string) — why this is a false positive; persisted with the rule and surfaced in `_suppressed` output. **Required to suppress a must-fix or secret-scanner finding on the hosted scanner**: a rule without one keeps hiding ordinary findings but is held at the floor for protected ones — always give one. **`suppress_finding` output schema:** `{ ok, file, action, entry, suppression_count }` where `action` is `"created"` (new `.rafter.yml` written), `"appended"` (rule added to an existing file), or `"updated"` (an existing rule with the same path+rules scope had its reason refreshed). `entry` is the persisted ignore rule `{ paths, rules?, reason? }`. The tool resolves the existing policy file via the loader's precedence; if none exists it creates a canonical `.rafter.yml` at the git root. It never appends a duplicate rule for the same path+rules scope. @@ -1349,7 +1349,7 @@ Precedence: policy file overrides `~/.rafter/config.json`. Arrays replace, not a **URL caching:** URL-backed docs are cached at `~/.rafter/docs-cache/` keyed by `sha256(url)[:32]`. Default TTL is 86400 seconds. On network failure, a stale cached copy is served and a warning is printed. `docs list` never fetches; `docs show` fetches on miss/expired or when `--refresh` is set. -**Ignore rules (`ignore:`):** suppress findings without removing them from the audit trail. Each entry needs `paths:` (a non-empty list of globs) and, on the hosted scanner, a non-empty `reason:` (local engines accept a rule without one; write one anyway — the same file is read by both); `rules:` is optional (omitting it suppresses every rule on the matched paths) and `reason:` is surfaced verbatim in the JSON `_suppressed` output. First entry that matches wins, so put more specific entries earlier. +**Ignore rules (`ignore:`):** suppress findings without removing them from the audit trail. Each entry needs `paths:` (a non-empty list of globs); `reason:` is optional but is what lets the hosted scanner hide a must-fix or secret-scanner finding (write one anyway — the same file is read by both engines); `rules:` is optional (omitting it suppresses every rule on the matched paths) and `reason:` is surfaced verbatim in the JSON `_suppressed` output. First entry that matches wins, so put more specific entries earlier. These rules are honored identically by the **local** CLI engines (Node and Python) and by the **remote `rafter run`** backend — they read the same `.rafter.yml` (and `.rafter/config.yml`) `ignore:` block. The matching contract is fixed and the same on every engine: @@ -1365,9 +1365,7 @@ These rules are honored identically by the **local** CLI engines (Node and Pytho *Key spelling* — every engine accepts the camelCase keys shown here and the hosted scanner's snake_case (`exclude_paths`) alike; if a file carries both, snake_case wins on the hosted scanner. -*`reason:` is required on the hosted scanner* — every `ignore:` entry must carry a non-empty `reason`. A missing, null or blank reason is a **schema error**: the whole `.rafter.yml` is rejected, the scan reports every finding unfiltered, and `suppressed.json` says so (`applied: false`, `error` names the entry, e.g. `ignore.1.reason`). Local engines accept a rule without a reason, so write one always. - -*Protected findings* — an `ignore:` rule may hide a **must-fix**, **secret-scanner**, or unclassified **critical/high** finding on any scan (default branch or PR head alike); the reason is the price. Every such suppression is written to `suppressed.json` twice — in `_suppressed` with `protected` set to `must-fix`, `secret-scanner` or `unclassified-high`, and in the top-level `protected_suppressions` list with a count — so it cannot go unnoticed. The accepted trade (decision sb-d5ld, 2026-09-08): a committer can hide a planted secret in the same PR that suppresses it; it is visible in the diff and in the audit trail. `scan.excludePaths` cannot hide a protected finding, because it carries no reason: such matches are listed under `blocked` with a hint to use an ignore rule. Suppression is the project's decision on both sides; it is a separate surface from `commandPolicy`, which the global config may bound. +*`reason:` is required to hide a protected finding* — on the hosted scanner an `ignore:` rule may hide a **must-fix**, **secret-scanner**, or unclassified **critical/high** finding on any scan (default branch or PR head alike) **only if it carries a non-empty `reason`**; the reason is the price. A rule without one (a blank reason counts as none) is not an error: it still suppresses ordinary findings, and for a protected finding it matches it is held at the floor — the finding stays in the report and `suppressed.json` lists it under `blocked` with `blocked_by` and a hint to add a reason. Every protected suppression that does go through is written to `suppressed.json` twice — in `_suppressed` with `protected` set to `must-fix`, `secret-scanner` or `unclassified-high`, and in the top-level `protected_suppressions` list with a count — so it cannot go unnoticed. The accepted trade (decision sb-d5ld, 2026-09-08): a committer can hide a planted secret in the same PR that suppresses it; it is visible in the diff and in the audit trail. `scan.excludePaths` cannot hide a protected finding at all, because it carries no reason slot: such matches are listed under `blocked` with a hint to use an ignore rule. Suppression is the project's decision on both sides; it is a separate surface from `commandPolicy`, which the global config may bound. --- diff --git a/shared-docs/CONFIG.md b/shared-docs/CONFIG.md index 8c72b42..12ff8d5 100644 --- a/shared-docs/CONFIG.md +++ b/shared-docs/CONFIG.md @@ -51,7 +51,7 @@ scan: ignore: # suppress findings (top-level, NOT under scan:) - paths: ["tests/fixtures/**"] rules: ["AWS Access Key ID"] # omit to suppress all rules for those paths - reason: "test fixtures, not real keys" # required by the hosted scanner + reason: "test fixtures, not real keys" # needed to suppress a secret/must-fix finding remotely audit: retentionDays: 30 logLevel: info # debug | info | warn | error @@ -63,12 +63,12 @@ Backend-compatibility: top-level `exclude_paths:` / `custom_patterns:` (the flat snake_case shape the hosted scanner also reads — it looks for `.rafter.yml` first, then `.rafter/config.yml`) are also accepted; nested `scan.*` wins on collision. Keys accept either `camelCase` or `snake_case` on every engine. The hosted scanner -honours `scan.excludePaths` and `ignore:`, and requires a non-empty `reason:` on -every `ignore:` entry (a rule without one rejects the whole file). With a reason an -ignore rule may hide even a must-fix or secret-scanner finding; every such -suppression, everything else hidden, and every selector that matched nothing is -written to `suppressed.json` beside the scan's `findings.json` (see CLI_SPEC, -*Ignore rules*). +honours `scan.excludePaths` and `ignore:`. An ignore rule with a non-empty +`reason:` may hide even a must-fix or secret-scanner finding; one without a reason +still hides ordinary findings but is held at the floor for protected ones. Every +protected suppression, everything else hidden, everything held, and every +selector that matched nothing is written to `suppressed.json` beside the scan's +`findings.json` (see CLI_SPEC, *Ignore rules*). > `.rafter.yml` does **not** contain `environments`, `components`, `outputFiltering`, > `skills`, `notifications`, or `hooks` — those are global-only (by design for `hooks`).