Skip to content

Commit 735e593

Browse files
fix(security): make suppression fingerprints content-addressed
Hash rule, path, normalized matched content, and duplicate occurrence instead of absolute line numbers. Document the one-time migration required for existing line-based suppressions. Co-Authored-By: Codex <codex@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 55da125 commit 735e593

4 files changed

Lines changed: 145 additions & 9 deletions

File tree

docs/security.md

Lines changed: 11 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.
40+
- `exclude_paths`: relative path prefixes or glob patterns such as `.brigade/**`.
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,15 @@ 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. Absolute line numbers are reported for review but do not affect fingerprint identity, so suppressions survive unrelated edits above a finding.
65+
66+
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. Occurrence order follows ascending scan line number, which keeps duplicate strings distinct without tying identity to a single absolute line.
67+
68+
**One-time migration from line-based fingerprints:** older releases mixed category, title, path, line, and excerpt into each fingerprint. Existing `[suppressions]` entries keyed by those fingerprints do not match the new content-addressed IDs and are not rewritten automatically. After upgrading, review and suppress each still-accepted finding once under its new fingerprint, then remove the stale entry from `.brigade/security.toml`. Later line shifts keep the new suppression intact. Old evidence bundles retain their original fingerprints and are not rewritten or reconciled.
6069

6170
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.
6271

src/brigade/security_cmd/scan_engine.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -906,11 +906,39 @@ def _secret_response_options(path: Path, target: Path) -> list[str]:
906906
return options
907907

908908

909-
def _fingerprint(*, category: str, title: str, rel_path: Path, line: int, evidence: str) -> str:
910-
stable = "\n".join([category, title, str(rel_path), str(line), _short(evidence, limit=96)])
909+
def _normalized_matched_content(evidence: str) -> str:
910+
return _short(evidence, limit=96)
911+
912+
913+
def _fingerprint(*, rule_id: str, rel_path: str, matched_content: str, occurrence: int) -> str:
914+
stable = "\n".join([rule_id, rel_path, matched_content, str(occurrence)])
911915
return hashlib.sha256(stable.encode()).hexdigest()[:16]
912916

913917

