suvoro 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.
Files changed (83) hide show
  1. suvoro/__init__.py +79 -0
  2. suvoro/_meta.py +17 -0
  3. suvoro/cli/__init__.py +1 -0
  4. suvoro/cli/commands/__init__.py +1 -0
  5. suvoro/cli/commands/audit.py +271 -0
  6. suvoro/cli/commands/baseline.py +33 -0
  7. suvoro/cli/commands/findings.py +98 -0
  8. suvoro/cli/commands/fix.py +138 -0
  9. suvoro/cli/commands/monitor.py +134 -0
  10. suvoro/cli/commands/prove.py +55 -0
  11. suvoro/cli/commands/report.py +43 -0
  12. suvoro/cli/commands/suppress.py +76 -0
  13. suvoro/cli/main.py +359 -0
  14. suvoro/client.py +466 -0
  15. suvoro/core/__init__.py +1 -0
  16. suvoro/core/config.py +421 -0
  17. suvoro/core/discovery.py +264 -0
  18. suvoro/core/logging.py +131 -0
  19. suvoro/core/paths.py +56 -0
  20. suvoro/core/redact.py +170 -0
  21. suvoro/core/schemas.py +376 -0
  22. suvoro/events/__init__.py +28 -0
  23. suvoro/events/types.py +148 -0
  24. suvoro/findings/__init__.py +1 -0
  25. suvoro/findings/baseline.py +54 -0
  26. suvoro/findings/identity.py +123 -0
  27. suvoro/findings/normalizer.py +113 -0
  28. suvoro/findings/prioritizer.py +71 -0
  29. suvoro/findings/suppression.py +89 -0
  30. suvoro/models/__init__.py +1 -0
  31. suvoro/models/budget.py +63 -0
  32. suvoro/models/estimator.py +60 -0
  33. suvoro/models/prompts.py +287 -0
  34. suvoro/models/provider.py +158 -0
  35. suvoro/models/triage.py +126 -0
  36. suvoro/options.py +100 -0
  37. suvoro/pipeline.py +420 -0
  38. suvoro/qa/__init__.py +1 -0
  39. suvoro/qa/agent.py +79 -0
  40. suvoro/remediation/__init__.py +1 -0
  41. suvoro/remediation/approval.py +54 -0
  42. suvoro/remediation/patcher.py +85 -0
  43. suvoro/remediation/safety.py +72 -0
  44. suvoro/remediation/verifier.py +168 -0
  45. suvoro/remediation/worktree.py +68 -0
  46. suvoro/reports/__init__.py +1 -0
  47. suvoro/reports/renderer.py +257 -0
  48. suvoro/result.py +121 -0
  49. suvoro/runtime/__init__.py +18 -0
  50. suvoro/runtime/detector.py +329 -0
  51. suvoro/runtime/ingest.py +398 -0
  52. suvoro/runtime/middleware.py +173 -0
  53. suvoro/runtime/monitor.py +266 -0
  54. suvoro/runtime/responder.py +212 -0
  55. suvoro/sandbox/__init__.py +3 -0
  56. suvoro/sandbox/docker.py +245 -0
  57. suvoro/sandbox/exploit_taxonomy.py +199 -0
  58. suvoro/sandbox/mock.py +187 -0
  59. suvoro/sandbox/prover.py +132 -0
  60. suvoro/sandbox/provider.py +21 -0
  61. suvoro/sandbox/redteam.py +286 -0
  62. suvoro/scanners/__init__.py +1 -0
  63. suvoro/scanners/bandit.py +100 -0
  64. suvoro/scanners/base.py +92 -0
  65. suvoro/scanners/checkov.py +117 -0
  66. suvoro/scanners/gitleaks.py +132 -0
  67. suvoro/scanners/installer.py +209 -0
  68. suvoro/scanners/nuclei.py +92 -0
  69. suvoro/scanners/osv.py +103 -0
  70. suvoro/scanners/pins.json +145 -0
  71. suvoro/scanners/registry.py +174 -0
  72. suvoro/scanners/semgrep.py +113 -0
  73. suvoro/scanners/syft.py +88 -0
  74. suvoro/scanners/trivy.py +131 -0
  75. suvoro/scanners/zap.py +126 -0
  76. suvoro/storage/__init__.py +1 -0
  77. suvoro/storage/ledger.py +557 -0
  78. suvoro/storage/models.py +183 -0
  79. suvoro-0.1.0.dist-info/METADATA +366 -0
  80. suvoro-0.1.0.dist-info/RECORD +83 -0
  81. suvoro-0.1.0.dist-info/WHEEL +4 -0
  82. suvoro-0.1.0.dist-info/entry_points.txt +2 -0
  83. suvoro-0.1.0.dist-info/licenses/LICENSE +20 -0
