Skip to content

Commit 772dd74

Browse files
committed
feat: add security evidence bundles
1 parent 7b92bc3 commit 772dd74

8 files changed

Lines changed: 111 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5757
- `brigade security scan --import-findings` to route security findings into the local work import inbox for review.
5858
- `brigade security init` to write gitignored local defaults to `.brigade/security.toml`.
5959
- Security policy presets (`personal`, `public-repo`, `strict`), template scanning controls, stable finding fingerprints, and fingerprint suppressions.
60+
- `brigade security scan --output-dir <dir>` to write redacted `security-report.json` and `security-report.md` evidence bundles.
6061
- Security scan secret evidence is redacted before reports or work imports are written.
6162
- `ROADMAP.md` covering the daily-driver path, scanner-ready inbox, chat-surface scanners, memory-card decay refresh, and portable operator setup.
6263
- `brigade work note` to append timestamped checkpoints to the active work session without ending it.
@@ -74,7 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7475
- `brigade work run` now consumes the oldest pending ledger task before falling back to the latest extracted dogfood next step, and marks consumed tasks done after successful runs.
7576
- `brigade work task add --from-next` now reuses an equivalent pending task instead of adding duplicates.
7677
- `brigade work brief` now includes pending local work imports and import counts in both text and JSON output.
77-
- The managed gitignore block now treats `.brigade/dogfood.toml`, `.brigade/security.toml`, and `.brigade/runs/` as local state.
78+
- The managed gitignore block now treats `.brigade/dogfood.toml`, `.brigade/security.toml`, `.brigade/runs/`, and `.brigade/security/` as local state.
7879
- Live smoke docs now keep Codex agent execution in a trusted repo cwd while writing temporary roster, artifacts, and handoff output under `/tmp`.
7980
- Handoff write failures now preserve final run artifacts, print the final answer, return nonzero, and mark `run.json` as `handoff-failed`.
8081
- Dogfood runs default to prompt-level read-only plus Codex's `danger-full-access` sandbox setting for trusted-workspace use so repo inspection works on hosts where native read-only sandboxing blocks shell inspection; `--native-read-only-sandbox` opts into stricter native enforcement.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ brigade runs show .brigade/runs/<run-id>
205205
brigade security init
206206
brigade security scan --target .
207207
brigade security scan --target . --policy public-repo
208+
brigade security scan --target . --output-dir .brigade/security/latest
208209
brigade security scan --target . --import-findings
209210
```
210211

@@ -289,7 +290,7 @@ brigade add guard # content-guard
289290
brigade add tokens # tokenjuice
290291
```
291292

292-
`security` is a built-in station with no external managed tool yet. Run `brigade security scan --target .` for a read-only agent workspace security report, or add `--import-findings` to turn findings into local `brigade work import` review items. Secret evidence is redacted before reports or imports are written. Use `brigade security init` to write gitignored local defaults to `.brigade/security.toml`; it supports policy presets (`personal`, `public-repo`, `strict`), `fail_on`, template scanning, and fingerprint suppressions for reviewed findings.
293+
`security` is a built-in station with no external managed tool yet. Run `brigade security scan --target .` for a read-only agent workspace security report, add `--output-dir .brigade/security/latest` to write redacted `security-report.json` and `security-report.md` artifacts, or add `--import-findings` to turn findings into local `brigade work import` review items. Secret evidence is redacted before reports, artifacts, or imports are written. Use `brigade security init` to write gitignored local defaults to `.brigade/security.toml`; it supports policy presets (`personal`, `public-repo`, `strict`), `fail_on`, template scanning, and fingerprint suppressions for reviewed findings.
293294

294295
The current managed tools:
295296

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Baseline coverage targets:
5252
- Analyze hooks and startup automation for command injection, remote execution, data exfiltration, silent failures, package installs, container escape, reverse shells, clipboard access, log tampering, and persistence behaviors.
5353
- Audit MCP server configs for high-risk server types, remote transports, shell metacharacters, unpinned `npx` usage, hardcoded env secrets, sensitive file args, excessive server counts, missing timeouts, and auto-approve behavior.
5454
- Review agent prompts, skills, subagents, slash commands, and workspace instructions for prompt-injection patterns, hidden instructions, URL execution, data harvesting, output suppression, time bombs, and unsafe auto-run language.
55-
- Emit graded reports with severity, category scores, evidence snippets, suggested fixes, JSON output, markdown output, HTML or bundle output, and CI-friendly exit codes.
55+
- Emit graded reports with severity, category scores, evidence snippets, suggested fixes, JSON output, markdown output, HTML or bundle output, and CI-friendly exit codes. Status: started with redacted JSON and Markdown evidence bundles.
5656
- Support CLI use, GitHub Action use, and local evidence packs.
5757