918+
def _assign_fingerprints(findings: list[dict[str, Any]]) -> None:
919+
groups: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
920+
for finding in findings:
921+
key = (
922+
str(finding.get("rule_id") or ""),
923+
str(finding.get("path") or ""),
924+
str(finding.get("_fingerprint_content") or finding.get("safe_excerpt") or finding.get("evidence") or ""),
925+
)
926+
groups.setdefault(key, []).append(finding)
927+
for (_, _, matched_content), items in groups.items():
928+
items.sort(key=lambda finding: (int(finding.get("line") or 0), str(finding.get("id") or "")))
929+
for occurrence, finding in enumerate(items):
930+
fingerprint = _fingerprint(
931+
rule_id=str(finding.get("rule_id") or ""),
932+
rel_path=str(finding.get("path") or ""),
933+
matched_content=matched_content,
934+
occurrence=occurrence,
935+
)
936+
finding["occurrence"] = occurrence
937+
finding["fingerprint"] = fingerprint
938+
finding["id"] = f"security-{fingerprint}"
939+
finding.pop("_fingerprint_content", None)
940+
941+
914942
def _finding(
915943
findings: list[dict[str, Any]],
916944
*,
@@ -928,13 +956,11 @@ def _finding(
928956
rel = path.relative_to(target)
929957
file_classification = classification or _classification_for(path, target)
930958
safe_excerpt = _short(evidence)
931-
fingerprint = _fingerprint(category=category, title=title, rel_path=rel, line=line, evidence=safe_excerpt)
932-
finding_id = f"security-{fingerprint}"
959+
matched_content = _normalized_matched_content(evidence)
960+
rule = _rule_id(category, title)
933961
findings.append(
934962
{
935-
"id": finding_id,
936-
"fingerprint": fingerprint,
937-
"rule_id": _rule_id(category, title),
963+
"rule_id": rule,
938964
"severity": severity,
939965
"category": category,
940966
"title": title,
@@ -944,6 +970,7 @@ def _finding(
944970
"confidence": file_classification.confidence,
945971
"evidence": safe_excerpt,
946972
"safe_excerpt": safe_excerpt,
973+
"_fingerprint_content": matched_content,
947974
"suggestion": suggestion,
948975
"remediation_hint": suggestion,
949976
"response_options": response_options or [],
@@ -1305,6 +1332,7 @@ def scan_target(
13051332
_scan_package_json(findings, target=target, path=path, text=text, classification=classification)
13061333
_scan_github_actions(findings, target=target, path=path, text=text, classification=classification)
13071334
_scan_python_project(findings, target=target, path=path, text=text, classification=classification)
1335+
_assign_fingerprints(findings)
13081336
findings = _filter_findings(
13091337
findings,
13101338
enabled_checks=enabled_checks,

src/brigade/security_cmd/template_audit_ops.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def harness_wiring_payload(target: Path) -> dict[str, Any]:
205205
text=text,
206206
classification=_classification_for(path, target),
207207
)
208+
_assign_fingerprints(findings)
208209
findings = _filter_findings(
209210
findings,
210211
enabled_checks=SECURITY_CHECKS,

tests/test_security_cmd.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,104 @@ def recording_confidence(path, target):
821821
assert confidence_calls == ["script.sh"]
822822

823823

824+
def test_security_suppression_fingerprint_survives_line_shift(tmp_path, capsys):
825+
readme = tmp_path / "README.md"
826+
needle = "npx -y @example/unpinned-package\n"
827+
readme.write_text("# Title\n\n" + needle)
828+
first = security_cmd.scan_target(tmp_path)
829+
finding = next(item for item in first["findings"] if item["category"] == "supply-chain")
830+
fingerprint = finding["fingerprint"]
831+
832+
assert security_cmd.suppress(target=tmp_path, fingerprint=fingerprint, reason="reviewed docs example") == 0
833+
capsys.readouterr()
834+
835+
readme.write_text("# Title\n\n| col | val |\n| --- | --- |\n| a | b |\n\n" + needle)
836+
second = security_cmd.scan_target(tmp_path, suppressions=security_cmd.load_config(tmp_path).suppressions)
837+
838+
assert second["finding_count"] == 0
839+
assert second["suppressed_count"] == 1
840+
suppressed = second["suppressed_findings"][0]
841+
assert suppressed["fingerprint"] == fingerprint
842+
assert suppressed["line"] != finding["line"]
843+
844+
845+
def test_security_fingerprint_distinguishes_identical_duplicates(tmp_path):
846+
line = "npx -y @example/unpinned-package\n"
847+
(tmp_path / "dup.txt").write_text(line + "middle section\n" + line)
848+
849+
report = security_cmd.scan_target(tmp_path)
850+
findings = [item for item in report["findings"] if item["category"] == "supply-chain"]
851+
852+
assert len(findings) == 2
853+
assert findings[0]["fingerprint"] != findings[1]["fingerprint"]
854+
assert findings[0]["occurrence"] == 0
855+
assert findings[1]["occurrence"] == 1
856+
assert findings[0]["safe_excerpt"] == findings[1]["safe_excerpt"]
857+
858+
suppressed = security_cmd.scan_target(tmp_path, suppressions=(findings[0]["fingerprint"],))
859+
assert suppressed["finding_count"] == 1
860+
assert suppressed["suppressed_count"] == 1
861+
assert suppressed["findings"][0]["fingerprint"] == findings[1]["fingerprint"]
862+
assert suppressed["suppressed_findings"][0]["fingerprint"] == findings[0]["fingerprint"]
863+
864+
865+
def test_security_line_based_suppression_requires_one_time_migration(tmp_path, capsys):
866+
import hashlib
867+
868+
from brigade.security_cmd import scan_engine as scan_engine
869+
870+
readme = tmp_path / "README.md"
871+
needle = "npx -y @example/unpinned-package\n"
872+
readme.write_text("# Title\n\n" + needle)
873+
current = security_cmd.scan_target(tmp_path)
874+
finding = next(item for item in current["findings"] if item["category"] == "supply-chain")
875+
876+
legacy = hashlib.sha256(
877+
"\n".join(
878+
[
879+
finding["category"],
880+
finding["title"],
881+
finding["path"],
882+
str(finding["line"]),
883+
scan_engine._short(needle.strip(), limit=96),
884+
]
885+
).encode()
886+
).hexdigest()[:16]
887+
assert legacy != finding["fingerprint"]
888+
889+
config = tmp_path / ".brigade" / "security.toml"
890+
config.parent.mkdir(parents=True)
891+
config.write_text(
892+
"\n".join(
893+
[
894+
'policy = "personal"',
895+
'scan_profile = "local-only-audit"',
896+
'fail_on = "none"',
897+
"include_templates = false",
898+
'enabled_checks = ["supply-chain"]',
899+
"include_paths = []",
900+
"exclude_paths = []",
901+
'severity_threshold = "low"',
902+
'output_path = ".brigade/security/latest"',
903+
"",
904+
"[suppressions]",
905+
f'fingerprints = ["{legacy}"]',
906+
"",
907+
"[suppression_reasons]",
908+
f'{legacy} = "legacy line-based suppression"',
909+
"",
910+
]
911+
)
912+
)
913+
914+
with_legacy = security_cmd.scan(target=tmp_path, fail_on="none", json_output=True)
915+
assert with_legacy == 0
916+
payload = json.loads(capsys.readouterr().out)
917+
assert payload["finding_count"] == 1
918+
assert payload["suppressed_count"] == 0
919+
assert payload["findings"][0]["fingerprint"] == finding["fingerprint"]
920+
921+
824922
def test_security_review_suppress_and_unsuppress(tmp_path, capsys):
825923
(tmp_path / ".env").write_text("SERVICE_TOKEN=abcd1234abcd1234abcd1234\n")
826924
output_dir = tmp_path / ".brigade" / "security" / "latest"

0 commit comments

Comments
 (0)