Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion node/src/commands/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
},
Expand Down
11 changes: 8 additions & 3 deletions python/rafter_cli/commands/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
21 changes: 16 additions & 5 deletions shared-docs/CLI_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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"
}
```

Expand All @@ -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.

Expand Down Expand Up @@ -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:

Expand All @@ -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.

---

Expand Down
13 changes: 10 additions & 3 deletions shared-docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`).
Expand Down
Loading