@@ -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
67110def 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 "" ,
0 commit comments