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.
- supplyguard/__init__.py +3 -0
- supplyguard/cli.py +504 -0
- supplyguard/config.py +139 -0
- supplyguard/formatters.py +301 -0
- supplyguard/models.py +113 -0
- supplyguard/remediation/__init__.py +28 -0
- supplyguard/remediation/classifier.py +104 -0
- supplyguard/remediation/deterministic_fixer.py +142 -0
- supplyguard/remediation/llm_fixer.py +202 -0
- supplyguard/remediation/loop.py +234 -0
- supplyguard/remediation/verifier.py +156 -0
- supplyguard/sast/__init__.py +5 -0
- supplyguard/sast/scanner.py +334 -0
- supplyguard/sbom/__init__.py +5 -0
- supplyguard/sbom/generator.py +153 -0
- supplyguard/scoring/__init__.py +5 -0
- supplyguard/scoring/engine.py +96 -0
- supplyguard/secrets/__init__.py +5 -0
- supplyguard/secrets/scanner.py +168 -0
- supplyguard/vulns/__init__.py +5 -0
- supplyguard/vulns/osv_client.py +251 -0
- supplyguard/web/__init__.py +5 -0
- supplyguard/web/app.py +35 -0
- supplyguard/web/routes.py +152 -0
- supplyguard-0.3.0.dist-info/METADATA +388 -0
- supplyguard-0.3.0.dist-info/RECORD +30 -0
- supplyguard-0.3.0.dist-info/WHEEL +5 -0
- supplyguard-0.3.0.dist-info/entry_points.txt +2 -0
- supplyguard-0.3.0.dist-info/licenses/LICENSE +21 -0
- supplyguard-0.3.0.dist-info/top_level.txt +1 -0
supplyguard/__init__.py
ADDED
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
|