5858
Brigade-specific additions:
@@ -72,7 +72,7 @@ First build slice:
7272
- Create a plugin scaffold and security scan contract. Status: started with built-in `security` station, `brigade security init`, and `brigade security scan`.
7373
- Start with config discovery and read-only reporting for Brigade, Claude Code, Codex, and MCP config files. Status: started.
7474
- Add core rule categories for secrets, permissions, hooks, MCP servers, supply-chain patterns, and agent instructions. Status: started.
75-
- Output JSON plus readable text, then route selected findings into `brigade work import`. Status: started with `--import-findings`.
75+
- Output JSON plus readable text, redacted evidence bundles, then route selected findings into `brigade work import`. Status: started with `--output-dir` and `--import-findings`.
7676
- Keep all raw findings local and gitignored unless the operator explicitly exports an evidence pack. Status: current default.
7777
- Add local policy defaults, stable finding fingerprints, and suppressions. Status: started with `.brigade/security.toml`.
7878

src/brigade/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,12 @@ def _build_parser() -> argparse.ArgumentParser:
394394
p_security_scan = security_sub.add_parser("scan", help="Run a read-only agent workspace security scan.")
395395
p_security_scan.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to scan.")
396396
p_security_scan.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
397+
p_security_scan.add_argument(
398+
"--output-dir",
399+
type=Path,
400+
default=None,
401+
help="Write redacted security report artifacts to this directory.",
402+
)
397403
p_security_scan.add_argument(
398404
"--policy",
399405
choices=["personal", "public-repo", "strict"],
@@ -793,6 +799,7 @@ def main(argv=None) -> int:
793799
fail_on=args.fail_on,
794800
include_templates=args.include_templates,
795801
import_findings=args.import_findings,
802+
output_dir=args.output_dir,
796803
)
797804
parser.error(f"unknown security command: {args.security_command}")
798805
return 2

src/brigade/install.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def build_gitignore_block(selection: Selection) -> str:
6666
".brigade/logs/",
6767
".brigade/runs/",
6868
".brigade/scrub-cache/",
69+
".brigade/security/",
6970
".brigade/work/",
7071
GITIGNORE_END,
7172
"",

src/brigade/security_cmd.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import re
88
import sys
9+
from datetime import datetime, timezone
910
from dataclasses import dataclass
1011
from pathlib import Path
1112
from typing import Any
@@ -49,6 +50,7 @@
4950

