Skip to content

Commit dcdde06

Browse files
fix(security): make suppression fingerprints content-addressed
Keep suppressions stable across unrelated line shifts while preserving distinct identities for duplicate findings. Migrate exact legacy singleton suppressions through a validated local alias map and keep report, health, diff, and import behavior compatible. Co-Authored-By: Codex <codex@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 55da125 commit dcdde06

8 files changed

Lines changed: 1969 additions & 75 deletions

File tree

docs/security.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ Supported fields:
3636
- `fail_on`: `none`, `low`, `medium`, `high`, or `critical`.
3737
- `include_templates`: whether public template files are scanned.
3838
- `enabled_checks`: any of `automation`, `mcp`, `permissions`, `prompt-injection`, `secrets`, and `supply-chain`.
39-
- `include_paths` and `exclude_paths`: relative path prefixes.
39+
- `include_paths`: relative path prefixes. Bracketed Next.js-style segments such as `app/[id]` are matched literally, not as globs.
40+
- `exclude_paths`: relative path prefixes. A trailing `/**` excludes the prefix and all descendants (for example `.brigade/**` covers `.brigade/security.toml` and nested files).
4041
- `severity_threshold`: minimum severity retained in reports.
4142
- `output_path`: relative path for the latest local evidence bundle.
4243
- `[suppressions]` and `[suppression_reasons]`: reviewed finding fingerprints and reasons.
@@ -56,7 +57,17 @@ brigade security unsuppress <finding-id-or-fingerprint>
5657
brigade security doctor
5758
```
5859

59-
Findings include stable `id`, `fingerprint`, `rule_id`, `severity`, `category`, `path`, `line`, `safe_excerpt`, `remediation_hint`, and optional `response_options` fields. Secret-looking values are redacted before JSON reports, Markdown reports, SARIF, work imports, docs, or session artifacts are written.
60+
Findings include stable `id`, `fingerprint`, `rule_id`, `severity`, `category`, `path`, `line`, `occurrence`, `safe_excerpt`, `remediation_hint`, and optional `response_options` fields. Secret-looking values are redacted before JSON reports, Markdown reports, SARIF, work imports, docs, or session artifacts are written.
61+
62+
### Finding fingerprints and suppressions
63+
64+
Finding fingerprints are content-addressed, not line-addressed. A fingerprint hashes `rule_id`, repo-relative `path`, a normalized redacted 96-character excerpt of the matched content, and a zero-based `occurrence` index for genuine duplicates of the same rule and text in one file. When more than one identical match exists in a file, the fingerprint also includes that group's `duplicate_count` so removing or adding a duplicate rekeys the remaining findings instead of transferring a suppression from a removed sibling. Singleton matches keep the pre-cardinality formula byte-for-byte unchanged. Absolute line numbers are reported for review but do not affect fingerprint identity, so suppressions survive unrelated edits above a finding.
65+
66+
Each finding also carries a `legacy_fingerprint` alias computed with the pre-upgrade line-based formula (`category`, `title`, `path`, `line`, and a 96-character redacted excerpt). Suppressions, accepted-risk closeouts, and suppression-health checks match the content-addressed fingerprint and may also match the legacy alias only when the finding is a singleton (`duplicate_count = 1`, or the field is missing on historical reports). Duplicate groups never inherit a pre-upgrade singleton suppression or accepted-risk closeout through the shared legacy alias. On the first exact legacy match for a singleton, a scan migrates the configured suppression to the primary fingerprint, writes a local legacy-to-primary map under `.brigade/security/fingerprint-migration-map.json`, and health records the primary fingerprint in an accepted-risk closeout. Review, findings, show, suppress, and unsuppress consult that map bidirectionally so an older evidence bundle that still lists only the legacy fingerprint remains manageable after migration and later line movement. `security.toml` stays canonical: legacy suppression entries are removed and only the primary fingerprint is retained; old evidence bundles are not rewritten.
67+
68+
When the same rule matches identical redacted text twice in one file, the first match uses `occurrence = 0`, the second `occurrence = 1`, and so on, and both carry `duplicate_count = 2`. Occurrence order follows ascending scan line number, which keeps duplicate strings distinct without tying identity to a single absolute line. If one duplicate is removed, the survivor's `duplicate_count` drops to `1` and its fingerprint changes, so a suppression on the removed instance does not quiet the remaining match.
69+
70+
**One-time migration edge case:** a legacy suppression or accepted-risk record for a finding that already moved before its first upgraded scan cannot be mapped automatically, because the legacy alias depends on the original line number. Re-review that finding once under its new content-addressed fingerprint. Findings that stayed at the same location keep working through the legacy alias without manual rework.
6071

6172
Secret findings include a small response playbook. Typical options are moving active credentials into a gitignored `.env` file or environment variable, scrubbing tracked files and rotating exposed values, showing the redacted finding to the operator so they can preserve the real value in KeePass before deciding, and redacting or archiving chat/session transcripts when a session log contains an exposed key.
6273

src/brigade/security_cmd/commands.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def show_config(*, target: Path, json_output: bool = False) -> int:
6060

6161
def health(target: Path, *, suppression_cache_only: bool = False) -> dict[str, Any]:
6262
target = target.expanduser().resolve()
63+
migration_map = _read_fingerprint_migration_map(target)
6364
checks: list[dict[str, Any]] = []
6465
closeouts = _read_closeouts(target)
6566
latest_closeout = closeouts[0] if closeouts else None
@@ -75,6 +76,8 @@ def health(target: Path, *, suppression_cache_only: bool = False) -> dict[str, A
7576
and latest_closeout["policy_pack"].get("accepted_risk") is True
7677
else set()
7778
)
79+
if accepted_fingerprints:
80+
accepted_fingerprints = _expand_suppression_fingerprints(accepted_fingerprints, migration_map)
7881
config_ok = True
7982
try:
8083
loaded = load_config(target)
@@ -144,11 +147,30 @@ def health(target: Path, *, suppression_cache_only: bool = False) -> dict[str, A
144147
eligible_harness_findings = [
145148
item for item in raw_harness_findings if include_templates or item.get("confidence") != "template"
146149
]
150+
if isinstance(latest_closeout, dict) and eligible_harness_findings:
151+
matched_harness_findings = [
152+
item
153+
for item in eligible_harness_findings
154+
if _finding_matches_fingerprints(item, accepted_fingerprints, migration_map=migration_map)
155+
]
156+
migrated_closeout = _migrate_closeout_fingerprints(latest_closeout, matched_harness_findings)
157+
if migrated_closeout is not None:
158+
latest_closeout = migrated_closeout
159+
accepted_fingerprints = {
160+
str(fingerprint)
161+
for fingerprint in latest_closeout.get("source_fingerprints", [])
162+
if isinstance(fingerprint, str) and fingerprint
163+
}
164+
accepted_fingerprints = _expand_suppression_fingerprints(accepted_fingerprints, migration_map)
147165
active_harness_findings = [
148-
item for item in eligible_harness_findings if str(item.get("fingerprint") or "") not in accepted_fingerprints
166+
item
167+
for item in eligible_harness_findings
168+
if not _finding_matches_fingerprints(item, accepted_fingerprints, migration_map=migration_map)
149169
]
150170
quieted_harness_findings = [
151-
item for item in eligible_harness_findings if str(item.get("fingerprint") or "") in accepted_fingerprints
171+
item
172+
for item in eligible_harness_findings
173+
if _finding_matches_fingerprints(item, accepted_fingerprints, migration_map=migration_map)
152174
]
153175
harness_wiring = {
154176
**raw_harness_wiring,
@@ -233,10 +255,24 @@ def health(target: Path, *, suppression_cache_only: bool = False) -> dict[str, A
233255
except (OSError, ValueError, json.JSONDecodeError):
234256
raw_open_findings = []
235257
quieted_findings = [
236-
item for item in raw_open_findings if str(item.get("fingerprint") or "") in accepted_fingerprints
258+
item
259+
for item in raw_open_findings
260+
if _finding_matches_fingerprints(item, accepted_fingerprints, migration_map=migration_map)
237261
]
262+
if isinstance(latest_closeout, dict) and quieted_findings:
263+
migrated_closeout = _migrate_closeout_fingerprints(latest_closeout, quieted_findings)
264+
if migrated_closeout is not None:
265+
latest_closeout = migrated_closeout
266+
accepted_fingerprints = {
267+
str(fingerprint)
268+
for fingerprint in latest_closeout.get("source_fingerprints", [])
269+
if isinstance(fingerprint, str) and fingerprint
270+
}
271+
accepted_fingerprints = _expand_suppression_fingerprints(accepted_fingerprints, migration_map)
238272
records = [
239-
item for item in raw_open_findings if str(item.get("fingerprint") or "") not in accepted_fingerprints
273+
item
274+
for item in raw_open_findings
275+
if not _finding_matches_fingerprints(item, accepted_fingerprints, migration_map=migration_map)
240276
]
241277
if records:
242278
top_finding = records[0]
@@ -411,6 +447,22 @@ def scan(
411447
exclude_paths=effective.exclude_paths,
412448
severity_threshold=effective.severity_threshold,
413449
)
450+
suppression_migrations: list[dict[str, str]] = []
451+
if effective.config_loaded:
452+
loaded = load_config(target)
453+
if loaded is not None:
454+
migrated_config, suppression_migrations = _migrate_legacy_suppressions(loaded, report)
455+
if suppression_migrations:
456+
if _merge_fingerprint_migration_map(target, suppression_migrations):
457+
write_config(target, migrated_config)
458+
effective = _effective_policy(
459+
target,
460+
policy=policy,
461+
fail_on=fail_on,
462+
include_templates=include_templates,
463+
)
464+
else:
465+
suppression_migrations = []
414466
report["policy"] = effective.policy
415467
report["scan_profile"] = effective.scan_profile
416468
report["fail_on"] = effective.fail_on
@@ -422,6 +474,8 @@ def scan(
422474
report["config"] = str(effective.config_path)
423475
report["config_loaded"] = effective.config_loaded
424476
report["generated_at"] = _utc_iso()
477+
if suppression_migrations:
478+
report["suppression_migrations"] = suppression_migrations
425479
_write_suppression_health_cache_from_report(target, effective, report)
426480
configured_output_dir = target / effective.output_path
427481
requested_output_dir = output_dir

src/brigade/security_cmd/models.py

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@
4141
SUPPRESSION_HEALTH_CACHE_REL_PATH = ".brigade/security/suppression-health-cache.json"
4242

4343

44+
FINGERPRINT_MIGRATION_MAP_REL_PATH = ".brigade/security/fingerprint-migration-map.json"
45+
46+
47+
FINGERPRINT_MIGRATION_MAP_SCHEMA = "brigade.security.fingerprint-migration-map.v1"
48+
49+
4450
SUPPRESSION_HEALTH_CACHE_VERSION = 1
4551

4652

@@ -373,21 +379,133 @@ def suppression_health_cache_path(target: Path) -> Path:
373379
return target / SUPPRESSION_HEALTH_CACHE_REL_PATH
374380

375381

382+
def fingerprint_migration_map_path(target: Path) -> Path:
383+
return target / FINGERPRINT_MIGRATION_MAP_REL_PATH
384+
385+
386+
def _workspace_state_path_is_safe(target: Path, path: Path) -> bool:
387+
target_resolved = target.expanduser().resolve()
388+
try:
389+
relative = path.relative_to(target_resolved)
390+
except ValueError:
391+
return False
392+
candidate = target_resolved
393+
for part in relative.parts:
394+
candidate /= part
395+
if candidate.is_symlink():
396+
return False
397+
try:
398+
path.resolve(strict=False).relative_to(target_resolved)
399+
except (OSError, ValueError):
400+
return False
401+
return True
402+
403+
404+
def _closeout_path_is_symlink(path: Path) -> bool:
405+
return path.is_symlink()
406+
407+
408+
def _closeout_path_contained_in(path: Path, root: Path) -> bool:
409+
try:
410+
path.resolve().relative_to(root.resolve())
411+
except (OSError, ValueError):
412+
return False
413+
return True
414+
415+
376416
def _closeouts_root(target: Path) -> Path:
377417
return target / ".brigade" / "security" / "closeouts"
378418

379419

420+
def _read_fingerprint_migration_map(target: Path) -> dict[str, str]:
421+
target_resolved = target.expanduser().resolve()
422+
path = fingerprint_migration_map_path(target_resolved)
423+
if not _workspace_state_path_is_safe(target_resolved, path):
424+
return {}
425+
payload = localio.read_json_dict(path)
426+
if payload is None:
427+
return {}
428+
if payload.get("schema") != FINGERPRINT_MIGRATION_MAP_SCHEMA:
429+
return {}
430+
raw_migrations = payload.get("migrations")
431+
if not isinstance(raw_migrations, dict):
432+
return {}
433+
migrations: dict[str, str] = {}
434+
for legacy, primary in raw_migrations.items():
435+
if not isinstance(legacy, str) or not isinstance(primary, str):
436+
continue
437+
legacy = legacy.strip()
438+
primary = primary.strip()
439+
if FINGERPRINT_RE.fullmatch(legacy) and FINGERPRINT_RE.fullmatch(primary):
440+
migrations[legacy] = primary
441+
return migrations
442+
443+
444+
def _write_fingerprint_migration_map(target: Path, migrations: dict[str, str]) -> bool:
445+
if not migrations:
446+
return True
447+
target_resolved = target.expanduser().resolve()
448+
path = fingerprint_migration_map_path(target_resolved)
449+
if not _workspace_state_path_is_safe(target_resolved, path):
450+
return False
451+
_write_json(
452+
path,
453+
{
454+
"schema": FINGERPRINT_MIGRATION_MAP_SCHEMA,
455+
"migrations": dict(sorted(migrations.items())),
456+
},
457+
)
458+
return True
459+
460+
461+
def _merge_fingerprint_migration_map(target: Path, entries: list[dict[str, str]]) -> bool:
462+
merged = _read_fingerprint_migration_map(target)
463+
changed = False
464+
for entry in entries:
465+
legacy = str(entry.get("from") or "").strip()
466+
primary = str(entry.get("to") or "").strip()
467+
if not legacy or not primary:
468+
continue
469+
if merged.get(legacy) == primary:
470+
continue
471+
merged[legacy] = primary
472+
changed = True
473+
if changed:
474+
return _write_fingerprint_migration_map(target, merged)
475+
return True
476+
477+
380478
def _read_closeouts(target: Path) -> list[dict[str, Any]]:
381-
root = _closeouts_root(target.expanduser().resolve())
479+
target_resolved = target.expanduser().resolve()
480+
root = _closeouts_root(target_resolved)
382481
receipts: list[dict[str, Any]] = []
383-
if not root.is_dir():
482+
if not root.is_dir() or _closeout_path_is_symlink(root):
483+
return receipts
484+
if not _closeout_path_contained_in(root, target_resolved):
384485
return receipts
385-
for path in sorted(root.glob("*/closeout.json")):
386-
payload = _read_json(path)
486+
try:
487+
root_resolved = root.resolve()
488+
except OSError:
489+
return receipts
490+
for closeout_dir in sorted(item for item in root.iterdir() if item.is_dir()):
491+
if _closeout_path_is_symlink(closeout_dir):
492+
continue
493+
if not _closeout_path_contained_in(closeout_dir, root_resolved):
494+
continue
495+
closeout_json = closeout_dir / "closeout.json"
496+
if not closeout_json.is_file() or _closeout_path_is_symlink(closeout_json):
497+
continue
498+
try:
499+
trusted_path = closeout_json.resolve()
500+
except OSError:
501+
continue
502+
if not _closeout_path_contained_in(trusted_path, root_resolved):
503+
continue
504+
payload = _read_json(trusted_path)
387505
if payload is None:
388506
continue
389-
payload.setdefault("closeout_id", path.parent.name)
390-
payload.setdefault("path", str(path))
507+
payload.setdefault("closeout_id", closeout_dir.name)
508+
payload["path"] = str(trusted_path)
391509
receipts.append(payload)
392510
return sorted(receipts, key=lambda item: str(item.get("created_at") or item.get("closeout_id") or ""), reverse=True)
393511

0 commit comments

Comments
 (0)