Skip to content

Commit 4aa1862

Browse files
authored
Merge pull request #544 from escoffier-labs/fix/529-security-exclude-paths
fix(security): exclude Brigade state by default
2 parents f11725b + de302bd commit 4aa1862

3 files changed

Lines changed: 266 additions & 8 deletions

File tree

src/brigade/security_cmd/config.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,33 @@ def _parse_toml_value(raw: str) -> object:
3939
return value
4040

4141

42-
def _read_toml_object(path: Path) -> dict[str, object]:
42+
@dataclass(frozen=True)
43+
class _SecurityToml:
44+
data: dict[str, object]
45+
top_level_keys: frozenset[str]
46+
suppressions_keys: frozenset[str]
47+
enrichment_keys: frozenset[str]
48+
49+
50+
def _validate_config_keys(parsed: _SecurityToml) -> None:
51+
unknown_top = parsed.top_level_keys - CONFIG_TOP_LEVEL_KEYS
52+
if unknown_top:
53+
raise ValueError(f"unsupported security config key: {', '.join(sorted(unknown_top))}")
54+
unknown_suppressions = parsed.suppressions_keys - CONFIG_SUPPRESSIONS_KEYS
55+
if unknown_suppressions:
56+
raise ValueError(f"unsupported suppressions key: {', '.join(sorted(unknown_suppressions))}")
57+
unknown_enrichment = parsed.enrichment_keys - CONFIG_ENRICHMENT_KEYS
58+
if unknown_enrichment:
59+
raise ValueError(f"unsupported enrichment key: {', '.join(sorted(unknown_enrichment))}")
60+
61+
62+
def _read_toml_object(path: Path) -> _SecurityToml:
4363
data: dict[str, object] = {}
4464
current = data
65+
current_section = "top"
66+
top_level_keys: set[str] = set()
67+
suppressions_keys: set[str] = set()
68+
enrichment_keys: set[str] = set()
4569
for line_number, raw_line in enumerate(path.read_text().splitlines(), start=1):
4670
line = raw_line.split("#", 1)[0].strip()
4771
if not line:
@@ -50,6 +74,7 @@ def _read_toml_object(path: Path) -> dict[str, object]:
5074
table = line[1:-1].strip()
5175
if table not in {"suppressions", "suppression_reasons", "enrichment"}:
5276
raise ValueError(f"invalid security config line {line_number}: unsupported table [{table}]")
77+
current_section = table
5378
current = data.setdefault(table, {})
5479
if not isinstance(current, dict):
5580
raise ValueError(f"invalid security config line {line_number}: {table} must be a table")
@@ -60,15 +85,34 @@ def _read_toml_object(path: Path) -> dict[str, object]:
6085
key = key.strip()
6186
if not key:
6287
raise ValueError(f"invalid security config line {line_number}: empty key")
63-
current[key] = _parse_toml_value(raw_value)
64-
return data
88+
value = _parse_toml_value(raw_value)
89+
if current_section == "top":
90+
top_level_keys.add(key)
91+
current[key] = value
92+
elif current_section == "suppressions":
93+
suppressions_keys.add(key)
94+
current[key] = value
95+
elif current_section == "suppression_reasons":
96+
current[key] = value
97+
else:
98+
enrichment_keys.add(key)
99+
current[key] = value
100+
parsed = _SecurityToml(
101+
data=data,
102+
top_level_keys=frozenset(top_level_keys),
103+
suppressions_keys=frozenset(suppressions_keys),
104+
enrichment_keys=frozenset(enrichment_keys),
105+
)
106+
_validate_config_keys(parsed)
107+
return parsed
65108

66109