5051
SKIP_PREFIXES = (
5152
(".brigade", "runs"),
53+
(".brigade", "security"),
5254
(".brigade", "work"),
5355
(".claude", "memory-handoffs"),
5456
(".codex", "memory-handoffs"),
@@ -563,6 +565,63 @@ def _import_findings(target: Path, findings: list[dict[str, Any]]) -> tuple[list
563565
return imported, skipped
564566

565567

568+
def _utc_iso() -> str:
569+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
570+
571+
572+
def _render_markdown_report(report: dict[str, Any]) -> str:
573+
lines = [
574+
"# Brigade Security Report",
575+
"",
576+
f"- target: `{report['target']}`",
577+
f"- generated_at: `{report['generated_at']}`",
578+
f"- policy: `{report['policy']}`",
579+
f"- fail_on: `{report['fail_on']}`",
580+
f"- include_templates: `{report['include_templates']}`",
581+
f"- scanned_files: `{report['scanned_file_count']}`",
582+
f"- findings: `{report['finding_count']}`",
583+
f"- suppressed: `{report['suppressed_count']}`",
584+
"",
585+
"## Severity Counts",
586+
"",
587+
]
588+
if report["severity_counts"]:
589+
for severity, count in report["severity_counts"].items():
590+
lines.append(f"- {severity}: {count}")
591+
else:
592+
lines.append("- none: 0")
593+
lines.extend(["", "## Findings", ""])
594+
if not report["findings"]:
595+
lines.append("No unsuppressed findings.")
596+
for finding in report["findings"]:
597+
lines.extend(
598+
[
599+
f"### {finding['id']} - {finding['title']}",
600+
"",
601+
f"- fingerprint: `{finding['fingerprint']}`",
602+
f"- severity: `{finding['severity']}`",
603+
f"- category: `{finding['category']}`",
604+
f"- path: `{finding['path']}:{finding['line']}`",
605+
f"- surface: `{finding['surface']}`",
606+
f"- confidence: `{finding['confidence']}`",
607+
f"- evidence: `{finding['evidence']}`",
608+
f"- suggestion: {finding['suggestion']}",
609+
"",
610+
]
611+
)
612+
return "\n".join(lines).rstrip() + "\n"
613+
614+
615+
def write_evidence_bundle(report: dict[str, Any], output_dir: Path) -> Path:
616+
output_dir = output_dir.expanduser().resolve()
617+
output_dir.mkdir(parents=True, exist_ok=True)
618+
report = dict(report)
619+
report["artifacts"] = str(output_dir)
620+
(output_dir / "security-report.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
621+
(output_dir / "security-report.md").write_text(_render_markdown_report(report))
622+
return output_dir
623+
624+
566625
def scan(
567626
*,
568627
target: Path,
@@ -571,6 +630,7 @@ def scan(
571630
fail_on: str | None = None,
572631
include_templates: bool | None = None,
573632
import_findings: bool = False,
633+
output_dir: Path | None = None,
574634
) -> int:
575635
target = target.expanduser().resolve()
576636
if not target.is_dir():
@@ -597,12 +657,16 @@ def scan(
597657
report["include_templates"] = effective.include_templates
598658
report["config"] = str(effective.config_path)
599659
report["config_loaded"] = effective.config_loaded
660+
report["generated_at"] = _utc_iso()
600661
imported: list[dict[str, Any]] = []
601662
skipped: list[dict[str, Any]] = []
602663
if import_findings and report["findings"]:
603664
imported, skipped = _import_findings(target, report["findings"])
604665
report["imported_findings"] = len(imported)
605666
report["skipped_duplicate_imports"] = len(skipped)
667+
if output_dir is not None:
668+
artifacts_dir = write_evidence_bundle(report, output_dir)
669+
report["artifacts"] = str(artifacts_dir)
606670

607671
if json_output:
608672
print(json.dumps(report, indent=2, sort_keys=True))
@@ -619,6 +683,8 @@ def scan(
619683
if import_findings:
620684
print(f"imported_findings: {len(imported)}")
621685
print(f"skipped_duplicate_imports: {len(skipped)}")
686+
if output_dir is not None:
687+
print(f"artifacts: {report['artifacts']}")
622688
for finding in report["findings"]:
623689
print(
624690
f"- [{finding['severity']}] {finding['category']} "

tests/test_gitignore.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def test_init_creates_gitignore_when_missing(tmp_target: Path):
2828
assert ".brigade/dogfood.toml" in gi
2929
assert ".brigade/security.toml" in gi
3030
assert ".brigade/runs/" in gi
31+
assert ".brigade/security/" in gi
3132
assert ".brigade/work/" in gi
3233

3334

@@ -183,6 +184,7 @@ def test_gitignore_block_includes_claude_section_when_selected():
183184
assert "!.claude/memory-handoffs/TEMPLATE.md" in block
184185
assert ".brigade/dogfood.toml" in block
185186
assert ".brigade/runs/" in block
187+
assert ".brigade/security/" in block
186188
assert ".brigade/work/" in block
187189
assert ".codex/memory-handoffs" not in block
188190

tests/test_security_cmd.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,32 @@ def test_security_scan_can_import_findings(tmp_path, capsys):
119119
assert "skipped_duplicate_imports: 1" in out
120120

121121

122+
def test_security_scan_writes_redacted_evidence_bundle(tmp_path, capsys):
123+
(tmp_path / ".env").write_text("SERVICE_TOKEN=abcd1234abcd1234abcd1234\n")
124+
output_dir = tmp_path / ".brigade" / "security" / "latest"
125+
126+
assert security_cmd.scan(target=tmp_path, fail_on="none", output_dir=output_dir) == 0
127+
out = capsys.readouterr().out
128+
assert f"artifacts: {output_dir.resolve()}" in out
129+
130+
json_path = output_dir / "security-report.json"
131+
markdown_path = output_dir / "security-report.md"
132+
assert json_path.is_file()
133+
assert markdown_path.is_file()
134+
135+
payload = json.loads(json_path.read_text())
136+
assert payload["artifacts"] == str(output_dir.resolve())
137+
assert payload["generated_at"]
138+
assert payload["finding_count"] == 1
139+
assert "[REDACTED]" in json_path.read_text()
140+
assert "abcd1234" not in json_path.read_text()
141+
markdown = markdown_path.read_text()
142+
assert "# Brigade Security Report" in markdown
143+
assert "Possible sensitive secret material" in markdown
144+
assert "[REDACTED]" in markdown
145+
assert "abcd1234" not in markdown
146+
147+
122148
def test_security_scan_cli(tmp_path, monkeypatch):
123149
seen = {}
124150

@@ -141,6 +167,8 @@ def fake_scan(**kwargs):
141167
"medium",
142168
"--include-templates",
143169
"--import-findings",
170+
"--output-dir",
171+
str(tmp_path / "security-report"),
144172
]
145173
)
146174
== 0
@@ -152,6 +180,7 @@ def fake_scan(**kwargs):
152180
"fail_on": "medium",
153181
"include_templates": True,
154182
"import_findings": True,
183+
"output_dir": tmp_path / "security-report",
155184
}
156185

157186

0 commit comments

Comments
 (0)