suvoro/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """Suvoro SDK — programmatic, headless interface to the Suvoro engine.
2
+
3
+ Quickstart::
4
+
5
+ import asyncio
6
+ from suvoro import SuvoroClient, ScanOptions
7
+ from suvoro.events import ScannerCompletedEvent, FindingDiscoveredEvent
8
+
9
+ async def main():
10
+ client = SuvoroClient(
11
+ model="openai/gpt-4o", # optional — only needed for AI steps
12
+ api_key="...", # or set OPENAI_API_KEY in environment
13
+ )
14
+
15
+ result = await client.audit.run(
16
+ workspace_path="./my-project",
17
+ options=ScanOptions(
18
+ scanners=["semgrep", "gitleaks", "osv", "bandit"],
19
+ triage=True,
20
+ ),
21
+ on_event=lambda evt: print(evt),
22
+ )
23
+
24
+ print(f"Found {len(result.findings)} issues")
25
+ for f in result.findings:
26
+ print(f" [{f.severity}] {f.title} — {f.file}:{f.line_start}")
27
+
28
+ asyncio.run(main())
29
+
30
+ Sync usage::
31
+
32
+ result = SuvoroClient().audit.run_sync("./my-project", ScanOptions())
33
+ """
34
+
35
+ from suvoro.client import AuditHandle, SuvoroClient
36
+ from suvoro.events import (
37
+ BaseEvent,
38
+ ErrorEvent,
39
+ FindingDiscoveredEvent,
40
+ PatchVerifiedEvent,
41
+ ProofCompletedEvent,
42
+ ScanCompletedEvent,
43
+ ScannerCompletedEvent,
44
+ ScannerStartedEvent,
45
+ ScanStartedEvent,
46
+ TriageStartedEvent,
47
+ )
48
+ from suvoro.options import BudgetOptions, ModelConfig, ScanOptions
49
+ from suvoro.result import AuditFinding, AuditResult, PlainEnglishSummary, ScannerSummary, VerifiedPatch
50
+
51
+ __all__ = [
52
+ # Main client
53
+ "SuvoroClient",
54
+ "AuditHandle",
55
+ # Options
56
+ "ScanOptions",
57
+ "ModelConfig",
58
+ "BudgetOptions",
59
+ # Results
60
+ "AuditResult",
61
+ "AuditFinding",
62
+ "VerifiedPatch",
63
+ "PlainEnglishSummary",
64
+ "ScannerSummary",
65
+ # Events
66
+ "BaseEvent",
67
+ "ScanStartedEvent",
68
+ "ScanCompletedEvent",
69
+ "ScannerStartedEvent",
70
+ "ScannerCompletedEvent",
71
+ "FindingDiscoveredEvent",
72
+ "TriageStartedEvent",
73
+ "ProofCompletedEvent",
74
+ "PatchVerifiedEvent",
75
+ "ErrorEvent",
76
+ ]
77
+
78
+ __version__ = "0.1.0"
79
+
suvoro/_meta.py ADDED
@@ -0,0 +1,17 @@
1
+ """Single source of truth for product naming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ PRODUCT_NAME = "Suvoro"
6
+ """Human-readable product name used in reports and banners."""
7
+
8
+ CLI_NAME = "suvoro"
9
+ """Executable name (must match ``[project.scripts]`` in pyproject.toml)."""
10
+
11
+ STATE_DIR_NAME = ".suvoro"
12
+ """Per-project state directory (ledger, reports, logs)."""
13
+
14
+ ENV_PREFIX = "SUVORO_"
15
+ """Prefix for configuration environment variables."""
16
+
17
+ USER_AGENT_NAME = "Suvoro"
suvoro/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # suvoro CLI subpackage
@@ -0,0 +1 @@
1
+ # suvoro CLI commands subpackage
@@ -0,0 +1,271 @@
1
+ """audit — scan, optional AI review steps, report.
2
+
3
+ Phases:
4
+
5
+ 1. Scan (always): discover -> scanners -> normalise -> triage (if a triage
6
+ model is configured) -> persist.
7
+ 2. Prove + red team (**experimental, off by default**): needs
8
+ ``--experimental-prove`` (or ``experimental.prove: true``), a configured
9
+ prover model, Docker, and gVisor unless ``sandbox.require_gvisor: false``.
10
+ Any missing precondition skips the phase with a clear message; the audit
11
+ still finishes and writes its report.
12
+ 3. Code-quality review (only if a ``models.qa`` model is configured).
13
+ 4. Report (always), including scanner coverage.
14
+
15
+ No model is ever chosen implicitly: an unconfigured step is skipped, never
16
+ silently routed to a default provider.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from datetime import datetime, timezone
23
+
24
+ from rich.console import Console
25
+
26
+ from suvoro.core.config import ModelRef, SuvoroConfig
27
+ from suvoro.core.logging import get_logger
28
+ from suvoro.core.schemas import Finding, Severity
29
+
30
+ log = get_logger("cli.audit")
31
+ # Progress and summaries go to stderr; only the report itself goes to stdout.
32
+ console = Console(stderr=True)
33
+
34
+ _PROVE_SEVERITIES = {Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM}
35
+ _SEVERITY_ORDER = [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO]
36
+ _INACTIVE = {"suppressed", "accepted_risk", "wont_fix", "resolved"}
37
+
38
+
39
+ def _model_configured(ref: ModelRef | None) -> bool:
40
+ return bool(ref and ref.model)
41
+
42
+
43
+ def _provider(ref: ModelRef):
44
+ from suvoro.models.provider import LiteLLMProvider
45
+
46
+ return LiteLLMProvider(
47
+ ref.full_name,
48
+ api_key=ref.resolved_api_key or None,
49
+ api_base=ref.api_base or None,
50
+ )
51
+
52
+
53
+ def _parse_severity(value: str | None, default: Severity) -> Severity:
54
+ if not value:
55
+ return default
56
+ try:
57
+ return Severity(value.strip().lower())
58
+ except ValueError:
59
+ console.print(f"[yellow]Unknown severity '{value}', using '{default.value}'.[/yellow]")
60
+ return default
61
+
62
+
63
+ def _run_prove_phase(
64
+ cfg: SuvoroConfig,
65
+ target: str,
66
+ findings: list[Finding],
67
+ *,
68
+ max_prove: int,
69
+ prove_severity: str | None,
70
+ ) -> None:
71
+ """Experimental: sandboxed checks for top findings, then a bounded red team."""
72
+ from suvoro.sandbox.docker import DockerSandboxProvider, SandboxUnavailable
73
+
74
+ if not _model_configured(cfg.models.prover):
75
+ console.print("[yellow]Skipping prove: no models.prover configured.[/yellow]")
76
+ return
77
+
78
+ sandbox = DockerSandboxProvider(cfg.sandbox)
79
+ try:
80
+ sandbox.check_available()
81
+ except SandboxUnavailable as e:
82
+ console.print(f"[yellow]Skipping prove/red team: {e}[/yellow]", markup=False)
83
+ return
84
+
85
+ if prove_severity:
86
+ allowed = {_parse_severity(prove_severity, Severity.HIGH)}
87
+ else:
88
+ allowed = _PROVE_SEVERITIES
89
+ provable = [f for f in findings if f.severity in allowed and f.status.value not in _INACTIVE]
90
+ if not provable:
91
+ console.print("[dim]No findings at provable severity.[/dim]")
92
+ return
93
+
94
+ from suvoro.sandbox.prover import ProverAgent
95
+ from suvoro.storage.ledger import SQLiteLedger
96
+
97
+ ledger = SQLiteLedger(cfg.db_path)
98
+ model = _provider(cfg.models.prover)
99
+ prover = ProverAgent(model=model, sandbox=sandbox, ledger=ledger, project_path=target)
100
+ to_prove = provable[:max_prove]
101
+ console.print(f"Checking {len(to_prove)} of {len(provable)} eligible findings (max {max_prove})...")
102
+ try:
103
+ prover.start_session()
104
+ for i, finding in enumerate(to_prove, 1):
105
+ console.print(f"[{i}/{len(to_prove)}] {finding.id} - {finding.title[:60]}", markup=False)
106
+ try:
107
+ attempt = prover.prove(finding)
108
+ ok = attempt.result.startswith("Exit Code: 0")
109
+ console.print(
110
+ f" {'confirmed by check' if ok else 'not confirmed'}: {attempt.hypothesis[:80]}",
111
+ markup=False,
112
+ )
113
+ except Exception as e:
114
+ console.print(f" check failed: {e}", markup=False)
115
+ log.warning("Prove failed for %s: %s", finding.id, e)
116
+
117
+ if cfg.experimental.redteam:
118
+ rt = cfg.models.redteam
119
+ if not (_model_configured(rt.planner) and _model_configured(rt.reviewer)):
120
+ console.print("[yellow]Skipping red team: planner/reviewer models not configured.[/yellow]")
121
+ else:
122
+ from suvoro.qa.agent import QAAgent
123
+ from suvoro.sandbox.redteam import run_redteam
124
+
125
+ console.print(
126
+ f"Running bounded red team (max {rt.max_iterations} iterations, "
127
+ f"{rt.max_llm_calls} LLM calls, {rt.max_seconds}s)..."
128
+ )
129
+ try:
130
+ final_state = run_redteam({
131
+ "project_context": QAAgent(model=model, project_path=target)._gather_context(),
132
+ "planner_model": _provider(rt.planner),
133
+ "reviewer_model": _provider(rt.reviewer),
134
+ "sandbox": sandbox,
135
+ "ledger": ledger,
136
+ "container_id": prover._active_container,
137
+ "mock_env": prover.mock_env,
138
+ "max_iterations": rt.max_iterations,
139
+ "max_llm_calls": rt.max_llm_calls,
140
+ "max_seconds": rt.max_seconds,
141
+ "timeout_per_attempt": rt.timeout_per_attempt,
142
+ })
143
+ for attempt in final_state.get("completed_attempts", []):
144
+ ok = attempt.result.startswith("Exit Code: 0")
145
+ console.print(f" {'confirmed' if ok else 'not confirmed'}: {attempt.hypothesis[:80]}", markup=False)
146
+ console.print(f" Red team stopped: {final_state.get('stop_reason') or 'done'}", markup=False)
147
+ except Exception as e:
148
+ console.print(f" Red team phase failed: {e}", markup=False)
149
+ log.warning("Red Team failed: %s", e)
150
+ finally:
151
+ prover.stop_session()
152
+
153
+
154
+ def _run_qa_phase(cfg: SuvoroConfig, target: str) -> None:
155
+ if not _model_configured(cfg.models.qa):
156
+ console.print("[dim]Skipping code-quality review: no models.qa configured.[/dim]")
157
+ return
158
+ from suvoro.qa.agent import QAAgent
159
+
160
+ try:
161
+ qa_report = QAAgent(model=_provider(cfg.models.qa), project_path=target).run_qa()
162
+ except Exception as e:
163
+ console.print(f"Code-quality review failed: {e}", markup=False)
164
+ log.warning("QA phase failed: %s", e)
165
+ return
166
+ console.print("Code-quality review complete.")
167
+ console.print(f"Summary: {qa_report.summary}", markup=False)
168
+ for title, items in (
169
+ ("Production risks", qa_report.production_risks),
170
+ ("Observability gaps", qa_report.logging_suggestions),
171
+ ("Possibly stale code", qa_report.stale_code_flags),
172
+ ):
173
+ if items:
174
+ console.print(f"\n{title}:")
175
+ for it in items:
176
+ console.print(f" - {it}", markup=False)
177
+
178
+
179
+ def run_audit(
180
+ cfg: SuvoroConfig,
181
+ target: str,
182
+ *,
183
+ max_prove: int = 10,
184
+ prove_severity: str | None = None,
185
+ output_format: str = "markdown",
186
+ min_severity: str = "low",
187
+ experimental_prove: bool = False,
188
+ dast_url: str | None = None,
189
+ ) -> int:
190
+ """Execute the audit. Never raises for missing optional dependencies."""
191
+ import sys
192
+
193
+ from suvoro.pipeline import execute_scan, print_scanner_summary
194
+ from suvoro.reports.renderer import render_report
195
+ from suvoro.storage.ledger import SQLiteLedger
196
+
197
+ audit_start = time.monotonic()
198
+ durations: dict[str, float] = {}
199
+ min_sev = _parse_severity(min_severity, Severity.LOW)
200
+
201
+ # Phase 1
202
+ console.rule("Phase 1: Scanning")
203
+ t = time.monotonic()
204
+ state = execute_scan(target, cfg, dast_url=dast_url)
205
+ durations["scan"] = time.monotonic() - t
206
+ if state.get("errors"):
207
+ console.print("Scan failed: " + "; ".join(state["errors"]), markup=False)
208
+ return 2
209
+ scanners = state.get("scan_results", [])
210
+ findings = [Finding(**f) for f in state.get("findings", [])]
211
+
212
+ # Phase 2 (experimental)
213
+ if experimental_prove or cfg.experimental.prove:
214
+ console.rule("Phase 2: Sandboxed checks (experimental)")
215
+ t = time.monotonic()
216
+ try:
217
+ _run_prove_phase(cfg, target, findings, max_prove=max_prove, prove_severity=prove_severity)
218
+ except Exception as e: # never lose the report because of an optional phase
219
+ console.print(f"Prove phase failed: {e}", markup=False)
220
+ log.exception("Prove phase failed")
221
+ durations["prove"] = time.monotonic() - t
222
+ else:
223
+ console.print("[dim]Prove/red team are experimental and off (use --experimental-prove).[/dim]")
224
+
225
+ # Phase 3
226
+ console.rule("Phase 3: Code-quality review")
227
+ t = time.monotonic()
228
+ _run_qa_phase(cfg, target)
229
+ durations["qa"] = time.monotonic() - t
230
+
231
+ # Phase 4
232
+ console.rule("Phase 4: Report")
233
+ ledger = SQLiteLedger(cfg.db_path)
234
+ all_final = ledger.latest_findings()
235
+ cutoff = _SEVERITY_ORDER.index(min_sev)
236
+ final = [f for f in all_final if _SEVERITY_ORDER.index(f.severity) <= cutoff]
237
+ report_text = render_report(final, output_format, scanners=scanners)
238
+
239
+ try:
240
+ reports_dir = cfg.state_dir / "reports"
241
+ reports_dir.mkdir(parents=True, exist_ok=True)
242
+ ext = "json" if output_format.lower() == "json" else ("sarif" if output_format.lower() == "sarif" else "md")
243
+ now_str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
244
+ stamped = reports_dir / f"audit_report_{now_str}.{ext}"
245
+ latest = reports_dir / f"latest_audit.{ext}"
246
+ stamped.write_text(report_text, encoding="utf-8")
247
+ latest.write_text(report_text, encoding="utf-8")
248
+ console.print(f"Report saved to: {stamped}", markup=False, highlight=False)
249
+ except OSError as e:
250
+ log.warning("Could not write audit report: %s", e)
251
+
252
+ sys.stdout.write(report_text if report_text.endswith("\n") else report_text + "\n")
253
+ sys.stdout.flush()
254
+
255
+ print_scanner_summary(scanners, console)
256
+ active = [f for f in final if f.status.value not in _INACTIVE]
257
+ critical = sum(1 for f in active if f.severity == Severity.CRITICAL)
258
+ high = sum(1 for f in active if f.severity == Severity.HIGH)
259
+ total = time.monotonic() - audit_start
260
+ console.print(
261
+ f"{len(active)} active findings at or above {min_sev.value} "
262
+ f"({critical} critical, {high} high) in {total:.1f}s",
263
+ markup=False,
264
+ )
265
+ log.info("Audit finished in %.2fs (%s)", total, durations)
266
+
267
+ if active:
268
+ return 1
269
+ if not state.get("complete"):
270
+ return 2
271
+ return 0
@@ -0,0 +1,33 @@
1
+ """baseline command implementation — FR-03."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+
7
+ from suvoro.core.config import SuvoroConfig
8
+ from suvoro.core.schemas import BaselineSnapshot
9
+ from suvoro.findings.baseline import compute_fingerprint
10
+
11
+ console = Console()
12
+
13
+
14
+ def manage_baseline(cfg: SuvoroConfig, action: str) -> None:
15
+ """Manage scan baselines."""
16
+ from suvoro.storage.ledger import SQLiteLedger
17
+
18
+ ledger = SQLiteLedger(cfg.db_path)
19
+
20
+ if action == "update":
21
+ findings = ledger.latest_findings()
22
+ fingerprints = [compute_fingerprint(f) for f in findings]
23
+ baseline = BaselineSnapshot(
24
+ fingerprints=fingerprints,
25
+ created_by="user",
26
+ )
27
+ ledger.save_baseline(baseline)
28
+ console.print(
29
+ f"[green]Baseline updated with {len(fingerprints)} finding(s).[/green]\n"
30
+ f" Baseline ID: {baseline.id}"
31
+ )
32
+ else:
33
+ console.print(f"[red]Unknown baseline action: {action}[/red]")
@@ -0,0 +1,98 @@
1
+ """findings / show command implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ from suvoro.core.config import SuvoroConfig
10
+ from suvoro.core.schemas import Severity
11
+ from suvoro.remediation.safety import sanitize_for_terminal
12
+ from suvoro.storage.ledger import SQLiteLedger
13
+
14
+ console = Console()
15
+
16
+ _SEVERITY_COLORS = {
17
+ Severity.CRITICAL: "bold red",
18
+ Severity.HIGH: "red",
19
+ Severity.MEDIUM: "yellow",
20
+ Severity.LOW: "cyan",
21
+ Severity.INFO: "dim",
22
+ }
23
+
24
+
25
+ def list_findings(cfg: SuvoroConfig, *, status: str | None, severity: str | None) -> None:
26
+ """List findings with optional filters."""
27
+ ledger = SQLiteLedger(cfg.db_path)
28
+ filters = {}
29
+ if status:
30
+ filters["status"] = status
31
+ if severity:
32
+ filters["severity"] = severity
33
+
34
+ findings = ledger.latest_findings(**filters)
35
+ if not findings:
36
+ console.print("[dim]No findings found.[/dim]")
37
+ return
38
+
39
+ table = Table(title="suvoro Findings", show_lines=True)
40
+ table.add_column("ID", style="bold")
41
+ table.add_column("Severity")
42
+ table.add_column("Status")
43
+ table.add_column("Source")
44
+ table.add_column("Title", max_width=50)
45
+ table.add_column("File")
46
+ table.add_column("Score", justify="right")
47
+
48
+ for f in sorted(findings, key=lambda x: x.risk_score, reverse=True):
49
+ sev_style = _SEVERITY_COLORS.get(f.severity, "")
50
+ table.add_row(
51
+ f.id,
52
+ f"[{sev_style}]{f.severity.value.upper()}[/{sev_style}]",
53
+ f.status.value,
54
+ Text(f.source),
55
+ Text(sanitize_for_terminal(f.title)),
56
+ Text(f"{f.file}:{f.line_start}" if f.line_start else f.file),
57
+ f"{f.risk_score:.1f}",
58
+ )
59
+
60
+ console.print(table)
61
+ console.print(f"\n[bold]{len(findings)}[/bold] finding(s) total.")
62
+
63
+
64
+ def show_finding(cfg: SuvoroConfig, finding_id: str) -> None:
65
+ """Show detailed info for a single finding."""
66
+ ledger = SQLiteLedger(cfg.db_path)
67
+ finding = ledger.get_finding(finding_id)
68
+ if finding is None:
69
+ console.print(Text(f"Finding {finding_id} not found."))
70
+ return
71
+
72
+ console.print(f"\n[bold]Finding {finding.id}[/bold]")
73
+ console.print(Text(sanitize_for_terminal(f" Title: {finding.title}")))
74
+ console.print(Text(sanitize_for_terminal(f" Source: {finding.source}")))
75
+ console.print(Text(sanitize_for_terminal(f" Category: {finding.category}")))
76
+ console.print(Text(sanitize_for_terminal(f" Severity: {finding.severity.value}")))
77
+ console.print(Text(sanitize_for_terminal(f" Confidence: {finding.confidence:.0%}")))
78
+ console.print(Text(sanitize_for_terminal(f" Status: {finding.status.value}")))
79
+ console.print(Text(sanitize_for_terminal(f" Risk Score: {finding.risk_score:.1f}")))
80
+ console.print(Text(sanitize_for_terminal(f" File: {finding.file}")))
81
+ if finding.line_start:
82
+ console.print(Text(sanitize_for_terminal(f" Lines: {finding.line_start}-{finding.line_end or finding.line_start}")))
83
+ if finding.rule_id:
84
+ console.print(Text(sanitize_for_terminal(f" Rule: {finding.rule_id}")))
85
+ if finding.cve:
86
+ console.print(Text(sanitize_for_terminal(f" CVE: {finding.cve}")))
87
+ if finding.osv_id:
88
+ console.print(Text(sanitize_for_terminal(f" OSV: {finding.osv_id}")))
89
+ if finding.package:
90
+ console.print(Text(sanitize_for_terminal(f" Package: {finding.package}@{finding.version}")))
91
+ console.print(Text(sanitize_for_terminal(f" Description: {finding.description}")))
92
+
93
+ # Show evidence
94
+ evidence = ledger.get_evidence(finding_id)
95
+ if evidence:
96
+ console.print(f"\n [bold]Evidence ({len(evidence)} item(s)):[/bold]")
97
+ for e in evidence:
98
+ console.print(Text(f" ({e.kind.value}) from {e.source} @ {e.created_at}"))
@@ -0,0 +1,138 @@
1
+ """fix command: generate a patch, verify it in a throwaway copy, apply on approval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+ from rich.text import Text
9
+
10
+ from suvoro.core.config import SuvoroConfig
11
+ from suvoro.core.schemas import Finding, FindingStatus, StatusChange
12
+
13
+ console = Console(stderr=True)
14
+
15
+ _INACTIVE = (
16
+ FindingStatus.SUPPRESSED,
17
+ FindingStatus.ACCEPTED_RISK,
18
+ FindingStatus.WONT_FIX,
19
+ FindingStatus.RESOLVED,
20
+ )
21
+
22
+
23
+ def _show(label: str, value: str) -> None:
24
+ """Print untrusted text with no markup and no terminal control sequences."""
25
+ from suvoro.remediation.safety import sanitize_for_terminal
26
+
27
+ console.print(Text(f"{label}{sanitize_for_terminal(value)}"))
28
+
29
+
30
+ def _process(cfg: SuvoroConfig, finding: Finding, *, auto_approve: bool, run_tests: bool) -> str:
31
+ """Handle one finding. Returns 'applied' | 'skipped' | 'failed' | 'declined'."""
32
+ from suvoro.remediation.approval import apply_patch, describe, prompt_approval
33
+ from suvoro.remediation.patcher import generate_patch
34
+ from suvoro.remediation.safety import check_patch_scope
35
+ from suvoro.remediation.verifier import verify_patch
36
+ from suvoro.storage.ledger import SQLiteLedger
37
+
38
+ proposal = generate_patch(cfg, finding)
39
+ if not proposal.diff:
40
+ console.print("No patch could be generated (is models.remediation configured?).")
41
+ return "skipped"
42
+
43
+ _show("Root cause: ", proposal.root_cause)
44
+ _show("Impact: ", proposal.impact)
45
+ console.print(Text("\nProposed diff:"))
46
+ _show("", proposal.diff)
47
+
48
+ in_scope, why = check_patch_scope(proposal.diff, {finding.file})
49
+ if not in_scope:
50
+ console.print(Text(f"Rejected: {why}. Patches may only change the finding's own file."))
51
+ return "failed"
52
+
53
+ console.print(Text("\nVerifying in a throwaway copy of the project..."))
54
+ result = verify_patch(cfg, finding, proposal, run_tests=run_tests)
55
+ console.print(Text(f" Applies cleanly: {'yes' if result.applied_cleanly else 'NO'}"))
56
+ console.print(Text(f" Tests: {describe(result.tests_passed)}"))
57
+ console.print(Text(f" Scanner re-check: {describe(result.scanner_clear)}"))
58
+ if result.regression_test_passed is not None or run_tests:
59
+ console.print(Text(f" Regression test: {describe(result.regression_test_passed)}"))
60
+ _show(" Details: ", result.details)
61
+
62
+ if result.any_failed:
63
+ console.print(Text("Verification failed. Not applying."))
64
+ return "failed"
65
+
66
+ if auto_approve:
67
+ approved = True
68
+ if not result.fully_verified:
69
+ console.print(Text("--yes: applying a patch that is only partly verified (see above)."))
70
+ else:
71
+ approved = prompt_approval(proposal, result)
72
+ if not approved:
73
+ console.print(Text("Fix not applied."))
74
+ return "declined"
75
+
76
+ ok, details = apply_patch(Path(cfg.project_root), proposal.diff)
77
+ if not ok:
78
+ console.print(Text(f"Failed to apply the patch to the project: {details}"))
79
+ return "failed"
80
+
81
+ ledger = SQLiteLedger(cfg.db_path)
82
+ new_status = FindingStatus.FIX_VERIFIED if result.fully_verified else FindingStatus.FIX_PROPOSED
83
+ ledger.update_finding_status(StatusChange(
84
+ finding_id=finding.id, old_status=finding.status, new_status=new_status,
85
+ actor="user" if not auto_approve else "user(--yes)",
86
+ reason="patch applied" + ("" if result.fully_verified else " (partly unverified)"),
87
+ ))
88
+ console.print(Text(f"Fix applied to {finding.file}. Review it with `git diff` and re-scan."))
89
+ return "applied"
90
+
91
+
92
+ def fix_finding(
93
+ cfg: SuvoroConfig, finding_id: str, *, auto_approve: bool = False, run_tests: bool = False
94
+ ) -> int:
95
+ from suvoro.storage.ledger import SQLiteLedger
96
+
97
+ ledger = SQLiteLedger(cfg.db_path)
98
+ finding = ledger.get_finding(finding_id)
99
+ if finding is None:
100
+ console.print(Text(f"Finding {finding_id} not found."))
101
+ return 2
102
+ console.print(Text(f"\nGenerating fix for {finding_id}..."))
103
+ outcome = _process(cfg, finding, auto_approve=auto_approve, run_tests=run_tests)
104
+ return 0 if outcome in ("applied", "declined") else 1
105
+
106
+
107
+ def fix_all_findings(
108
+ cfg: SuvoroConfig,
109
+ *,
110
+ severity: str | None = None,
111
+ auto_approve: bool = False,
112
+ run_tests: bool = False,
113
+ ) -> int:
114
+ """Batch fix active findings from the latest scan."""
115
+ from suvoro.storage.ledger import SQLiteLedger
116
+
117
+ ledger = SQLiteLedger(cfg.db_path)
118
+ active = [f for f in ledger.latest_findings() if f.status not in _INACTIVE]
119
+ active = [f for f in active if f.category not in ("sbom",)]
120
+ if severity:
121
+ active = [f for f in active if f.severity.value == severity.lower()]
122
+ if not active:
123
+ console.print("No actionable findings to fix.")
124
+ return 0
125
+
126
+ console.print(Text(f"\nFixing {len(active)} finding(s)...\n"))
127
+ counts: dict[str, int] = {}
128
+ for i, finding in enumerate(active, 1):
129
+ console.rule(Text(f"[{i}/{len(active)}] {finding.id} - {finding.title[:60]}"))
130
+ outcome = _process(cfg, finding, auto_approve=auto_approve, run_tests=run_tests)
131
+ counts[outcome] = counts.get(outcome, 0) + 1
132
+ console.rule("Batch remediation complete")
133
+ console.print(Text(
134
+ f"applied {counts.get('applied', 0)}, failed {counts.get('failed', 0)}, "
135
+ f"declined {counts.get('declined', 0)}, skipped {counts.get('skipped', 0)} "
136
+ f"of {len(active)}"
137
+ ))
138
+ return 1 if counts.get("failed") else 0