Skip to content
Merged
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
15 changes: 13 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ Supported fields:
- `fail_on`: `none`, `low`, `medium`, `high`, or `critical`.
- `include_templates`: whether public template files are scanned.
- `enabled_checks`: any of `automation`, `mcp`, `permissions`, `prompt-injection`, `secrets`, and `supply-chain`.
- `include_paths` and `exclude_paths`: relative path prefixes.
- `include_paths`: relative path prefixes. Bracketed Next.js-style segments such as `app/[id]` are matched literally, not as globs.
- `exclude_paths`: relative path prefixes. A trailing `/**` excludes the prefix and all descendants (for example `.brigade/**` covers `.brigade/security.toml` and nested files).
- `severity_threshold`: minimum severity retained in reports.
- `output_path`: relative path for the latest local evidence bundle.
- `[suppressions]` and `[suppression_reasons]`: reviewed finding fingerprints and reasons.
Expand All @@ -56,7 +57,17 @@ brigade security unsuppress <finding-id-or-fingerprint>
brigade security doctor
```

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

### Finding fingerprints and suppressions

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.

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.

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.

**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.

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.

Expand Down
62 changes: 58 additions & 4 deletions src/brigade/security_cmd/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def show_config(*, target: Path, json_output: bool = False) -> int:

def health(target: Path, *, suppression_cache_only: bool = False) -> dict[str, Any]:
target = target.expanduser().resolve()
migration_map = _read_fingerprint_migration_map(target)
checks: list[dict[str, Any]] = []
closeouts = _read_closeouts(target)
latest_closeout = closeouts[0] if closeouts else None
Expand All @@ -75,6 +76,8 @@ def health(target: Path, *, suppression_cache_only: bool = False) -> dict[str, A
and latest_closeout["policy_pack"].get("accepted_risk") is True
else set()
)
if accepted_fingerprints:
accepted_fingerprints = _expand_suppression_fingerprints(accepted_fingerprints, migration_map)
config_ok = True
try:
loaded = load_config(target)
Expand Down Expand Up @@ -144,11 +147,30 @@ def health(target: Path, *, suppression_cache_only: bool = False) -> dict[str, A
eligible_harness_findings = [
item for item in raw_harness_findings if include_templates or item.get("confidence") != "template"
]
if isinstance(latest_closeout, dict) and eligible_harness_findings:
matched_harness_findings = [
item
for item in eligible_harness_findings
if _finding_matches_fingerprints(item, accepted_fingerprints, migration_map=migration_map)
]
migrated_closeout = _migrate_closeout_fingerprints(latest_closeout, matched_harness_findings)
if migrated_closeout is not None:
latest_closeout = migrated_closeout
accepted_fingerprints = {
str(fingerprint)
for fingerprint in latest_closeout.get("source_fingerprints", [])
if isinstance(fingerprint, str) and fingerprint
}
accepted_fingerprints = _expand_suppression_fingerprints(accepted_fingerprints, migration_map)
active_harness_findings = [
item for item in eligible_harness_findings if str(item.get("fingerprint") or "") not in accepted_fingerprints
item
for item in eligible_harness_findings
if not _finding_matches_fingerprints(item, accepted_fingerprints, migration_map=migration_map)
]
quieted_harness_findings = [
item for item in eligible_harness_findings if str(item.get("fingerprint") or "") in accepted_fingerprints
item
for item in eligible_harness_findings
if _finding_matches_fingerprints(item, accepted_fingerprints, migration_map=migration_map)
]
harness_wiring = {
**raw_harness_wiring,
Expand Down Expand Up @@ -233,10 +255,24 @@ def health(target: Path, *, suppression_cache_only: bool = False) -> dict[str, A
except (OSError, ValueError, json.JSONDecodeError):
raw_open_findings = []
quieted_findings = [
item for item in raw_open_findings if str(item.get("fingerprint") or "") in accepted_fingerprints
item
for item in raw_open_findings
if _finding_matches_fingerprints(item, accepted_fingerprints, migration_map=migration_map)
]
if isinstance(latest_closeout, dict) and quieted_findings:
migrated_closeout = _migrate_closeout_fingerprints(latest_closeout, quieted_findings)
if migrated_closeout is not None:
latest_closeout = migrated_closeout
accepted_fingerprints = {
str(fingerprint)
for fingerprint in latest_closeout.get("source_fingerprints", [])
if isinstance(fingerprint, str) and fingerprint
}
accepted_fingerprints = _expand_suppression_fingerprints(accepted_fingerprints, migration_map)
records = [
item for item in raw_open_findings if str(item.get("fingerprint") or "") not in accepted_fingerprints
item
for item in raw_open_findings
if not _finding_matches_fingerprints(item, accepted_fingerprints, migration_map=migration_map)
]
if records:
top_finding = records[0]
Expand Down Expand Up @@ -411,6 +447,22 @@ def scan(
exclude_paths=effective.exclude_paths,
severity_threshold=effective.severity_threshold,
)
suppression_migrations: list[dict[str, str]] = []
if effective.config_loaded:
loaded = load_config(target)
if loaded is not None:
migrated_config, suppression_migrations = _migrate_legacy_suppressions(loaded, report)
if suppression_migrations:
if _merge_fingerprint_migration_map(target, suppression_migrations):
write_config(target, migrated_config)
effective = _effective_policy(
target,
policy=policy,
fail_on=fail_on,
include_templates=include_templates,
)
else:
suppression_migrations = []
report["policy"] = effective.policy
report["scan_profile"] = effective.scan_profile
report["fail_on"] = effective.fail_on
Expand All @@ -422,6 +474,8 @@ def scan(
report["config"] = str(effective.config_path)
report["config_loaded"] = effective.config_loaded
report["generated_at"] = _utc_iso()
if suppression_migrations:
report["suppression_migrations"] = suppression_migrations
_write_suppression_health_cache_from_report(target, effective, report)
configured_output_dir = target / effective.output_path
requested_output_dir = output_dir
Expand Down
130 changes: 124 additions & 6 deletions src/brigade/security_cmd/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@
SUPPRESSION_HEALTH_CACHE_REL_PATH = ".brigade/security/suppression-health-cache.json"


FINGERPRINT_MIGRATION_MAP_REL_PATH = ".brigade/security/fingerprint-migration-map.json"


FINGERPRINT_MIGRATION_MAP_SCHEMA = "brigade.security.fingerprint-migration-map.v1"


SUPPRESSION_HEALTH_CACHE_VERSION = 1


Expand Down Expand Up @@ -373,21 +379,133 @@ def suppression_health_cache_path(target: Path) -> Path:
return target / SUPPRESSION_HEALTH_CACHE_REL_PATH


def fingerprint_migration_map_path(target: Path) -> Path:
return target / FINGERPRINT_MIGRATION_MAP_REL_PATH


def _workspace_state_path_is_safe(target: Path, path: Path) -> bool:
target_resolved = target.expanduser().resolve()
try:
relative = path.relative_to(target_resolved)
except ValueError:
return False
candidate = target_resolved
for part in relative.parts:
candidate /= part
if candidate.is_symlink():
return False
try:
path.resolve(strict=False).relative_to(target_resolved)
except (OSError, ValueError):
return False
return True


def _closeout_path_is_symlink(path: Path) -> bool:
return path.is_symlink()


def _closeout_path_contained_in(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
except (OSError, ValueError):
return False
return True


def _closeouts_root(target: Path) -> Path:
return target / ".brigade" / "security" / "closeouts"


def _read_fingerprint_migration_map(target: Path) -> dict[str, str]:
target_resolved = target.expanduser().resolve()
path = fingerprint_migration_map_path(target_resolved)
if not _workspace_state_path_is_safe(target_resolved, path):
return {}
payload = localio.read_json_dict(path)
if payload is None:
return {}
if payload.get("schema") != FINGERPRINT_MIGRATION_MAP_SCHEMA:
return {}
raw_migrations = payload.get("migrations")
if not isinstance(raw_migrations, dict):
return {}
migrations: dict[str, str] = {}
for legacy, primary in raw_migrations.items():
if not isinstance(legacy, str) or not isinstance(primary, str):
continue
legacy = legacy.strip()
primary = primary.strip()
if FINGERPRINT_RE.fullmatch(legacy) and FINGERPRINT_RE.fullmatch(primary):
migrations[legacy] = primary
return migrations


def _write_fingerprint_migration_map(target: Path, migrations: dict[str, str]) -> bool:
if not migrations:
return True
target_resolved = target.expanduser().resolve()
path = fingerprint_migration_map_path(target_resolved)
if not _workspace_state_path_is_safe(target_resolved, path):
return False
_write_json(
path,
{
"schema": FINGERPRINT_MIGRATION_MAP_SCHEMA,
"migrations": dict(sorted(migrations.items())),
},
)
return True


def _merge_fingerprint_migration_map(target: Path, entries: list[dict[str, str]]) -> bool:
merged = _read_fingerprint_migration_map(target)
changed = False
for entry in entries:
legacy = str(entry.get("from") or "").strip()
primary = str(entry.get("to") or "").strip()
if not legacy or not primary:
continue
if merged.get(legacy) == primary:
continue
merged[legacy] = primary
changed = True
if changed:
return _write_fingerprint_migration_map(target, merged)
return True


def _read_closeouts(target: Path) -> list[dict[str, Any]]:
root = _closeouts_root(target.expanduser().resolve())
target_resolved = target.expanduser().resolve()
root = _closeouts_root(target_resolved)
receipts: list[dict[str, Any]] = []
if not root.is_dir():
if not root.is_dir() or _closeout_path_is_symlink(root):
return receipts
if not _closeout_path_contained_in(root, target_resolved):
return receipts
for path in sorted(root.glob("*/closeout.json")):
payload = _read_json(path)
try:
root_resolved = root.resolve()
except OSError:
return receipts
for closeout_dir in sorted(item for item in root.iterdir() if item.is_dir()):
if _closeout_path_is_symlink(closeout_dir):
continue
if not _closeout_path_contained_in(closeout_dir, root_resolved):
continue
closeout_json = closeout_dir / "closeout.json"
if not closeout_json.is_file() or _closeout_path_is_symlink(closeout_json):
continue
try:
trusted_path = closeout_json.resolve()
except OSError:
continue
if not _closeout_path_contained_in(trusted_path, root_resolved):
continue
payload = _read_json(trusted_path)
if payload is None:
continue
payload.setdefault("closeout_id", path.parent.name)
payload.setdefault("path", str(path))
payload.setdefault("closeout_id", closeout_dir.name)
payload["path"] = str(trusted_path)
receipts.append(payload)
return sorted(receipts, key=lambda item: str(item.get("created_at") or item.get("closeout_id") or ""), reverse=True)

Expand Down
Loading
Loading