67110
def load_config(target: Path) -> SecurityConfig | None:
68111
path = config_path(target.expanduser().resolve())
69112
if not path.is_file():
70113
return None
71-
data = _read_toml_object(path)
114+
parsed = _read_toml_object(path)
115+
data = parsed.data
72116
policy = data.get("policy", "personal")
73117
if not isinstance(policy, str) or policy not in POLICIES:
74118
raise ValueError("policy must be one of: ci, personal, public-repo, strict")
@@ -87,7 +131,10 @@ def load_config(target: Path) -> SecurityConfig | None:
87131
allowed=SECURITY_CHECKS,
88132
)
89133
include_paths = _parse_string_list(data.get("include_paths", []), field_name="include_paths")
90-
exclude_paths = _parse_string_list(data.get("exclude_paths", []), field_name="exclude_paths")
134+
if "exclude_paths" in parsed.top_level_keys:
135+
exclude_paths = _parse_string_list(data.get("exclude_paths", []), field_name="exclude_paths")
136+
else:
137+
exclude_paths = DEFAULT_EXCLUDE_PATHS
91138
severity_threshold = data.get("severity_threshold", "low")
92139
if not isinstance(severity_threshold, str) or severity_threshold not in SEVERITY_ORDER:
93140
raise ValueError("severity_threshold must be one of: info, low, medium, high, critical")
@@ -151,6 +198,9 @@ def _parse_enrichment_config(raw: object) -> SecurityEnrichmentConfig:
151198
return SecurityEnrichmentConfig()
152199
if not isinstance(raw, dict):
153200
raise ValueError("enrichment must be a table")
201+
unknown = set(raw.keys()) - CONFIG_ENRICHMENT_KEYS
202+
if unknown:
203+
raise ValueError(f"unsupported enrichment key: {', '.join(sorted(unknown))}")
154204
provider = raw.get("provider")
155205
if provider is not None:
156206
if not isinstance(provider, str) or provider not in ENRICHMENT_PROVIDERS:
@@ -207,7 +257,7 @@ def _effective_policy(
207257
include_templates=effective_include_templates,
208258
enabled_checks=loaded.enabled_checks if loaded is not None else SECURITY_CHECKS,
209259
include_paths=loaded.include_paths if loaded is not None else (),
210-
exclude_paths=loaded.exclude_paths if loaded is not None else (),
260+
exclude_paths=loaded.exclude_paths if loaded is not None else DEFAULT_EXCLUDE_PATHS,
211261
severity_threshold=loaded.severity_threshold if loaded is not None else "low",
212262
output_path=loaded.output_path if loaded is not None else ARTIFACTS_REL_PATH,
213263
suppressions=loaded.suppressions if loaded is not None else (),
@@ -233,7 +283,7 @@ def write_default_config(target: Path, *, force: bool = False) -> Path:
233283
"include_templates = false",
234284
'enabled_checks = ["automation", "mcp", "permissions", "prompt-injection", "secrets", "supply-chain"]',
235285
"include_paths = []",
236-
"exclude_paths = []",
286+
'exclude_paths = [".brigade/**"]',
237287
'severity_threshold = "low"',
238288
'output_path = ".brigade/security/latest"',
239289
"",

src/brigade/security_cmd/models.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,38 @@
8888
)
8989

9090

