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.
- controlgate/__init__.py +3 -0
- controlgate/__main__.py +297 -0
- controlgate/catalog.py +115 -0
- controlgate/catalog_downloader.py +103 -0
- controlgate/config.py +152 -0
- controlgate/data/nist80053r5_full_catalog_enriched.json +20236 -0
- controlgate/diff_parser.py +107 -0
- controlgate/engine.py +119 -0
- controlgate/gates/__init__.py +33 -0
- controlgate/gates/audit_gate.py +145 -0
- controlgate/gates/base.py +61 -0
- controlgate/gates/change_gate.py +125 -0
- controlgate/gates/crypto_gate.py +178 -0
- controlgate/gates/iac_gate.py +184 -0
- controlgate/gates/iam_gate.py +117 -0
- controlgate/gates/input_gate.py +158 -0
- controlgate/gates/sbom_gate.py +133 -0
- controlgate/gates/secrets_gate.py +205 -0
- controlgate/models.py +155 -0
- controlgate/reporters/__init__.py +7 -0
- controlgate/reporters/json_reporter.py +35 -0
- controlgate/reporters/markdown_reporter.py +104 -0
- controlgate/reporters/sarif_reporter.py +108 -0
- controlgate-0.1.0.dist-info/METADATA +184 -0
- controlgate-0.1.0.dist-info/RECORD +28 -0
- controlgate-0.1.0.dist-info/WHEEL +5 -0
- controlgate-0.1.0.dist-info/entry_points.txt +2 -0
- controlgate-0.1.0.dist-info/top_level.txt +1 -0
controlgate/__init__.py
ADDED
controlgate/__main__.py
ADDED
|
@@ -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
|