controlgate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """ControlGate — NIST RMF Cloud Security Hardening Compliance Gate."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,297 @@
1
+ """ControlGate CLI entry point.
2
+
3
+ Usage:
4
+ controlgate scan [--config PATH] [--format json|markdown|sarif] [--baseline low|moderate|high]
5
+ controlgate scan --diff-file PATH # scan a saved diff file
6
+ python -m controlgate scan [options]
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from controlgate.catalog import CatalogIndex
17
+ from controlgate.config import ControlGateConfig
18
+ from controlgate.diff_parser import parse_diff
19
+ from controlgate.engine import ControlGateEngine
20
+ from controlgate.models import Action
21
+ from controlgate.reporters.json_reporter import JSONReporter
22
+ from controlgate.reporters.markdown_reporter import MarkdownReporter
23
+ from controlgate.reporters.sarif_reporter import SARIFReporter
24
+
25
+
26
+ def _get_diff(mode: str, target_branch: str = "main") -> str:
27
+ """Get diff text from git.
28
+
29
+ Args:
30
+ mode: 'pre-commit' for staged changes, 'pr' for branch diff.
31
+ target_branch: Target branch for PR mode diff.
32
+
33
+ Returns:
34
+ The unified diff text.
35
+ """
36
+ if mode == "pre-commit":
37
+ cmd = ["git", "diff", "--cached", "--unified=3"]
38
+ else:
39
+ cmd = ["git", "diff", f"{target_branch}...HEAD", "--unified=3"]
40
+
41
+ try:
42
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
43
+ return result.stdout
44
+ except subprocess.CalledProcessError as e:
45
+ print(f"Error running git diff: {e.stderr}", file=sys.stderr)
46
+ sys.exit(1)
47
+ except FileNotFoundError:
48
+ print("Error: git is not installed or not in PATH", file=sys.stderr)
49
+ sys.exit(1)
50
+
51
+
52
+ def _resolve_catalog_path(config: ControlGateConfig) -> Path:
53
+ """Resolve the catalog path, auto-downloading if needed.
54
+
55
+ Search order:
56
+ 1. Bundled/cached catalog in the package data dir
57
+ 2. Explicit path from config
58
+ 3. Relative to current directory or git root
59
+ 4. Auto-download from GitHub (NCSB repository)
60
+ """
61
+ from controlgate.catalog_downloader import get_catalog_path
62
+
63
+ # 1. Check bundled/cached catalog (auto-downloads if missing)
64
+ try:
65
+ return get_catalog_path()
66
+ except ConnectionError:
67
+ pass # Fall through to config-based resolution
68
+
69
+ # 2. Explicit config path (absolute)
70
+ catalog_path = Path(config.catalog_path)
71
+ if catalog_path.is_absolute() and catalog_path.exists():
72
+ return catalog_path
73
+
74
+ # 3. Relative to current directory
75
+ if catalog_path.exists():
76
+ return catalog_path
77
+
78
+ # 4. Relative to git project root
79
+ try:
80
+ result = subprocess.run(
81
+ ["git", "rev-parse", "--show-toplevel"],
82
+ capture_output=True,
83
+ text=True,
84
+ check=True,
85
+ )
86
+ project_root = Path(result.stdout.strip())
87
+ resolved = project_root / catalog_path
88
+ if resolved.exists():
89
+ return resolved
90
+ except (subprocess.CalledProcessError, FileNotFoundError):
91
+ pass
92
+
93
+ print(
94
+ "Error: catalog file not found and download failed.\n"
95
+ "Run 'controlgate update-catalog' or set 'catalog' in .controlgate.yml.",
96
+ file=sys.stderr,
97
+ )
98
+ sys.exit(1)
99
+
100
+
101
+ def scan_command(args: argparse.Namespace) -> int:
102
+ """Execute the scan command."""
103
+ # Load config
104
+ config = ControlGateConfig.load(args.config)
105
+ if args.baseline:
106
+ config.baseline = args.baseline
107
+
108
+ # Resolve and load catalog
109
+ catalog_path = _resolve_catalog_path(config)
110
+ catalog = CatalogIndex(catalog_path)
111
+ print(f"📚 Loaded catalog: {catalog.count} controls", file=sys.stderr)
112
+
113
+ # Get diff
114
+ if args.diff_file:
115
+ diff_text = Path(args.diff_file).read_text(encoding="utf-8")
116
+ else:
117
+ diff_text = _get_diff(args.mode, args.target_branch)
118
+
119
+ if not diff_text.strip():
120
+ print("ℹ️ No changes to scan.", file=sys.stderr)
121
+ return 0
122
+
123
+ diff_files = parse_diff(diff_text)
124
+ print(
125
+ f"📄 Scanning {len(diff_files)} changed file(s)...",
126
+ file=sys.stderr,
127
+ )
128
+
129
+ # Run engine
130
+ engine = ControlGateEngine(config, catalog)
131
+ verdict = engine.scan(diff_files)
132
+
133
+ # Determine output format(s)
134
+ formats = args.format if args.format else config.report_formats
135
+
136
+ # Output reports
137
+ for fmt in formats:
138
+ if fmt == "json":
139
+ json_reporter = JSONReporter()
140
+ print(json_reporter.render(verdict))
141
+ elif fmt == "markdown":
142
+ md_reporter = MarkdownReporter()
143
+ print(md_reporter.render(verdict))
144
+ elif fmt == "sarif":
145
+ sarif_reporter = SARIFReporter()
146
+ print(sarif_reporter.render(verdict))
147
+
148
+ # Write to output dir if configured
149
+ if args.output_dir or config.output_dir:
150
+ output_dir = args.output_dir or config.output_dir
151
+ for fmt in formats:
152
+ if fmt == "json":
153
+ JSONReporter().write(verdict, f"{output_dir}/verdict.json")
154
+ elif fmt == "markdown":
155
+ MarkdownReporter().write(verdict, f"{output_dir}/verdict.md")
156
+ elif fmt == "sarif":
157
+ SARIFReporter().write(verdict, f"{output_dir}/verdict.sarif")
158
+ print(f"📁 Reports written to {output_dir}/", file=sys.stderr)
159
+
160
+ # Print summary to stderr
161
+ emoji = {"BLOCK": "🚫", "WARN": "⚠️", "PASS": "✅"}.get(verdict.verdict, "❓")
162
+ print(
163
+ f"\n{emoji} Verdict: {verdict.verdict} — {verdict.summary}",
164
+ file=sys.stderr,
165
+ )
166
+
167
+ # Exit code
168
+ if verdict.verdict == Action.BLOCK.value:
169
+ return 1
170
+ return 0
171
+
172
+
173
+ def build_parser() -> argparse.ArgumentParser:
174
+ """Build the argument parser."""
175
+ parser = argparse.ArgumentParser(
176
+ prog="controlgate",
177
+ description="ControlGate — NIST RMF Cloud Security Hardening Compliance Gate",
178
+ )
179
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
180
+
181
+ # scan subcommand
182
+ scan_parser = subparsers.add_parser("scan", help="Scan code changes for security compliance")
183
+ scan_parser.add_argument(
184
+ "--config",
185
+ type=str,
186
+ default=None,
187
+ help="Path to .controlgate.yml config file",
188
+ )
189
+ scan_parser.add_argument(
190
+ "--format",
191
+ type=str,
192
+ nargs="+",
193
+ choices=["json", "markdown", "sarif"],
194
+ default=None,
195
+ help="Output format(s)",
196
+ )
197
+ scan_parser.add_argument(
198
+ "--baseline",
199
+ type=str,
200
+ choices=["low", "moderate", "high"],
201
+ default=None,
202
+ help="Target NIST baseline level",
203
+ )
204
+ scan_parser.add_argument(
205
+ "--mode",
206
+ type=str,
207
+ choices=["pre-commit", "pr"],
208
+ default="pre-commit",
209
+ help="Scan mode: pre-commit (staged) or pr (branch diff)",
210
+ )
211
+ scan_parser.add_argument(
212
+ "--target-branch",
213
+ type=str,
214
+ default="main",
215
+ help="Target branch for PR mode diff (default: main)",
216
+ )
217
+ scan_parser.add_argument(
218
+ "--diff-file",
219
+ type=str,
220
+ default=None,
221
+ help="Path to a saved diff file to scan (instead of git diff)",
222
+ )
223
+ scan_parser.add_argument(
224
+ "--output-dir",
225
+ type=str,
226
+ default=None,
227
+ help="Directory to write report files to",
228
+ )
229
+
230
+ # update-catalog subcommand
231
+ subparsers.add_parser(
232
+ "update-catalog",
233
+ help="Download the latest NIST catalog from NCSB",
234
+ )
235
+
236
+ # catalog-info subcommand
237
+ subparsers.add_parser(
238
+ "catalog-info",
239
+ help="Show information about the current catalog",
240
+ )
241
+
242
+ return parser
243
+
244
+
245
+ def update_catalog_command() -> int:
246
+ """Download the latest catalog from GitHub."""
247
+ from controlgate.catalog_downloader import download_catalog
248
+
249
+ try:
250
+ path = download_catalog()
251
+ print(f"📦 Catalog saved to: {path}", file=sys.stderr)
252
+ return 0
253
+ except ConnectionError as e:
254
+ print(f"❌ {e}", file=sys.stderr)
255
+ return 1
256
+
257
+
258
+ def catalog_info_command() -> int:
259
+ """Show info about the current catalog."""
260
+ from controlgate.catalog_downloader import catalog_info, get_catalog_path
261
+
262
+ try:
263
+ path = get_catalog_path()
264
+ except ConnectionError:
265
+ print("❌ No catalog available. Run 'controlgate update-catalog'.", file=sys.stderr)
266
+ return 1
267
+
268
+ info = catalog_info(path)
269
+ print("📚 NIST Catalog Info:")
270
+ print(f" Project: {info['project']}")
271
+ print(f" Version: {info['version']}")
272
+ print(f" Framework: {info['framework']}")
273
+ print(f" Controls: {info['control_count']}")
274
+ print(f" Generated: {info['generated_at']}")
275
+ print(f" Path: {info['path']}")
276
+ return 0
277
+
278
+
279
+ def main() -> None:
280
+ """Main entry point for the CLI."""
281
+ parser = build_parser()
282
+ args = parser.parse_args()
283
+
284
+ if args.command == "scan":
285
+ exit_code = scan_command(args)
286
+ sys.exit(exit_code)
287
+ elif args.command == "update-catalog":
288
+ sys.exit(update_catalog_command())
289
+ elif args.command == "catalog-info":
290
+ sys.exit(catalog_info_command())
291
+ else:
292
+ parser.print_help()
293
+ sys.exit(0)
294
+
295
+
296
+ if __name__ == "__main__": # pragma: no cover
297
+ main()
controlgate/catalog.py ADDED
@@ -0,0 +1,115 @@
1
+ """NIST 800-53 Rev. 5 enriched catalog loader and query API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from controlgate.models import Control
9
+
10
+ # Mapping of gate names to the NIST control IDs they cover.
11
+ GATE_CONTROL_MAP: dict[str, list[str]] = {
12
+ "secrets": ["IA-5", "IA-6", "SC-12", "SC-28"],
13
+ "crypto": ["SC-8", "SC-13", "SC-17", "SC-23"],
14
+ "iam": ["AC-3", "AC-4", "AC-5", "AC-6"],
15
+ "sbom": ["SR-3", "SR-11", "SA-10", "SA-11"],
16
+ "iac": ["CM-2", "CM-6", "CM-7", "SC-7"],
17
+ "input_validation": ["SI-7", "SI-10", "SI-11", "SI-16"],
18
+ "audit": ["AU-2", "AU-3", "AU-12"],
19
+ "change_control": ["CM-3", "CM-4", "CM-5"],
20
+ }
21
+
22
+
23
+ class CatalogIndex:
24
+ """Queryable index over the enriched NIST 800-53 R5 catalog.
25
+
26
+ Loads the JSON catalog file and builds in-memory indexes for fast lookups
27
+ by control_id, family, severity, and gate.
28
+ """
29
+
30
+ def __init__(self, catalog_path: str | Path) -> None:
31
+ self._controls: list[Control] = []
32
+ self._by_id: dict[str, Control] = {}
33
+ self._by_family: dict[str, list[Control]] = {}
34
+ self._by_severity: dict[str, list[Control]] = {}
35
+ self._load(catalog_path)
36
+
37
+ def _load(self, catalog_path: str | Path) -> None:
38
+ """Load the enriched catalog JSON and build indexes."""
39
+ path = Path(catalog_path)
40
+ if not path.exists():
41
+ raise FileNotFoundError(f"Catalog file not found: {path}")
42
+
43
+ with open(path, encoding="utf-8") as f:
44
+ data = json.load(f)
45
+
46
+ controls_data = data.get("controls", [])
47
+ for entry in controls_data:
48
+ control = Control.from_dict(entry)
49
+ self._controls.append(control)
50
+ self._by_id[control.control_id] = control
51
+
52
+ # Index by family
53
+ family = control.family
54
+ if family not in self._by_family:
55
+ self._by_family[family] = []
56
+ self._by_family[family].append(control)
57
+
58
+ # Index by severity
59
+ severity = control.severity
60
+ if severity not in self._by_severity:
61
+ self._by_severity[severity] = []
62
+ self._by_severity[severity].append(control)
63
+
64
+ @property
65
+ def count(self) -> int:
66
+ """Total number of controls in the catalog."""
67
+ return len(self._controls)
68
+
69
+ def by_id(self, control_id: str) -> Control | None:
70
+ """Look up a single control by its ID (e.g. 'AC-3')."""
71
+ return self._by_id.get(control_id)
72
+
73
+ def by_family(self, family: str) -> list[Control]:
74
+ """Get all controls in a family (e.g. 'AC', 'SC')."""
75
+ return self._by_family.get(family, [])
76
+
77
+ def by_severity(self, severity: str) -> list[Control]:
78
+ """Get all controls at a given severity level."""
79
+ return self._by_severity.get(severity, [])
80
+
81
+ def non_negotiable(self) -> list[Control]:
82
+ """Get all controls marked as non-negotiable."""
83
+ return [c for c in self._controls if c.non_negotiable]
84
+
85
+ def for_gate(self, gate_name: str) -> list[Control]:
86
+ """Get the controls mapped to a specific security gate.
87
+
88
+ Args:
89
+ gate_name: One of 'secrets', 'crypto', 'iam', 'sbom',
90
+ 'iac', 'input_validation', 'audit', 'change_control'.
91
+ """
92
+ control_ids = GATE_CONTROL_MAP.get(gate_name, [])
93
+ controls = []
94
+ for cid in control_ids:
95
+ ctrl = self._by_id.get(cid)
96
+ if ctrl:
97
+ controls.append(ctrl)
98
+ return controls
99
+
100
+ def related_to(self, control_id: str) -> list[Control]:
101
+ """Get controls related to a given control ID."""
102
+ control = self._by_id.get(control_id)
103
+ if not control or not control.related_controls:
104
+ return []
105
+
106
+ related = []
107
+ # related_controls is a comma-separated string like "IA-1, PM-9, PS-8."
108
+ raw = control.related_controls.strip().rstrip(".")
109
+ if raw == "[None]":
110
+ return []
111
+ for ref in raw.split(","):
112
+ ref = ref.strip()
113
+ if ref and ref in self._by_id:
114
+ related.append(self._by_id[ref])
115
+ return related
@@ -0,0 +1,103 @@
1
+ """Dynamic catalog downloader for ControlGate.
2
+
3
+ Downloads the latest NIST 800-53 R5 enriched catalog from the
4
+ NIST Cloud Security Baseline (NCSB) GitHub repository.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ import urllib.error
12
+ import urllib.request
13
+ from pathlib import Path
14
+
15
+ # Source of truth for the enriched catalog
16
+ CATALOG_REPO = "sadayamuthu/nist-cloud-security-baseline"
17
+ CATALOG_BRANCH = "main"
18
+ CATALOG_PATH = "baseline/nist80053r5_full_catalog_enriched.json"
19
+ CATALOG_URL = f"https://raw.githubusercontent.com/{CATALOG_REPO}/{CATALOG_BRANCH}/{CATALOG_PATH}"
20
+
21
+ # Where to cache the downloaded catalog
22
+ _PACKAGE_DATA_DIR = Path(__file__).resolve().parent / "data"
23
+ _CATALOG_FILENAME = "nist80053r5_full_catalog_enriched.json"
24
+
25
+
26
+ def get_catalog_path() -> Path:
27
+ """Get the path to the catalog, downloading if needed.
28
+
29
+ Returns the path to the catalog JSON file. If no local copy exists,
30
+ downloads the latest from GitHub automatically.
31
+ """
32
+ local_path = _PACKAGE_DATA_DIR / _CATALOG_FILENAME
33
+ if local_path.exists():
34
+ return local_path
35
+
36
+ # Auto-download on first use
37
+ print("📥 No local catalog found. Downloading latest from NCSB...", file=sys.stderr)
38
+ return download_catalog()
39
+
40
+
41
+ def download_catalog(target_dir: Path | None = None) -> Path:
42
+ """Download the latest enriched catalog from GitHub.
43
+
44
+ Args:
45
+ target_dir: Directory to save the catalog to.
46
+ Defaults to the package's data/ directory.
47
+
48
+ Returns:
49
+ Path to the downloaded catalog file.
50
+
51
+ Raises:
52
+ ConnectionError: If the download fails.
53
+ """
54
+ dest_dir = target_dir or _PACKAGE_DATA_DIR
55
+ dest_dir.mkdir(parents=True, exist_ok=True)
56
+ dest_path = dest_dir / _CATALOG_FILENAME
57
+
58
+ try:
59
+ print(f"📥 Downloading catalog from {CATALOG_URL}...", file=sys.stderr)
60
+ req = urllib.request.Request(
61
+ CATALOG_URL,
62
+ headers={"User-Agent": "ControlGate/0.1.0"},
63
+ )
64
+ with urllib.request.urlopen(req, timeout=30) as response:
65
+ data = response.read()
66
+
67
+ # Validate it's valid JSON with the expected structure
68
+ catalog = json.loads(data)
69
+ if "controls" not in catalog:
70
+ raise ValueError("Downloaded file is not a valid NCSB catalog (missing 'controls' key)")
71
+
72
+ control_count = len(catalog.get("controls", []))
73
+
74
+ # Write atomically (write to tmp then rename)
75
+ tmp_path = dest_path.with_suffix(".tmp")
76
+ tmp_path.write_bytes(data)
77
+ tmp_path.rename(dest_path)
78
+
79
+ print(
80
+ f"✅ Catalog downloaded: {control_count} controls "
81
+ f"(v{catalog.get('project_version', 'unknown')})",
82
+ file=sys.stderr,
83
+ )
84
+ return dest_path
85
+
86
+ except urllib.error.URLError as e:
87
+ raise ConnectionError(f"Failed to download catalog from {CATALOG_URL}: {e}") from e
88
+ except (json.JSONDecodeError, ValueError) as e:
89
+ raise ConnectionError(f"Downloaded file is not valid: {e}") from e
90
+
91
+
92
+ def catalog_info(catalog_path: Path) -> dict:
93
+ """Get metadata about a local catalog file."""
94
+ with open(catalog_path, encoding="utf-8") as f:
95
+ data = json.load(f)
96
+ return {
97
+ "path": str(catalog_path),
98
+ "project": data.get("project", "unknown"),
99
+ "version": data.get("project_version", "unknown"),
100
+ "generated_at": data.get("generated_at_utc", "unknown"),
101
+ "framework": data.get("framework", "unknown"),
102
+ "control_count": len(data.get("controls", [])),
103
+ }
controlgate/config.py ADDED
@@ -0,0 +1,152 @@
1
+ """Configuration management for ControlGate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml # type: ignore
10
+
11
+ _DEFAULT_CONFIG = {
12
+ "baseline": "moderate",
13
+ "catalog": "baseline/nist80053r5_full_catalog_enriched.json",
14
+ "gates": {
15
+ "secrets": {"enabled": True, "action": "block"},
16
+ "crypto": {"enabled": True, "action": "block"},
17
+ "iam": {"enabled": True, "action": "warn"},
18
+ "sbom": {"enabled": True, "action": "warn"},
19
+ "iac": {"enabled": True, "action": "block"},
20
+ "input": {"enabled": True, "action": "block"},
21
+ "audit": {"enabled": True, "action": "warn"},
22
+ "change": {"enabled": True, "action": "warn"},
23
+ },
24
+ "thresholds": {
25
+ "block_on": ["CRITICAL", "HIGH"],
26
+ "warn_on": ["MEDIUM"],
27
+ "ignore": ["LOW"],
28
+ },
29
+ "exclusions": {
30
+ "paths": ["tests/**", "docs/**", "*.md"],
31
+ "controls": ["AC-13", "AC-15"],
32
+ },
33
+ "reporting": {
34
+ "format": ["json", "markdown"],
35
+ "sarif": False,
36
+ "output_dir": ".controlgate/reports",
37
+ },
38
+ }
39
+
40
+
41
+ @dataclass
42
+ class GateConfig:
43
+ """Configuration for a single gate."""
44
+
45
+ enabled: bool = True
46
+ action: str = "warn"
47
+
48
+
49
+ @dataclass
50
+ class ControlGateConfig:
51
+ """Full ControlGate configuration loaded from `.controlgate.yml`."""
52
+
53
+ baseline: str = "moderate"
54
+ catalog_path: str = "baseline/nist80053r5_full_catalog_enriched.json"
55
+ gates: dict[str, GateConfig] = field(default_factory=dict)
56
+ block_on: list[str] = field(default_factory=lambda: ["CRITICAL", "HIGH"])
57
+ warn_on: list[str] = field(default_factory=lambda: ["MEDIUM"])
58
+ ignore: list[str] = field(default_factory=lambda: ["LOW"])
59
+ excluded_paths: list[str] = field(default_factory=lambda: ["tests/**", "docs/**", "*.md"])
60
+ excluded_controls: list[str] = field(default_factory=lambda: ["AC-13", "AC-15"])
61
+ report_formats: list[str] = field(default_factory=lambda: ["json", "markdown"])
62
+ sarif_enabled: bool = False
63
+ output_dir: str = ".controlgate/reports"
64
+
65
+ @classmethod
66
+ def load(cls, config_path: str | Path | None = None) -> ControlGateConfig:
67
+ """Load configuration from a YAML file, falling back to defaults.
68
+
69
+ Search order:
70
+ 1. Explicit ``config_path`` argument
71
+ 2. ``.controlgate.yml`` in the current directory
72
+ 3. Built-in defaults
73
+ """
74
+ raw: dict[str, Any] = dict(_DEFAULT_CONFIG)
75
+
76
+ search_paths: list[Path] = []
77
+ if config_path:
78
+ search_paths.append(Path(config_path))
79
+ search_paths.append(Path(".controlgate.yml"))
80
+
81
+ for path in search_paths:
82
+ if path.exists():
83
+ with open(path, encoding="utf-8") as f:
84
+ loaded = yaml.safe_load(f)
85
+ if loaded and isinstance(loaded, dict):
86
+ raw = _deep_merge(raw, loaded)
87
+ break
88
+
89
+ return cls._from_raw(raw)
90
+
91
+ @classmethod
92
+ def _from_raw(cls, raw: dict[str, Any]) -> ControlGateConfig:
93
+ """Build config from a raw dict (merged defaults + user overrides)."""
94
+ cfg = cls()
95
+ cfg.baseline = raw.get("baseline", cfg.baseline)
96
+ cfg.catalog_path = raw.get("catalog", cfg.catalog_path)
97
+
98
+ # Gates
99
+ gates_raw = raw.get("gates", {})
100
+ for name, settings in gates_raw.items():
101
+ if isinstance(settings, dict):
102
+ cfg.gates[name] = GateConfig(
103
+ enabled=settings.get("enabled", True),
104
+ action=settings.get("action", "warn"),
105
+ )
106
+
107
+ # Thresholds
108
+ thresholds = raw.get("thresholds", {})
109
+ cfg.block_on = thresholds.get("block_on", cfg.block_on)
110
+ cfg.warn_on = thresholds.get("warn_on", cfg.warn_on)
111
+ cfg.ignore = thresholds.get("ignore", cfg.ignore)
112
+
113
+ # Exclusions
114
+ exclusions = raw.get("exclusions", {})
115
+ cfg.excluded_paths = exclusions.get("paths", cfg.excluded_paths)
116
+ cfg.excluded_controls = exclusions.get("controls", cfg.excluded_controls)
117
+
118
+ # Reporting
119
+ reporting = raw.get("reporting", {})
120
+ cfg.report_formats = reporting.get("format", cfg.report_formats)
121
+ cfg.sarif_enabled = reporting.get("sarif", cfg.sarif_enabled)
122
+ cfg.output_dir = reporting.get("output_dir", cfg.output_dir)
123
+
124
+ return cfg
125
+
126
+ def is_gate_enabled(self, gate_name: str) -> bool:
127
+ """Check if a gate is enabled in the config."""
128
+ gc = self.gates.get(gate_name)
129
+ if gc is None:
130
+ return True # enabled by default
131
+ return gc.enabled
132
+
133
+ def is_path_excluded(self, file_path: str) -> bool:
134
+ """Check if a file path is excluded by glob patterns."""
135
+ from fnmatch import fnmatch
136
+
137
+ return any(fnmatch(file_path, pattern) for pattern in self.excluded_paths)
138
+
139
+ def is_control_excluded(self, control_id: str) -> bool:
140
+ """Check if a control ID is explicitly excluded."""
141
+ return control_id in self.excluded_controls
142
+
143
+
144
+ def _deep_merge(base: dict, override: dict) -> dict:
145
+ """Deep-merge override dict into base dict."""
146
+ result = dict(base)
147
+ for key, value in override.items():
148
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
149
+ result[key] = _deep_merge(result[key], value)
150
+ else:
151
+ result[key] = value
152
+ return result