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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ brigade runs show .brigade/runs/<run-id>
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
```

Expand Down Expand Up @@ -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:

Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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`.

Expand Down
7 changes: 7 additions & 0 deletions src/brigade/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/brigade/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def build_gitignore_block(selection: Selection) -> str:
".brigade/logs/",
".brigade/runs/",
".brigade/scrub-cache/",
".brigade/security/",
".brigade/work/",
GITIGNORE_END,
"",
Expand Down
66 changes: 66 additions & 0 deletions src/brigade/security_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +50,7 @@

SKIP_PREFIXES = (
(".brigade", "runs"),
(".brigade", "security"),
(".brigade", "work"),
(".claude", "memory-handoffs"),
(".codex", "memory-handoffs"),
Expand Down Expand Up @@ -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,
Expand All @@ -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():
Expand All @@ -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))
Expand All @@ -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']} "
Expand Down
2 changes: 2 additions & 0 deletions tests/test_gitignore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions tests/test_security_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand All @@ -141,6 +167,8 @@ def fake_scan(**kwargs):
"medium",
"--include-templates",
"--import-findings",
"--output-dir",
str(tmp_path / "security-report"),
]
)
== 0
Expand All @@ -152,6 +180,7 @@ def fake_scan(**kwargs):
"fail_on": "medium",
"include_templates": True,
"import_findings": True,
"output_dir": tmp_path / "security-report",
}


Expand Down
Loading