diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3b8295..7c9a2d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `brigade security scan --import-findings` to route security findings into the local work import inbox for review. - `brigade security init` to write gitignored local defaults to `.brigade/security.toml`. - Security policy presets (`personal`, `public-repo`, `strict`), template scanning controls, stable finding fingerprints, and fingerprint suppressions. +- `brigade security scan --output-dir ` to write redacted `security-report.json` and `security-report.md` evidence bundles. - Security scan secret evidence is redacted before reports or work imports are written. - `ROADMAP.md` covering the daily-driver path, scanner-ready inbox, chat-surface scanners, memory-card decay refresh, and portable operator setup. - `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 - `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. - `brigade work task add --from-next` now reuses an equivalent pending task instead of adding duplicates. - `brigade work brief` now includes pending local work imports and import counts in both text and JSON output. -- The managed gitignore block now treats `.brigade/dogfood.toml`, `.brigade/security.toml`, and `.brigade/runs/` as local state. +- The managed gitignore block now treats `.brigade/dogfood.toml`, `.brigade/security.toml`, `.brigade/runs/`, and `.brigade/security/` as local state. - Live smoke docs now keep Codex agent execution in a trusted repo cwd while writing temporary roster, artifacts, and handoff output under `/tmp`. - Handoff write failures now preserve final run artifacts, print the final answer, return nonzero, and mark `run.json` as `handoff-failed`. - 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. diff --git a/README.md b/README.md index fa61ad9a..65a1e672 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ brigade runs show .brigade/runs/ brigade security init brigade security scan --target . brigade security scan --target . --policy public-repo +brigade security scan --target . --output-dir .brigade/security/latest brigade security scan --target . --import-findings ``` @@ -289,7 +290,7 @@ brigade add guard # content-guard brigade add tokens # tokenjuice ``` -`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. +`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. The current managed tools: diff --git a/ROADMAP.md b/ROADMAP.md index 1311902b..8214859e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,7 +52,7 @@ Baseline coverage targets: - 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. - 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. - 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. -- Emit graded reports with severity, category scores, evidence snippets, suggested fixes, JSON output, markdown output, HTML or bundle output, and CI-friendly exit codes. +- 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. - Support CLI use, GitHub Action use, and local evidence packs. Brigade-specific additions: @@ -72,7 +72,7 @@ First build slice: - Create a plugin scaffold and security scan contract. Status: started with built-in `security` station, `brigade security init`, and `brigade security scan`. - Start with config discovery and read-only reporting for Brigade, Claude Code, Codex, and MCP config files. Status: started. - Add core rule categories for secrets, permissions, hooks, MCP servers, supply-chain patterns, and agent instructions. Status: started. -- Output JSON plus readable text, then route selected findings into `brigade work import`. Status: started with `--import-findings`. +- Output JSON plus readable text, redacted evidence bundles, then route selected findings into `brigade work import`. Status: started with `--output-dir` and `--import-findings`. - Keep all raw findings local and gitignored unless the operator explicitly exports an evidence pack. Status: current default. - Add local policy defaults, stable finding fingerprints, and suppressions. Status: started with `.brigade/security.toml`. diff --git a/src/brigade/cli.py b/src/brigade/cli.py index e38549b3..22abc693 100644 --- a/src/brigade/cli.py +++ b/src/brigade/cli.py @@ -394,6 +394,12 @@ def _build_parser() -> argparse.ArgumentParser: p_security_scan = security_sub.add_parser("scan", help="Run a read-only agent workspace security scan.") p_security_scan.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to scan.") p_security_scan.add_argument("--json", action="store_true", help="Print machine-readable JSON.") + p_security_scan.add_argument( + "--output-dir", + type=Path, + default=None, + help="Write redacted security report artifacts to this directory.", + ) p_security_scan.add_argument( "--policy", choices=["personal", "public-repo", "strict"], @@ -793,6 +799,7 @@ def main(argv=None) -> int: fail_on=args.fail_on, include_templates=args.include_templates, import_findings=args.import_findings, + output_dir=args.output_dir, ) parser.error(f"unknown security command: {args.security_command}") return 2 diff --git a/src/brigade/install.py b/src/brigade/install.py index 1bc2051f..cdd274d5 100644 --- a/src/brigade/install.py +++ b/src/brigade/install.py @@ -66,6 +66,7 @@ def build_gitignore_block(selection: Selection) -> str: ".brigade/logs/", ".brigade/runs/", ".brigade/scrub-cache/", + ".brigade/security/", ".brigade/work/", GITIGNORE_END, "", diff --git a/src/brigade/security_cmd.py b/src/brigade/security_cmd.py index 159074fc..183aa728 100644 --- a/src/brigade/security_cmd.py +++ b/src/brigade/security_cmd.py @@ -6,6 +6,7 @@ import json import re import sys +from datetime import datetime, timezone from dataclasses import dataclass from pathlib import Path from typing import Any @@ -49,6 +50,7 @@ SKIP_PREFIXES = ( (".brigade", "runs"), + (".brigade", "security"), (".brigade", "work"), (".claude", "memory-handoffs"), (".codex", "memory-handoffs"), @@ -563,6 +565,63 @@ def _import_findings(target: Path, findings: list[dict[str, Any]]) -> tuple[list return imported, skipped +def _utc_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _render_markdown_report(report: dict[str, Any]) -> str: + lines = [ + "# Brigade Security Report", + "", + f"- target: `{report['target']}`", + f"- generated_at: `{report['generated_at']}`", + f"- policy: `{report['policy']}`", + f"- fail_on: `{report['fail_on']}`", + f"- include_templates: `{report['include_templates']}`", + f"- scanned_files: `{report['scanned_file_count']}`", + f"- findings: `{report['finding_count']}`", + f"- suppressed: `{report['suppressed_count']}`", + "", + "## Severity Counts", + "", + ] + if report["severity_counts"]: + for severity, count in report["severity_counts"].items(): + lines.append(f"- {severity}: {count}") + else: + lines.append("- none: 0") + lines.extend(["", "## Findings", ""]) + if not report["findings"]: + lines.append("No unsuppressed findings.") + for finding in report["findings"]: + lines.extend( + [ + f"### {finding['id']} - {finding['title']}", + "", + f"- fingerprint: `{finding['fingerprint']}`", + f"- severity: `{finding['severity']}`", + f"- category: `{finding['category']}`", + f"- path: `{finding['path']}:{finding['line']}`", + f"- surface: `{finding['surface']}`", + f"- confidence: `{finding['confidence']}`", + f"- evidence: `{finding['evidence']}`", + f"- suggestion: {finding['suggestion']}", + "", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def write_evidence_bundle(report: dict[str, Any], output_dir: Path) -> Path: + output_dir = output_dir.expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + report = dict(report) + report["artifacts"] = str(output_dir) + (output_dir / "security-report.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + (output_dir / "security-report.md").write_text(_render_markdown_report(report)) + return output_dir + + def scan( *, target: Path, @@ -571,6 +630,7 @@ def scan( fail_on: str | None = None, include_templates: bool | None = None, import_findings: bool = False, + output_dir: Path | None = None, ) -> int: target = target.expanduser().resolve() if not target.is_dir(): @@ -597,12 +657,16 @@ def scan( report["include_templates"] = effective.include_templates report["config"] = str(effective.config_path) report["config_loaded"] = effective.config_loaded + report["generated_at"] = _utc_iso() imported: list[dict[str, Any]] = [] skipped: list[dict[str, Any]] = [] if import_findings and report["findings"]: imported, skipped = _import_findings(target, report["findings"]) report["imported_findings"] = len(imported) report["skipped_duplicate_imports"] = len(skipped) + if output_dir is not None: + artifacts_dir = write_evidence_bundle(report, output_dir) + report["artifacts"] = str(artifacts_dir) if json_output: print(json.dumps(report, indent=2, sort_keys=True)) @@ -619,6 +683,8 @@ def scan( if import_findings: print(f"imported_findings: {len(imported)}") print(f"skipped_duplicate_imports: {len(skipped)}") + if output_dir is not None: + print(f"artifacts: {report['artifacts']}") for finding in report["findings"]: print( f"- [{finding['severity']}] {finding['category']} " diff --git a/tests/test_gitignore.py b/tests/test_gitignore.py index 415e78fe..82475d84 100644 --- a/tests/test_gitignore.py +++ b/tests/test_gitignore.py @@ -28,6 +28,7 @@ def test_init_creates_gitignore_when_missing(tmp_target: Path): assert ".brigade/dogfood.toml" in gi assert ".brigade/security.toml" in gi assert ".brigade/runs/" in gi + assert ".brigade/security/" in gi assert ".brigade/work/" in gi @@ -183,6 +184,7 @@ def test_gitignore_block_includes_claude_section_when_selected(): assert "!.claude/memory-handoffs/TEMPLATE.md" in block assert ".brigade/dogfood.toml" in block assert ".brigade/runs/" in block + assert ".brigade/security/" in block assert ".brigade/work/" in block assert ".codex/memory-handoffs" not in block diff --git a/tests/test_security_cmd.py b/tests/test_security_cmd.py index 0a6eef0b..69ebf57f 100644 --- a/tests/test_security_cmd.py +++ b/tests/test_security_cmd.py @@ -119,6 +119,32 @@ def test_security_scan_can_import_findings(tmp_path, capsys): assert "skipped_duplicate_imports: 1" in out +def test_security_scan_writes_redacted_evidence_bundle(tmp_path, capsys): + (tmp_path / ".env").write_text("SERVICE_TOKEN=abcd1234abcd1234abcd1234\n") + output_dir = tmp_path / ".brigade" / "security" / "latest" + + assert security_cmd.scan(target=tmp_path, fail_on="none", output_dir=output_dir) == 0 + out = capsys.readouterr().out + assert f"artifacts: {output_dir.resolve()}" in out + + json_path = output_dir / "security-report.json" + markdown_path = output_dir / "security-report.md" + assert json_path.is_file() + assert markdown_path.is_file() + + payload = json.loads(json_path.read_text()) + assert payload["artifacts"] == str(output_dir.resolve()) + assert payload["generated_at"] + assert payload["finding_count"] == 1 + assert "[REDACTED]" in json_path.read_text() + assert "abcd1234" not in json_path.read_text() + markdown = markdown_path.read_text() + assert "# Brigade Security Report" in markdown + assert "Possible sensitive secret material" in markdown + assert "[REDACTED]" in markdown + assert "abcd1234" not in markdown + + def test_security_scan_cli(tmp_path, monkeypatch): seen = {} @@ -141,6 +167,8 @@ def fake_scan(**kwargs): "medium", "--include-templates", "--import-findings", + "--output-dir", + str(tmp_path / "security-report"), ] ) == 0 @@ -152,6 +180,7 @@ def fake_scan(**kwargs): "fail_on": "medium", "include_templates": True, "import_findings": True, + "output_dir": tmp_path / "security-report", }