91+
DEFAULT_EXCLUDE_PATHS = (".brigade/**",)
92+
93+
94+
CONFIG_TOP_LEVEL_KEYS = frozenset(
95+
{
96+
"policy",
97+
"scan_profile",
98+
"fail_on",
99+
"include_templates",
100+
"enabled_checks",
101+
"include_paths",
102+
"exclude_paths",
103+
"severity_threshold",
104+
"output_path",
105+
}
106+
)
107+
108+
109+
CONFIG_SUPPRESSIONS_KEYS = frozenset({"fingerprints"})
110+
111+
112+
CONFIG_ENRICHMENT_KEYS = frozenset(
113+
{
114+
"provider",
115+
"misp_url",
116+
"misp_api_key_env",
117+
"timeout_seconds",
118+
"cache_path",
119+
}
120+
)
121+
122+
91123
SKIP_DIRS = {
92124
".git",
93125
".hg",
@@ -337,7 +369,7 @@ class SecurityConfig:
337369
include_templates: bool | None = None
338370
enabled_checks: tuple[str, ...] = SECURITY_CHECKS
339371
include_paths: tuple[str, ...] = ()
340-
exclude_paths: tuple[str, ...] = ()
372+
exclude_paths: tuple[str, ...] = DEFAULT_EXCLUDE_PATHS
341373
severity_threshold: str = "low"
342374
output_path: str = ARTIFACTS_REL_PATH
343375
suppressions: tuple[str, ...] = ()

tests/test_security_cmd.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import json
22

3+
import pytest
4+
35
from brigade import cli
46
from brigade import learn_cmd
57
from brigade import release_cmd
@@ -189,6 +191,8 @@ def test_security_policy_pack_closeout_release_and_candidate_evidence(tmp_path,
189191
def test_security_accepted_risk_closeout_quiets_matching_findings_and_resurfaces_changes(tmp_path, capsys):
190192
assert security_cmd.init(target=tmp_path) == 0
191193
capsys.readouterr()
194+
config_path = tmp_path / ".brigade" / "security.toml"
195+
config_path.write_text(config_path.read_text().replace('exclude_paths = [".brigade/**"]', "exclude_paths = []"))
192196
harness_dir = tmp_path / ".brigade" / "hermes"
193197
harness_dir.mkdir(parents=True)
194198
(harness_dir / "workspace.harness.json").write_text(json.dumps({"endpoint": "https://agent.private/api"}, indent=2))
@@ -337,6 +341,18 @@ def test_security_scan_deep_mcp_config_checks(tmp_path, capsys):
337341
def test_security_scan_harness_wiring_checks_cross_harness_json(tmp_path, capsys):
338342
brigade_dir = tmp_path / ".brigade"
339343
brigade_dir.mkdir()
344+
(brigade_dir / "security.toml").write_text(
345+
"\n".join(
346+
[
347+
'policy = "personal"',
348+
"exclude_paths = []",
349+
"",
350+
"[suppressions]",
351+
"fingerprints = []",
352+
"",
353+
]
354+
)
355+
)
340356
(brigade_dir / "handoff-sources.json").write_text(
341357
json.dumps(
342358
{
@@ -712,6 +728,166 @@ def test_security_scan_cli_excludes_brigade_glob_from_self_scan(tmp_path, capsys
712728
assert not security_cmd._path_matches_any("nested/.brigade/evidence.json", (".brigade/**",))
713729

714730

731+
def test_security_scan_excludes_brigade_by_default_without_config(tmp_path, capsys):
732+
(tmp_path / "hooks" / "install.sh").parent.mkdir(parents=True)
733+
(tmp_path / "hooks" / "install.sh").write_text("curl https://example.invalid/install.sh | sh\n")
734+
brigade_evidence = tmp_path / ".brigade" / "center" / "reports" / "operator-one"
735+
brigade_evidence.mkdir(parents=True)
736+
(brigade_evidence / "CENTER_EVIDENCE.json").write_text(
737+
json.dumps(
738+
{
739+
"findings": [
740+
{
741+
"path": "README.md",
742+
"line": 42,
743+
"safe_excerpt": "npx -y @example/unpinned-package",
744+
"category": "supply-chain",
745+
"title": "Unpinned remote package execution",
746+
}
747+
]
748+
}
749+
)
750+
+ "\n"
751+
)
752+
753+
assert security_cmd.scan(target=tmp_path, fail_on="none", json_output=True) == 0
754+
report = json.loads(capsys.readouterr().out)
755+
756+
finding_paths = {finding["path"] for finding in report["findings"]}
757+
scanned_paths = set(report["scanned_files"])
758+
assert report["exclude_paths"] == [".brigade/**"]
759+
assert finding_paths == {"hooks/install.sh"}
760+
assert not any(path.startswith(".brigade/") for path in finding_paths)
761+
assert not any(path.startswith(".brigade/") for path in scanned_paths)
762+
763+
764+
def test_security_init_default_config_excludes_brigade(tmp_path, capsys):
765+
(tmp_path / "hooks" / "install.sh").parent.mkdir(parents=True)
766+
(tmp_path / "hooks" / "install.sh").write_text("curl https://example.invalid/install.sh | sh\n")
767+
brigade_secret = tmp_path / ".brigade" / "evidence" / "secret.txt"
768+
brigade_secret.parent.mkdir(parents=True)
769+
brigade_secret.write_text("SERVICE_TOKEN=abcd1234abcd1234abcd1234\n")
770+
771+
assert security_cmd.init(target=tmp_path) == 0
772+
capsys.readouterr()
773+
loaded = security_cmd.load_config(tmp_path)
774+
assert loaded is not None
775+
assert loaded.exclude_paths == (".brigade/**",)
776+
777+
assert security_cmd.scan(target=tmp_path, fail_on="none", json_output=True) == 0
778+
report = json.loads(capsys.readouterr().out)
779+
finding_paths = {finding["path"] for finding in report["findings"]}
780+
assert finding_paths == {"hooks/install.sh"}
781+
assert ".brigade/evidence/secret.txt" not in report["scanned_files"]
782+
783+
784+
def test_security_explicit_empty_exclude_paths_scans_brigade(tmp_path, capsys):
785+
brigade_secret = tmp_path / ".brigade" / "evidence" / "secret.txt"
786+
brigade_secret.parent.mkdir(parents=True)
787+
brigade_secret.write_text("SERVICE_TOKEN=abcd1234abcd1234abcd1234\n")
788+
config = tmp_path / ".brigade" / "security.toml"
789+
config.write_text(
790+
"\n".join(
791+
[
792+
'policy = "personal"',
793+
'scan_profile = "local-only-audit"',
794+
'fail_on = "none"',
795+
"include_templates = false",
796+
"exclude_paths = []",
797+
'severity_threshold = "low"',
798+
'output_path = ".brigade/security/latest"',
799+
"",
800+
"[suppressions]",
801+
"fingerprints = []",
802+
"",
803+
]
804+
)
805+
)
806+
807+
loaded = security_cmd.load_config(tmp_path)
808+
assert loaded is not None
809+
assert loaded.exclude_paths == ()
810+
811+
assert security_cmd.scan(target=tmp_path, fail_on="none", json_output=True) == 0
812+
report = json.loads(capsys.readouterr().out)
813+
assert ".brigade/evidence/secret.txt" in report["scanned_files"]
814+
assert any(finding["path"] == ".brigade/evidence/secret.txt" for finding in report["findings"])
815+
816+
817+
def test_security_config_rejects_unknown_top_level_key(tmp_path):
818+
config = tmp_path / ".brigade" / "security.toml"
819+
config.parent.mkdir(parents=True)
820+
config.write_text('unknown_setting = "nope"\n')
821+
822+
with pytest.raises(ValueError, match="unsupported security config key: unknown_setting"):
823+
security_cmd.load_config(tmp_path)
824+
825+
826+
def test_security_config_rejects_unknown_suppressions_key(tmp_path):
827+
config = tmp_path / ".brigade" / "security.toml"
828+
config.parent.mkdir(parents=True)
829+
config.write_text(
830+
"\n".join(
831+
[
832+
'policy = "personal"',
833+
"",
834+
"[suppressions]",
835+
"fingerprints = []",
836+
"legacy_ids = []",
837+
"",
838+
]
839+
)
840+
)
841+
842+
with pytest.raises(ValueError, match="unsupported suppressions key: legacy_ids"):
843+
security_cmd.load_config(tmp_path)
844+
845+
846+
def test_security_config_rejects_unknown_enrichment_key(tmp_path):
847+
config = tmp_path / ".brigade" / "security.toml"
848+
config.parent.mkdir(parents=True)
849+
config.write_text(
850+
"\n".join(
851+
[
852+
'policy = "personal"',
853+
"",
854+
"[enrichment]",
855+
'provider = "local"',
856+
"extra_flag = true",
857+
"",
858+
]
859+
)
860+
)
861+
862+
with pytest.raises(ValueError, match="unsupported enrichment key: extra_flag"):
863+
security_cmd.load_config(tmp_path)
864+
865+
866+
def test_security_config_allows_suppression_reasons_fingerprint_keys(tmp_path):
867+
fingerprint = "a" * 64
868+
config = tmp_path / ".brigade" / "security.toml"
869+
config.parent.mkdir(parents=True)
870+
config.write_text(
871+
"\n".join(
872+
[
873+
'policy = "personal"',
874+
"",
875+
"[suppressions]",
876+
f'fingerprints = ["{fingerprint}"]',
877+
"",
878+
"[suppression_reasons]",
879+
f'{fingerprint} = "reviewed local fake token"',
880+
"",
881+
]
882+
)
883+
)
884+
885+
loaded = security_cmd.load_config(tmp_path)
886+
assert loaded is not None
887+
assert loaded.suppressions == (fingerprint,)
888+
assert loaded.suppression_reasons[fingerprint] == "reviewed local fake token"
889+
890+
715891
def test_security_scan_include_paths_do_not_open_unrelated_files(tmp_path, monkeypatch):
716892
included = tmp_path / "included"
717893
included.mkdir()

0 commit comments

Comments
 (0)