diff --git a/node/src/commands/mcp/server.ts b/node/src/commands/mcp/server.ts index 48ffd24..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' (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`. 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 9ba7fb3..4424d6f 100644 --- a/python/rafter_cli/commands/mcp_server.py +++ b/python/rafter_cli/commands/mcp_server.py @@ -373,9 +373,14 @@ 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`. - reason: Why this is a false positive — persisted with the rule. Strongly recommended. + 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`. + 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 ba6e070..ac677c2 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -532,7 +532,14 @@ 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: + +- `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` — 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; 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] @@ -1120,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 to suppress a must-fix or secret finding remotely" } ``` @@ -1139,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` (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. @@ -1342,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); `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: @@ -1354,7 +1361,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. + +*`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 699bf39..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" + reason: "test fixtures, not real keys" # needed to suppress a secret/must-fix finding remotely audit: retentionDays: 30 logLevel: info # debug | info | warn | error @@ -60,8 +60,15 @@ 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:`. 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`).