supplyguard 0.3.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
+ """SupplyGuard: AI-Aware Software Supply Chain Security Scanner & Remediation Engine."""
2
+
3
+ __version__ = "0.2.0"
supplyguard/cli.py ADDED
@@ -0,0 +1,504 @@
1
+ """SupplyGuard CLI interface powered by Click and Rich.
2
+
3
+ Exit codes:
4
+ 0 — Scan complete, risk score ≤ threshold (pass).
5
+ 1 — Scan complete, risk score > threshold (policy violation).
6
+ 2 — Scan failed due to tool error.
7
+ """
8
+
9
+ import concurrent.futures
10
+ import datetime
11
+ import json
12
+ import logging
13
+ import shutil
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import click
18
+ from rich.console import Console
19
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
20
+
21
+ from supplyguard.config import (
22
+ CONFIG_FILENAME,
23
+ SupplyGuardConfig,
24
+ load_config,
25
+ severity_meets_minimum,
26
+ write_default_config,
27
+ )
28
+ from supplyguard.formatters import ScanOutput, format_output
29
+ from supplyguard.models import Finding, Scan, init_db
30
+ from supplyguard.remediation.loop import remediate
31
+ from supplyguard.sast.scanner import run_sast
32
+ from supplyguard.sbom.generator import generate_sbom
33
+ from supplyguard.scoring.engine import calculate_risk_score
34
+ from supplyguard.secrets.scanner import scan_secrets
35
+ from supplyguard.vulns.osv_client import batch_query
36
+
37
+ console = Console(stderr=True, force_terminal=True)
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ @click.group()
42
+ @click.option("-v", "--verbose", is_flag=True, help="Enable verbose debug logging.")
43
+ @click.version_option(version="0.3.0")
44
+ def main(verbose: bool = False) -> None:
45
+ """SupplyGuard — AI-Aware Software Supply Chain Security Scanner & Self-Heal Engine."""
46
+ level = logging.DEBUG if verbose else logging.WARNING
47
+ logging.basicConfig(level=level, format="%(levelname)s: %(message)s", force=True)
48
+
49
+
50
+ # --------------------------------------------------------------------------- #
51
+ # supplyguard init
52
+ # --------------------------------------------------------------------------- #
53
+
54
+
55
+ @main.command(name="init")
56
+ @click.argument(
57
+ "project_path",
58
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
59
+ default=".",
60
+ )
61
+ def init_cmd(project_path: Path) -> None:
62
+ """Initialize SupplyGuard configuration for a project."""
63
+ console.print()
64
+ console.print("[bold cyan][SHIELD] SupplyGuard Project Setup[/]")
65
+ console.print("─" * 40)
66
+
67
+ # Detect project type
68
+ has_requirements = (project_path / "requirements.txt").exists()
69
+ has_pyproject = (project_path / "pyproject.toml").exists()
70
+ has_setup = (project_path / "setup.py").exists()
71
+
72
+ if has_requirements or has_pyproject or has_setup:
73
+ console.print("[green]✓[/] Python project detected")
74
+ else:
75
+ console.print("[yellow]![/] No Python project markers found (requirements.txt, pyproject.toml)")
76
+
77
+ # Check external tools
78
+ semgrep_path = shutil.which("semgrep")
79
+ gitleaks_path = shutil.which("gitleaks")
80
+ console.print(
81
+ f"[green]✓[/] Semgrep: {'[green]' + semgrep_path + '[/]' if semgrep_path else '[yellow]not found[/] (will use built-in AST scanner)'}"
82
+ )
83
+ console.print(
84
+ f"[green]✓[/] Gitleaks: {'[green]' + gitleaks_path + '[/]' if gitleaks_path else '[yellow]not found[/] (will use built-in regex scanner)'}"
85
+ )
86
+
87
+ # Write config
88
+ try:
89
+ config_path = write_default_config(project_path)
90
+ console.print(f"[green]✓[/] Created [bold]{CONFIG_FILENAME}[/] with default configuration")
91
+ console.print(f" [dim]{config_path}[/]")
92
+ except FileExistsError:
93
+ console.print(f"[yellow]![/] {CONFIG_FILENAME} already exists — skipping")
94
+
95
+ console.print()
96
+ console.print("[dim]Run [bold]supplyguard scan .[/] to start your first scan.[/]")
97
+
98
+
99
+ # --------------------------------------------------------------------------- #
100
+ # supplyguard scan
101
+ # --------------------------------------------------------------------------- #
102
+
103
+
104
+ @main.command(name="scan")
105
+ @click.argument(
106
+ "project_path",
107
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
108
+ default=".",
109
+ )
110
+ @click.option(
111
+ "--format", "-f",
112
+ "output_format",
113
+ type=click.Choice(["table", "json", "sarif"], case_sensitive=False),
114
+ default=None,
115
+ help="Output format (default: table, or from .supplyguard.yml).",
116
+ )
117
+ @click.option(
118
+ "--threshold", "-t",
119
+ type=int,
120
+ default=None,
121
+ help="Risk score threshold. Exits with code 1 if exceeded (default: 100).",
122
+ )
123
+ @click.option(
124
+ "--output", "-o",
125
+ type=click.Path(path_type=Path),
126
+ default=None,
127
+ help="Write output to file instead of stdout.",
128
+ )
129
+ @click.option("--db-path", type=click.Path(path_type=Path), default="supplyguard.db", help="SQLite database path.")
130
+ def scan_cmd(
131
+ project_path: Path,
132
+ output_format: str | None,
133
+ threshold: int | None,
134
+ output: Path | None,
135
+ db_path: Path,
136
+ ) -> None:
137
+ """Scan a project for supply chain vulnerabilities, secrets, and code smells."""
138
+ try:
139
+ _run_scan(project_path, output_format, threshold, output, db_path)
140
+ except KeyboardInterrupt:
141
+ console.print("\n[yellow]Scan interrupted by user.[/]")
142
+ sys.exit(2)
143
+ except Exception as err: # noqa: BLE001
144
+ console.print(f"[bold red]Scan failed:[/] {err}")
145
+ logger.exception("Scan failed")
146
+ sys.exit(2)
147
+
148
+
149
+ def _run_scan(
150
+ project_path: Path,
151
+ output_format: str | None,
152
+ threshold: int | None,
153
+ output: Path | None,
154
+ db_path: Path,
155
+ ) -> None:
156
+ """Internal scan implementation with proper exit codes.
157
+
158
+ Args:
159
+ project_path: Target project directory.
160
+ output_format: CLI override for format.
161
+ threshold: CLI override for threshold.
162
+ output: Optional output file path.
163
+ db_path: SQLite database path.
164
+ """
165
+ # Load config (file + CLI overrides)
166
+ cli_overrides = {}
167
+ if output_format is not None:
168
+ cli_overrides["format"] = output_format
169
+ if threshold is not None:
170
+ cli_overrides["threshold"] = threshold
171
+
172
+ config = load_config(project_path, cli_overrides)
173
+ resolved_format = config.format
174
+ resolved_threshold = config.threshold
175
+
176
+ started_at = datetime.datetime.now(datetime.UTC)
177
+
178
+ # Non-table formats should not print progress to stdout
179
+ is_interactive = resolved_format == "table" and output is None
180
+
181
+ if is_interactive:
182
+ console.print(f"\n[bold cyan][SCAN] Scanning:[/] {project_path.resolve()}\n")
183
+
184
+ # Run scan pipeline with progress indicators
185
+ with Progress(
186
+ SpinnerColumn(),
187
+ TextColumn("[progress.description]{task.description}"),
188
+ TimeElapsedColumn(),
189
+ console=console,
190
+ disable=not is_interactive,
191
+ ) as progress:
192
+ # 1. SBOM + OSV
193
+ task_sbom = progress.add_task("Generating CycloneDX SBOM...", total=None)
194
+ _, components = generate_sbom(project_path)
195
+ progress.update(task_sbom, description=f"[green]✓[/] SBOM: {len(components)} components")
196
+ progress.stop_task(task_sbom)
197
+
198
+ task_osv = progress.add_task(f"Querying OSV.dev ({len(components)} packages)...", total=None)
199
+ vulns = batch_query(components)
200
+ progress.update(task_osv, description=f"[green]✓[/] OSV: {len(vulns)} vulnerabilities found")
201
+ progress.stop_task(task_osv)
202
+
203
+ # 2. Parallel Secrets + SAST
204
+ task_parallel = progress.add_task("Running Secrets + SAST scans...", total=None)
205
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
206
+ f_secrets = executor.submit(scan_secrets, project_path)
207
+ f_sast = executor.submit(run_sast, project_path)
208
+ secrets = f_secrets.result()
209
+ sast_findings = f_sast.result()
210
+ progress.update(
211
+ task_parallel,
212
+ description=f"[green]✓[/] Secrets: {len(secrets)} | SAST: {len(sast_findings)} findings",
213
+ )
214
+ progress.stop_task(task_parallel)
215
+
216
+ # 3. Scoring
217
+ score_breakdown = calculate_risk_score(vulns, secrets, sast_findings)
218
+ finished_at = datetime.datetime.now(datetime.UTC)
219
+
220
+ # 4. Persistence
221
+ _, session_factory = init_db(db_path)
222
+ with session_factory() as session:
223
+ scan_rec = Scan(
224
+ project_path=str(project_path.resolve()),
225
+ started_at=started_at,
226
+ finished_at=finished_at,
227
+ risk_score=score_breakdown.total_score,
228
+ components_count=len(components),
229
+ findings_count=len(vulns) + len(secrets) + len(sast_findings),
230
+ )
231
+ session.add(scan_rec)
232
+ session.flush()
233
+
234
+ for v in vulns:
235
+ session.add(
236
+ Finding(
237
+ scan_id=scan_rec.id,
238
+ source="sbom_osv",
239
+ severity=v.severity,
240
+ file="requirements.txt",
241
+ line=1,
242
+ rule_id=v.vuln_id,
243
+ cwe="CWE-1395",
244
+ message=f"{v.package}=={v.version}: {v.summary}",
245
+ raw_json=json.dumps({"package": v.package, "fixed": v.fixed_version}),
246
+ )
247
+ )
248
+
249
+ for s in secrets:
250
+ session.add(
251
+ Finding(
252
+ scan_id=scan_rec.id,
253
+ source="secrets",
254
+ severity="CRITICAL",
255
+ file=s.file,
256
+ line=s.line,
257
+ rule_id=s.rule_id,
258
+ cwe="CWE-798",
259
+ message=f"Hardcoded secret ({s.rule_id}): {s.match_preview}",
260
+ raw_json=json.dumps({"match_preview": s.match_preview}),
261
+ )
262
+ )
263
+
264
+ for sa in sast_findings:
265
+ session.add(
266
+ Finding(
267
+ scan_id=scan_rec.id,
268
+ source="sast",
269
+ severity=sa.severity,
270
+ file=sa.file,
271
+ line=sa.line,
272
+ rule_id=sa.rule_id,
273
+ cwe=sa.cwe,
274
+ message=sa.message,
275
+ raw_json=json.dumps({"rule_id": sa.rule_id, "cwe": sa.cwe}),
276
+ )
277
+ )
278
+
279
+ session.commit()
280
+ scan_id = scan_rec.id
281
+
282
+ # 5. Build unified findings list and apply filters
283
+ all_findings: list[dict[str, str | int]] = []
284
+
285
+ for v in vulns:
286
+ if not severity_meets_minimum(v.severity, config.severity_minimum):
287
+ continue
288
+ if v.vuln_id in config.ignore_rules:
289
+ continue
290
+ all_findings.append({
291
+ "source": "sbom_osv",
292
+ "severity": v.severity,
293
+ "file": f"{v.package}=={v.version}",
294
+ "line": 1,
295
+ "rule_id": v.vuln_id,
296
+ "cwe": "CWE-1395",
297
+ "message": v.summary[:200] if v.summary else f"Vulnerability in {v.package}",
298
+ })
299
+
300
+ for s in secrets:
301
+ if "CWE-798" in config.ignore_rules or s.rule_id in config.ignore_rules:
302
+ continue
303
+ all_findings.append({
304
+ "source": "secrets",
305
+ "severity": "CRITICAL",
306
+ "file": s.file,
307
+ "line": s.line,
308
+ "rule_id": s.rule_id,
309
+ "cwe": "CWE-798",
310
+ "message": f"Hardcoded secret ({s.rule_id}): {s.match_preview}",
311
+ })
312
+
313
+ for sa in sast_findings:
314
+ if not severity_meets_minimum(sa.severity, config.severity_minimum):
315
+ continue
316
+ if sa.rule_id in config.ignore_rules or sa.cwe in config.ignore_rules:
317
+ continue
318
+ all_findings.append({
319
+ "source": "sast",
320
+ "severity": sa.severity,
321
+ "file": sa.file,
322
+ "line": sa.line,
323
+ "rule_id": sa.rule_id,
324
+ "cwe": sa.cwe,
325
+ "message": sa.message,
326
+ })
327
+
328
+ # 6. Format & output
329
+ scan_output = ScanOutput(
330
+ scan_id=scan_id,
331
+ project_path=str(project_path.resolve()),
332
+ risk_score=score_breakdown.total_score,
333
+ score_breakdown=score_breakdown,
334
+ components_count=len(components),
335
+ findings=all_findings,
336
+ started_at=started_at.isoformat(),
337
+ finished_at=finished_at.isoformat(),
338
+ )
339
+
340
+ formatted = format_output(resolved_format, scan_output)
341
+
342
+ if output and formatted:
343
+ output.parent.mkdir(parents=True, exist_ok=True)
344
+ output.write_text(formatted, encoding="utf-8")
345
+ if is_interactive:
346
+ console.print(f"\n[dim]Results written to {output}[/]")
347
+ elif formatted:
348
+ # Non-table formats go to stdout (not stderr console)
349
+ click.echo(formatted)
350
+
351
+ # 7. Exit code based on threshold
352
+ if score_breakdown.total_score > resolved_threshold:
353
+ if is_interactive:
354
+ console.print(
355
+ f"\n[bold red]✗ POLICY VIOLATION:[/] Risk score {score_breakdown.total_score} "
356
+ f"exceeds threshold {resolved_threshold}"
357
+ )
358
+ sys.exit(1)
359
+ elif is_interactive:
360
+ console.print(
361
+ f"\n[bold green]✓ PASS:[/] Risk score {score_breakdown.total_score} "
362
+ f"≤ threshold {resolved_threshold}"
363
+ )
364
+
365
+
366
+ # --------------------------------------------------------------------------- #
367
+ # supplyguard report
368
+ # --------------------------------------------------------------------------- #
369
+
370
+
371
+ @main.command(name="report")
372
+ @click.argument("scan_id", type=int)
373
+ @click.option("--db-path", type=click.Path(path_type=Path), default="supplyguard.db")
374
+ @click.option(
375
+ "--format", "-f",
376
+ "output_format",
377
+ type=click.Choice(["table", "json", "sarif"], case_sensitive=False),
378
+ default="table",
379
+ help="Output format.",
380
+ )
381
+ def report_cmd(scan_id: int, db_path: Path, output_format: str) -> None:
382
+ """Display detailed report of a previous scan from SQLite."""
383
+ _, session_factory = init_db(db_path)
384
+ with session_factory() as session:
385
+ scan_rec = session.get(Scan, scan_id)
386
+ if not scan_rec:
387
+ console.print(f"[bold red]Scan ID {scan_id} not found in database.[/]")
388
+ sys.exit(2)
389
+
390
+ findings_list = []
391
+ for f in scan_rec.findings:
392
+ findings_list.append({
393
+ "source": f.source,
394
+ "severity": f.severity,
395
+ "file": f.file,
396
+ "line": f.line,
397
+ "rule_id": f.rule_id,
398
+ "cwe": f.cwe,
399
+ "message": f.message,
400
+ })
401
+
402
+ scan_output = ScanOutput(
403
+ scan_id=scan_rec.id,
404
+ project_path=scan_rec.project_path,
405
+ risk_score=scan_rec.risk_score,
406
+ score_breakdown=calculate_risk_score([], [], []), # Placeholder for stored scans
407
+ components_count=scan_rec.components_count,
408
+ findings=findings_list,
409
+ started_at=str(scan_rec.started_at) if scan_rec.started_at else "",
410
+ finished_at=str(scan_rec.finished_at) if scan_rec.finished_at else "",
411
+ )
412
+
413
+ formatted = format_output(output_format, scan_output)
414
+ if formatted:
415
+ click.echo(formatted)
416
+
417
+
418
+ # --------------------------------------------------------------------------- #
419
+ # supplyguard fix
420
+ # --------------------------------------------------------------------------- #
421
+
422
+
423
+ @main.command(name="fix")
424
+ @click.argument("project_path", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".")
425
+ @click.option("--dry-run", is_flag=True, help="Simulate fixes without modifying files.")
426
+ @click.option("--max-iterations", type=int, default=5, help="Maximum remediation loop cycles.")
427
+ @click.option("--no-llm", is_flag=True, help="Disable LLM-assisted patches.")
428
+ @click.option("--db-path", type=click.Path(path_type=Path), default="supplyguard.db")
429
+ def fix_cmd(
430
+ project_path: Path, dry_run: bool, max_iterations: int, no_llm: bool, db_path: Path
431
+ ) -> None:
432
+ """Execute self-healing remediation loop on project_path."""
433
+ from rich.table import Table as RichTable
434
+
435
+ console.print(f"[bold green][FIX] Remediation:[/] {project_path.resolve()}")
436
+ if dry_run:
437
+ console.print("[yellow bold][!] DRY-RUN MODE: No files will be modified.[/]")
438
+
439
+ _, session_factory = init_db(db_path)
440
+ with session_factory() as session:
441
+ scan_rec = Scan(
442
+ project_path=str(project_path.resolve()),
443
+ started_at=datetime.datetime.now(datetime.UTC),
444
+ risk_score=0,
445
+ )
446
+ session.add(scan_rec)
447
+ session.flush()
448
+
449
+ report = remediate(
450
+ project_path=project_path,
451
+ max_iterations=max_iterations,
452
+ dry_run=dry_run,
453
+ use_llm=not no_llm,
454
+ session=session,
455
+ scan_id=scan_rec.id,
456
+ )
457
+
458
+ scan_rec.finished_at = datetime.datetime.now(datetime.UTC)
459
+ scan_rec.risk_score = report.final_score
460
+ session.commit()
461
+
462
+ console.print()
463
+ from rich.panel import Panel as RichPanel
464
+
465
+ score_change = f"[red]{report.initial_score}[/] → [green]{report.final_score}[/]"
466
+ console.print(
467
+ RichPanel(
468
+ f"Initial Risk: [bold]{report.initial_score}[/] | Final Risk: [bold]{report.final_score}[/] ({score_change})\n"
469
+ f"Iterations: {report.iterations_run} | Resolved: [green]{report.resolved_count}[/] | "
470
+ f"Failed: [red]{report.failed_count}[/] | Manual: [yellow]{report.manual_count}[/]",
471
+ title="[bold][SUMMARY] Remediation Summary[/]",
472
+ )
473
+ )
474
+
475
+ if report.manual_findings:
476
+ m_table = RichTable(title="[!] Manual Human Action Required", header_style="bold yellow")
477
+ m_table.add_column("Target", width=25)
478
+ m_table.add_column("Rule / CWE", width=20)
479
+ m_table.add_column("Specific Reason / Next Steps")
480
+ for m in report.manual_findings:
481
+ m_table.add_row(m["target"], m["rule"], m["reason"])
482
+ console.print(m_table)
483
+
484
+
485
+ # --------------------------------------------------------------------------- #
486
+ # supplyguard web
487
+ # --------------------------------------------------------------------------- #
488
+
489
+
490
+ @main.command(name="web")
491
+ @click.option("--host", default="127.0.0.1", help="Dashboard bind address.")
492
+ @click.option("--port", default=5000, type=int, help="Dashboard port.")
493
+ @click.option("--db-path", type=click.Path(path_type=Path), default="supplyguard.db")
494
+ def web_cmd(host: str, port: int, db_path: Path) -> None:
495
+ """Launch the SupplyGuard web dashboard."""
496
+ from supplyguard.web.app import create_app
497
+
498
+ app = create_app(db_path=db_path)
499
+ console.print(f"[bold cyan][WEB] Dashboard:[/] http://{host}:{port}")
500
+ app.run(host=host, port=port, debug=False)
501
+
502
+
503
+ if __name__ == "__main__":
504
+ main()
supplyguard/config.py ADDED
@@ -0,0 +1,139 @@
1
+ """Project configuration loader for .supplyguard.yml files."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ CONFIG_FILENAME = ".supplyguard.yml"
13
+
14
+ # Default config template written by `supplyguard init`
15
+ DEFAULT_CONFIG_TEMPLATE = """\
16
+ # SupplyGuard Configuration
17
+ # https://github.com/supplyguard/supplyguard
18
+
19
+ # Risk score threshold for CI gates (0-100).
20
+ # `supplyguard scan` exits with code 1 if the score exceeds this value.
21
+ threshold: 100
22
+
23
+ # Default output format: table | json | sarif
24
+ format: table
25
+
26
+ # Minimum severity level to report. Findings below this are filtered out.
27
+ # Options: CRITICAL, HIGH, MEDIUM, LOW
28
+ severity_minimum: LOW
29
+
30
+ # Paths to exclude from scanning (glob patterns).
31
+ ignore_paths:
32
+ - ".venv/"
33
+ - "node_modules/"
34
+ - ".git/"
35
+ - "__pycache__/"
36
+
37
+ # Rule IDs or CWE IDs to suppress (e.g., CWE-489 for debug=True in dev).
38
+ ignore_rules: []
39
+ """
40
+
41
+
42
+ @dataclass
43
+ class SupplyGuardConfig:
44
+ """Resolved configuration for a SupplyGuard scan.
45
+
46
+ Attributes:
47
+ threshold: Risk score threshold for CI exit codes (0-100).
48
+ format: Output format (table, json, sarif).
49
+ severity_minimum: Minimum severity to include in results.
50
+ ignore_paths: Glob patterns for paths to skip during scanning.
51
+ ignore_rules: Rule IDs or CWE IDs to suppress from output.
52
+ """
53
+
54
+ threshold: int = 100
55
+ format: str = "table"
56
+ severity_minimum: str = "LOW"
57
+ ignore_paths: list[str] = field(default_factory=lambda: [".venv/", "node_modules/", ".git/", "__pycache__/"])
58
+ ignore_rules: list[str] = field(default_factory=list)
59
+
60
+
61
+ _SEVERITY_ORDER = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "INFO": 0}
62
+
63
+
64
+ def severity_meets_minimum(severity: str, minimum: str) -> bool:
65
+ """Check if a finding's severity meets the configured minimum.
66
+
67
+ Args:
68
+ severity: The finding's severity (e.g., "HIGH").
69
+ minimum: The minimum severity threshold (e.g., "MEDIUM").
70
+
71
+ Returns:
72
+ True if severity >= minimum.
73
+ """
74
+ return _SEVERITY_ORDER.get(severity.upper(), 0) >= _SEVERITY_ORDER.get(minimum.upper(), 0)
75
+
76
+
77
+ def load_config(project_path: Path, cli_overrides: dict[str, Any] | None = None) -> SupplyGuardConfig:
78
+ """Load configuration from .supplyguard.yml with CLI flag overrides.
79
+
80
+ Resolution order (highest priority first):
81
+ 1. CLI flags (--format, --threshold, etc.)
82
+ 2. .supplyguard.yml in project root
83
+ 3. Built-in defaults
84
+
85
+ Args:
86
+ project_path: Path to the project root to search for config.
87
+ cli_overrides: Dict of CLI flag values that override config file.
88
+
89
+ Returns:
90
+ Resolved SupplyGuardConfig instance.
91
+ """
92
+ config = SupplyGuardConfig()
93
+ config_file = Path(project_path) / CONFIG_FILENAME
94
+
95
+ if config_file.is_file():
96
+ try:
97
+ raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
98
+ if isinstance(raw, dict):
99
+ if "threshold" in raw:
100
+ config.threshold = int(raw["threshold"])
101
+ if "format" in raw:
102
+ config.format = str(raw["format"])
103
+ if "severity_minimum" in raw:
104
+ config.severity_minimum = str(raw["severity_minimum"]).upper()
105
+ if "ignore_paths" in raw and isinstance(raw["ignore_paths"], list):
106
+ config.ignore_paths = [str(p) for p in raw["ignore_paths"]]
107
+ if "ignore_rules" in raw and isinstance(raw["ignore_rules"], list):
108
+ config.ignore_rules = [str(r) for r in raw["ignore_rules"]]
109
+ logger.info(f"Loaded config from {config_file}")
110
+ except (yaml.YAMLError, ValueError, OSError) as err:
111
+ logger.warning(f"Failed to parse {config_file}: {err}. Using defaults.")
112
+
113
+ # CLI overrides take highest priority
114
+ if cli_overrides:
115
+ if cli_overrides.get("threshold") is not None:
116
+ config.threshold = int(cli_overrides["threshold"])
117
+ if cli_overrides.get("format") is not None:
118
+ config.format = str(cli_overrides["format"])
119
+
120
+ return config
121
+
122
+
123
+ def write_default_config(project_path: Path) -> Path:
124
+ """Write the default .supplyguard.yml template to a project directory.
125
+
126
+ Args:
127
+ project_path: Target project directory.
128
+
129
+ Returns:
130
+ Path to the created config file.
131
+
132
+ Raises:
133
+ FileExistsError: If .supplyguard.yml already exists.
134
+ """
135
+ config_file = Path(project_path) / CONFIG_FILENAME
136
+ if config_file.exists():
137
+ raise FileExistsError(f"{CONFIG_FILENAME} already exists at {config_file}")
138
+ config_file.write_text(DEFAULT_CONFIG_TEMPLATE, encoding="utf-8")
139
+ return config_file