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.
- suvoro/__init__.py +79 -0
- suvoro/_meta.py +17 -0
- suvoro/cli/__init__.py +1 -0
- suvoro/cli/commands/__init__.py +1 -0
- suvoro/cli/commands/audit.py +271 -0
- suvoro/cli/commands/baseline.py +33 -0
- suvoro/cli/commands/findings.py +98 -0
- suvoro/cli/commands/fix.py +138 -0
- suvoro/cli/commands/monitor.py +134 -0
- suvoro/cli/commands/prove.py +55 -0
- suvoro/cli/commands/report.py +43 -0
- suvoro/cli/commands/suppress.py +76 -0
- suvoro/cli/main.py +359 -0
- suvoro/client.py +466 -0
- suvoro/core/__init__.py +1 -0
- suvoro/core/config.py +421 -0
- suvoro/core/discovery.py +264 -0
- suvoro/core/logging.py +131 -0
- suvoro/core/paths.py +56 -0
- suvoro/core/redact.py +170 -0
- suvoro/core/schemas.py +376 -0
- suvoro/events/__init__.py +28 -0
- suvoro/events/types.py +148 -0
- suvoro/findings/__init__.py +1 -0
- suvoro/findings/baseline.py +54 -0
- suvoro/findings/identity.py +123 -0
- suvoro/findings/normalizer.py +113 -0
- suvoro/findings/prioritizer.py +71 -0
- suvoro/findings/suppression.py +89 -0
- suvoro/models/__init__.py +1 -0
- suvoro/models/budget.py +63 -0
- suvoro/models/estimator.py +60 -0
- suvoro/models/prompts.py +287 -0
- suvoro/models/provider.py +158 -0
- suvoro/models/triage.py +126 -0
- suvoro/options.py +100 -0
- suvoro/pipeline.py +420 -0
- suvoro/qa/__init__.py +1 -0
- suvoro/qa/agent.py +79 -0
- suvoro/remediation/__init__.py +1 -0
- suvoro/remediation/approval.py +54 -0
- suvoro/remediation/patcher.py +85 -0
- suvoro/remediation/safety.py +72 -0
- suvoro/remediation/verifier.py +168 -0
- suvoro/remediation/worktree.py +68 -0
- suvoro/reports/__init__.py +1 -0
- suvoro/reports/renderer.py +257 -0
- suvoro/result.py +121 -0
- suvoro/runtime/__init__.py +18 -0
- suvoro/runtime/detector.py +329 -0
- suvoro/runtime/ingest.py +398 -0
- suvoro/runtime/middleware.py +173 -0
- suvoro/runtime/monitor.py +266 -0
- suvoro/runtime/responder.py +212 -0
- suvoro/sandbox/__init__.py +3 -0
- suvoro/sandbox/docker.py +245 -0
- suvoro/sandbox/exploit_taxonomy.py +199 -0
- suvoro/sandbox/mock.py +187 -0
- suvoro/sandbox/prover.py +132 -0
- suvoro/sandbox/provider.py +21 -0
- suvoro/sandbox/redteam.py +286 -0
- suvoro/scanners/__init__.py +1 -0
- suvoro/scanners/bandit.py +100 -0
- suvoro/scanners/base.py +92 -0
- suvoro/scanners/checkov.py +117 -0
- suvoro/scanners/gitleaks.py +132 -0
- suvoro/scanners/installer.py +209 -0
- suvoro/scanners/nuclei.py +92 -0
- suvoro/scanners/osv.py +103 -0
- suvoro/scanners/pins.json +145 -0
- suvoro/scanners/registry.py +174 -0
- suvoro/scanners/semgrep.py +113 -0
- suvoro/scanners/syft.py +88 -0
- suvoro/scanners/trivy.py +131 -0
- suvoro/scanners/zap.py +126 -0
- suvoro/storage/__init__.py +1 -0
- suvoro/storage/ledger.py +557 -0
- suvoro/storage/models.py +183 -0
- suvoro-0.1.0.dist-info/METADATA +366 -0
- suvoro-0.1.0.dist-info/RECORD +83 -0
- suvoro-0.1.0.dist-info/WHEEL +4 -0
- suvoro-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|