redrun-scan 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.
redrun/__init__.py ADDED
File without changes
redrun/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from redrun.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
redrun/app/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """RedRun local control plane — the FastAPI app behind `redrun serve`."""
redrun/app/auth.py ADDED
@@ -0,0 +1,50 @@
1
+ """Per-launch local API token + same-origin guard.
2
+
3
+ The control plane runs an exploitation engine, so the only caller allowed is the
4
+ app's own webview. We require a per-launch token header AND reject any cross-site
5
+ Origin (defeats a malicious page reaching 127.0.0.1 via DNS-rebinding/CSRF).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hmac
10
+ import secrets
11
+ from urllib.parse import urlparse
12
+
13
+ from fastapi import Header, HTTPException
14
+ from typing import Optional
15
+
16
+
17
+ def is_allowed_origin(origin: Optional[str]) -> bool:
18
+ """True if the Origin is same-origin/loopback or the Tauri shell, or absent
19
+ (non-browser client). A foreign site is rejected (anti DNS-rebind/CSRF).
20
+
21
+ Uses exact hostname matching (not prefix) so spoofed domains like
22
+ http://127.0.0.1.evil.com cannot bypass the guard.
23
+ """
24
+ if origin is None:
25
+ return True
26
+ parsed = urlparse(origin)
27
+ host = parsed.hostname # None for schemes like tauri:// without //host
28
+ return (
29
+ host in ("127.0.0.1", "localhost", "::1")
30
+ or (parsed.scheme == "tauri" and host in ("localhost", None))
31
+ )
32
+
33
+
34
+ class LocalAuth:
35
+ def __init__(self, token: str):
36
+ self.token = token
37
+
38
+ @staticmethod
39
+ def new_token() -> str:
40
+ return secrets.token_urlsafe(32)
41
+
42
+ async def require(
43
+ self,
44
+ x_redrun_token: Optional[str] = Header(default=None),
45
+ origin: Optional[str] = Header(default=None),
46
+ ) -> None:
47
+ if not is_allowed_origin(origin):
48
+ raise HTTPException(status_code=403, detail="Cross-origin blocked")
49
+ if not x_redrun_token or not hmac.compare_digest(x_redrun_token, self.token):
50
+ raise HTTPException(status_code=401, detail="Invalid local token")
redrun/app/db.py ADDED
@@ -0,0 +1,57 @@
1
+ """SQLite connection + schema for the local control plane."""
2
+ from __future__ import annotations
3
+
4
+ import sqlite3
5
+
6
+ _SCHEMA = """
7
+ CREATE TABLE IF NOT EXISTS targets (
8
+ id TEXT PRIMARY KEY,
9
+ label TEXT NOT NULL,
10
+ host_or_url TEXT NOT NULL,
11
+ scope TEXT NOT NULL DEFAULT '[]', -- JSON list of hosts/CIDRs
12
+ authorized INTEGER NOT NULL DEFAULT 0,
13
+ enabled_phases TEXT NOT NULL DEFAULT '[]', -- JSON list of phase names
14
+ tags TEXT NOT NULL DEFAULT '[]', -- JSON list
15
+ created_at TEXT NOT NULL
16
+ );
17
+ CREATE TABLE IF NOT EXISTS scans (
18
+ id TEXT PRIMARY KEY,
19
+ target_id TEXT NOT NULL REFERENCES targets(id) ON DELETE CASCADE,
20
+ mode TEXT NOT NULL,
21
+ status TEXT NOT NULL,
22
+ phase TEXT,
23
+ progress INTEGER NOT NULL DEFAULT 0,
24
+ started_at TEXT,
25
+ completed_at TEXT,
26
+ duration REAL,
27
+ report TEXT NOT NULL DEFAULT '{}',
28
+ source TEXT NOT NULL DEFAULT 'manual'
29
+ );
30
+ CREATE INDEX IF NOT EXISTS idx_scans_target ON scans(target_id);
31
+ CREATE TABLE IF NOT EXISTS settings (
32
+ key TEXT PRIMARY KEY,
33
+ value TEXT NOT NULL
34
+ );
35
+ """
36
+
37
+
38
+ def connect(path: str) -> sqlite3.Connection:
39
+ conn = sqlite3.connect(path, check_same_thread=False)
40
+ conn.row_factory = sqlite3.Row
41
+ conn.execute("PRAGMA foreign_keys = ON")
42
+ conn.execute("PRAGMA journal_mode = WAL")
43
+ return conn
44
+
45
+
46
+ def _migrate(conn: sqlite3.Connection) -> None:
47
+ """Idempotent column adds for DBs created before a column existed."""
48
+ cols = {r["name"] for r in conn.execute("PRAGMA table_info(scans)")}
49
+ if "source" not in cols:
50
+ conn.execute(
51
+ "ALTER TABLE scans ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'")
52
+
53
+
54
+ def init_schema(conn: sqlite3.Connection) -> None:
55
+ conn.executescript(_SCHEMA)
56
+ _migrate(conn)
57
+ conn.commit()
redrun/app/events.py ADDED
@@ -0,0 +1,31 @@
1
+ """In-process async pub/sub. One queue per WebSocket subscriber, keyed by scan id.
2
+ A small per-scan backlog lets a client that connects mid-scan replay the events it
3
+ missed; the authoritative final state is always in GET /v1/scans/{id}."""
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from collections import defaultdict, deque
8
+
9
+
10
+ class EventHub:
11
+ def __init__(self, backlog: int = 64):
12
+ self._subs: dict[str, list[asyncio.Queue]] = defaultdict(list)
13
+ self._backlog: dict[str, deque] = defaultdict(lambda: deque(maxlen=backlog))
14
+
15
+ def subscribe(self, scan_id: str) -> asyncio.Queue:
16
+ q: asyncio.Queue = asyncio.Queue()
17
+ for event in self._backlog.get(scan_id, ()): # replay what was missed
18
+ q.put_nowait(event)
19
+ self._subs[scan_id].append(q)
20
+ return q
21
+
22
+ def unsubscribe(self, scan_id: str, q: asyncio.Queue) -> None:
23
+ if q in self._subs.get(scan_id, []):
24
+ self._subs[scan_id].remove(q)
25
+ if not self._subs.get(scan_id):
26
+ self._subs.pop(scan_id, None)
27
+
28
+ async def publish(self, scan_id: str, event: dict) -> None:
29
+ self._backlog[scan_id].append(event)
30
+ for q in list(self._subs.get(scan_id, [])):
31
+ await q.put(event)
redrun/app/models.py ADDED
@@ -0,0 +1,60 @@
1
+ """Local control-plane domain types. Finding is reused from the engine schema."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Optional
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class Phase(str, Enum):
12
+ RECON = "recon"
13
+ ENUMERATION = "enumeration"
14
+ VULN_ASSESSMENT = "vuln_assessment"
15
+ EXPLOITATION = "exploitation"
16
+ EXPOSURE = "exposure"
17
+
18
+
19
+ # Phases an ACTIVE scan may run; passive excludes EXPLOITATION.
20
+ PASSIVE_PHASES = [Phase.RECON, Phase.ENUMERATION, Phase.VULN_ASSESSMENT, Phase.EXPOSURE]
21
+ ACTIVE_PHASES = [Phase.RECON, Phase.ENUMERATION, Phase.VULN_ASSESSMENT,
22
+ Phase.EXPLOITATION, Phase.EXPOSURE]
23
+
24
+
25
+ class ScanStatus(str, Enum):
26
+ PENDING = "pending"
27
+ RUNNING = "running"
28
+ COMPLETED = "completed"
29
+ FAILED = "failed"
30
+ INTERRUPTED = "interrupted"
31
+
32
+
33
+ class Target(BaseModel):
34
+ id: str
35
+ label: str
36
+ host_or_url: str
37
+ scope: list[str] = Field(default_factory=list)
38
+ authorized: bool = False
39
+ enabled_phases: list[str] = Field(default_factory=list)
40
+ tags: list[str] = Field(default_factory=list)
41
+ created_at: datetime = Field(default_factory=datetime.utcnow)
42
+ # rolled-up display state (computed from scans, not stored on the row)
43
+ status: str = "idle"
44
+ last_scan_at: Optional[datetime] = None
45
+ latest_risk_score: Optional[int] = None
46
+ open_findings: dict = Field(default_factory=dict)
47
+
48
+
49
+ class ScanRecord(BaseModel):
50
+ id: str
51
+ target_id: str
52
+ mode: str = "passive"
53
+ status: ScanStatus = ScanStatus.PENDING
54
+ phase: Optional[str] = None
55
+ progress: int = 0
56
+ started_at: Optional[datetime] = None
57
+ completed_at: Optional[datetime] = None
58
+ duration: Optional[float] = None
59
+ report: dict = Field(default_factory=dict)
60
+ source: str = "manual"
redrun/app/phases.py ADDED
@@ -0,0 +1,257 @@
1
+ """Phase-structured tool layer.
2
+
3
+ Each phase holds adapters. A built-in adapter wraps the existing engine; external
4
+ tools (e.g. sslyze) are additional adapters. Every adapter MUST return verified,
5
+ proof-backed Findings — raw tool output is never surfaced (zero-FP gate).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import abc
10
+ import asyncio
11
+ import hashlib
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from typing import TYPE_CHECKING, ClassVar, Optional
15
+
16
+ from redrun.app.models import Phase
17
+ from redrun.engine.schemas import AttackSurfaceMap, Finding
18
+
19
+ if TYPE_CHECKING:
20
+ from redrun.engine.scope import ScopeValidator
21
+
22
+
23
+ @dataclass
24
+ class ScanContext:
25
+ """Shared state threaded through the phases of one scan."""
26
+ host: str # bare host (no scheme/port)
27
+ target: str # original target (may include :port)
28
+ scope: Optional["ScopeValidator"] # ScopeValidator | None (set for active)
29
+ mode: str # passive | active
30
+ surface: Optional[AttackSurfaceMap] = None
31
+ findings: list[Finding] = field(default_factory=list)
32
+
33
+
34
+ class ToolAdapter(abc.ABC):
35
+ name: ClassVar[str] = "adapter"
36
+ phase: ClassVar[Phase] = Phase.VULN_ASSESSMENT
37
+
38
+ def available(self) -> bool:
39
+ return True
40
+
41
+ @abc.abstractmethod
42
+ async def run(self, ctx: ScanContext) -> list[Finding]:
43
+ ...
44
+
45
+
46
+ # ── built-in adapters: wrap the existing engine ─────────────────────────────
47
+ class ReconBuiltin(ToolAdapter):
48
+ name = "recon_builtin"
49
+ phase = Phase.RECON
50
+
51
+ async def run(self, ctx: ScanContext) -> list[Finding]:
52
+ from redrun.engine.recon import ReconAgent
53
+ surface = await ReconAgent().run(ctx.host)
54
+ if isinstance(surface, Exception):
55
+ surface = AttackSurfaceMap(target=ctx.target, scan_time=datetime.utcnow())
56
+ ctx.surface = surface
57
+ return [] # recon is discovery; it produces no findings itself
58
+
59
+
60
+ class EnumerationBuiltin(ToolAdapter):
61
+ name = "enum_builtin"
62
+ phase = Phase.ENUMERATION
63
+
64
+ async def run(self, ctx: ScanContext) -> list[Finding]:
65
+ # Endpoint/tech enumeration is folded into recon's surface today; the
66
+ # active checks in vuln-assessment consume it. No standalone findings yet.
67
+ return []
68
+
69
+
70
+ class VulnAssessmentBuiltin(ToolAdapter):
71
+ name = "vuln_builtin"
72
+ phase = Phase.VULN_ASSESSMENT
73
+
74
+ async def run(self, ctx: ScanContext) -> list[Finding]:
75
+ from redrun.engine.active_scanner import ActiveScanner
76
+ from redrun.engine.nuclei_scanner import NucleiScanner
77
+ subs = ctx.surface.subdomains if ctx.surface else []
78
+ # NOTE: call ActiveScanner.scan with the VENDORED engine's signature
79
+ # (target, subdomains) — the vendored copy predates the backend's
80
+ # `active=` param. Active vs passive is expressed by whether the
81
+ # EXPLOITATION phase runs, not by a flag here.
82
+ passive, nuclei = await asyncio.gather(
83
+ ActiveScanner().scan(ctx.target, subs),
84
+ NucleiScanner().scan(ctx.host),
85
+ return_exceptions=True)
86
+ out: list[Finding] = []
87
+ out += passive if isinstance(passive, list) else []
88
+ out += nuclei if isinstance(nuclei, list) else []
89
+ return out
90
+
91
+
92
+ class ExploitationBuiltin(ToolAdapter):
93
+ name = "exploit_builtin"
94
+ phase = Phase.EXPLOITATION
95
+
96
+ async def run(self, ctx: ScanContext) -> list[Finding]:
97
+ if ctx.mode != "active" or ctx.scope is None or ctx.surface is None:
98
+ return []
99
+ from redrun.engine.exploitation import ExploitationAgent
100
+ ctx.surface.target = ctx.target
101
+ try:
102
+ return await ExploitationAgent().run(ctx.surface, ctx.scope)
103
+ except Exception as e:
104
+ print(f"[phases] exploitation phase error: {e}")
105
+ return []
106
+
107
+
108
+ class ExposureBuiltin(ToolAdapter):
109
+ name = "exposure_builtin"
110
+ phase = Phase.EXPOSURE
111
+
112
+ async def run(self, ctx: ScanContext) -> list[Finding]:
113
+ from redrun.engine.schemas import Remediation, Severity
114
+ out: list[Finding] = []
115
+ surface = ctx.surface
116
+ if not surface:
117
+ return out
118
+ for bucket in surface.open_buckets:
119
+ out.append(Finding(
120
+ id=f"s3_open_{bucket.replace('.', '_')}",
121
+ title="Publicly Accessible S3 Bucket", severity=Severity.CRITICAL,
122
+ cvss_score=9.8, description=f"S3 bucket '{bucket}' is publicly listable.",
123
+ affected_asset=bucket,
124
+ proof_of_concept=f"GET https://{bucket}/ -> 200 OK (XML listing)",
125
+ remediation=Remediation(
126
+ summary="Set bucket ACL private; enable Block Public Access.",
127
+ effort="1h", references=[])))
128
+ for secret in surface.exposed_secrets:
129
+ out.append(Finding(
130
+ id=f"secret_{hashlib.sha256(secret.source.encode()).hexdigest()[:8]}",
131
+ title=f"Exposed {secret.type} on GitHub", severity=Severity.HIGH,
132
+ cvss_score=8.2,
133
+ description=f"A {secret.type} for this domain was found publicly.",
134
+ affected_asset=secret.source,
135
+ proof_of_concept=f"Source: {secret.source}\nType: {secret.type}\n"
136
+ f"Preview: {secret.preview}",
137
+ remediation=Remediation(
138
+ summary=f"Rotate the exposed {secret.type}; purge from Git history.",
139
+ effort="1d", references=[])))
140
+ return out
141
+
142
+
143
+ class SslyzeAdapter(ToolAdapter):
144
+ """TLS analysis via sslyze. Optional (pip extra `tools`). Output is verified
145
+ into findings; we never surface raw scan structs."""
146
+ name = "sslyze"
147
+ phase = Phase.VULN_ASSESSMENT
148
+
149
+ def available(self) -> bool:
150
+ try:
151
+ import sslyze # noqa: F401
152
+ return True
153
+ except ImportError:
154
+ return False
155
+
156
+ async def run(self, ctx: ScanContext) -> list[Finding]:
157
+ import asyncio
158
+ analysis = await asyncio.to_thread(self._analyze, ctx.host)
159
+ if analysis is None:
160
+ return []
161
+ return self._verify(ctx.host, analysis)
162
+
163
+ def _analyze(self, host: str) -> Optional[dict]:
164
+ try:
165
+ from sslyze import (Scanner, ServerScanRequest,
166
+ ServerNetworkLocation, ScanCommand)
167
+ except ImportError:
168
+ return None
169
+ try:
170
+ req = ServerScanRequest(
171
+ server_location=ServerNetworkLocation(hostname=host, port=443),
172
+ scan_commands={ScanCommand.CERTIFICATE_INFO,
173
+ ScanCommand.SSL_2_0_CIPHER_SUITES,
174
+ ScanCommand.SSL_3_0_CIPHER_SUITES,
175
+ ScanCommand.TLS_1_0_CIPHER_SUITES})
176
+ scanner = Scanner()
177
+ scanner.queue_scans([req])
178
+ cert_invalid, cert_reason, weak = False, None, []
179
+ for result in scanner.get_results():
180
+ attempts = result.scan_result
181
+ ci = getattr(attempts, "certificate_info", None)
182
+ if ci and getattr(ci, "status", None) and ci.status.name == "COMPLETED" \
183
+ and ci.result:
184
+ errs = []
185
+ for dep in ci.result.certificate_deployments:
186
+ for pv in dep.path_validation_results:
187
+ if not pv.was_validation_successful and pv.validation_error:
188
+ errs.append(pv.validation_error.lower())
189
+ if errs:
190
+ cert_invalid = True
191
+ joined = " ".join(errs)
192
+ if "not valid at validation time" in joined or "expired" in joined:
193
+ cert_reason = "expired"
194
+ elif "self" in joined and "sign" in joined:
195
+ cert_reason = "self_signed"
196
+ else:
197
+ cert_reason = "untrusted"
198
+ for proto, attr in (("SSL 2.0", "ssl_2_0_cipher_suites"),
199
+ ("SSL 3.0", "ssl_3_0_cipher_suites"),
200
+ ("TLS 1.0", "tls_1_0_cipher_suites")):
201
+ cs = getattr(attempts, attr, None)
202
+ if cs and getattr(cs, "status", None) and cs.status.name == "COMPLETED" \
203
+ and cs.result and cs.result.accepted_cipher_suites:
204
+ weak.append(proto)
205
+ return {"cert_invalid": cert_invalid, "cert_reason": cert_reason,
206
+ "weak_protocols": weak}
207
+ except Exception as e:
208
+ print(f"[phases] sslyze analyze error: {e}")
209
+ return None
210
+
211
+ def _verify(self, host: str, analysis: dict) -> list[Finding]:
212
+ from redrun.engine.schemas import Remediation, Severity
213
+ out: list[Finding] = []
214
+ if analysis.get("cert_invalid"):
215
+ reason = analysis.get("cert_reason") or "untrusted"
216
+ title = {"expired": "Expired TLS Certificate",
217
+ "self_signed": "Self-Signed TLS Certificate",
218
+ "untrusted": "Untrusted TLS Certificate"}[reason]
219
+ out.append(Finding(
220
+ id=f"tls_cert_{reason}_{host.replace('.', '_')}",
221
+ title=title, severity=Severity.HIGH, cvss_score=7.4,
222
+ description=f"{host} presents a certificate that fails path "
223
+ f"validation (sslyze classification: {reason}).",
224
+ affected_asset=host,
225
+ proof_of_concept=f"sslyze CERTIFICATE_INFO on {host}:443 -> path "
226
+ f"validation failed ({reason})",
227
+ remediation=Remediation(
228
+ summary="Install a valid, trusted, unexpired certificate; "
229
+ "automate renewal.",
230
+ effort="1h", references=[
231
+ "https://cheatsheetseries.owasp.org/cheatsheets/"
232
+ "Transport_Layer_Security_Cheat_Sheet.html"])))
233
+ weak = analysis.get("weak_protocols") or []
234
+ if weak:
235
+ out.append(Finding(
236
+ id=f"tls_weak_proto_{host.replace('.', '_')}",
237
+ title="Weak TLS Protocols Enabled", severity=Severity.MEDIUM,
238
+ cvss_score=5.9,
239
+ description=f"{host} accepts deprecated protocol(s): "
240
+ f"{', '.join(weak)}.",
241
+ affected_asset=host,
242
+ proof_of_concept=f"sslyze on {host}:443: accepted cipher suites "
243
+ f"present for: {', '.join(weak)}",
244
+ remediation=Remediation(
245
+ summary="Disable SSLv2/SSLv3/TLS1.0; require TLS 1.2+.",
246
+ effort="1h", references=[])))
247
+ return out
248
+
249
+
250
+ REGISTRY: list[ToolAdapter] = [
251
+ ReconBuiltin(), EnumerationBuiltin(), VulnAssessmentBuiltin(),
252
+ SslyzeAdapter(), ExploitationBuiltin(), ExposureBuiltin(),
253
+ ]
254
+
255
+
256
+ def available_adapters(phase: Phase) -> list[ToolAdapter]:
257
+ return [a for a in REGISTRY if a.phase == phase and a.available()]
redrun/app/runner.py ADDED
@@ -0,0 +1,101 @@
1
+ """Scan runner: executes the phase pipeline as one async task, emits progress
2
+ events, enforces the scope-inversion safety model and the license gate."""
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Callable
7
+
8
+ from redrun.app.events import EventHub
9
+ from redrun.app.models import (ACTIVE_PHASES, PASSIVE_PHASES,
10
+ ScanRecord, ScanStatus, Target)
11
+ from redrun.app.phases import ScanContext, available_adapters
12
+ from redrun.app.store import ScanStore
13
+ from redrun.engine.scope import ScopeValidator
14
+
15
+
16
+ def build_scope(host: str, allowlist: list[str]) -> ScopeValidator:
17
+ """Local scope: allow private IPs (internal testing is the point) but confine
18
+ strictly to the target's allowlist. Metadata/link-local stay blocked unless
19
+ explicitly listed."""
20
+ entries = [host] + [a for a in (allowlist or []) if a.strip()]
21
+ return ScopeValidator(entries, allow_private=True)
22
+
23
+
24
+ class ScanRunner:
25
+ def __init__(self, scans: ScanStore, hub: EventHub,
26
+ license_ok: Callable[[], bool]):
27
+ self.scans = scans
28
+ self.hub = hub
29
+ self.license_ok = license_ok
30
+
31
+ async def execute(self, scan: ScanRecord, target: Target) -> None:
32
+ scan.status = ScanStatus.RUNNING
33
+ scan.started_at = datetime.utcnow()
34
+ self.scans.save(scan)
35
+
36
+ if scan.mode == "active" and not self.license_ok():
37
+ return await self._fail(scan, "Active scans require a valid license.")
38
+ if scan.mode == "active" and not target.authorized:
39
+ return await self._fail(scan, "Target is not marked authorized.")
40
+
41
+ host = target.host_or_url.split("//")[-1].split("/")[0].split(":")[0]
42
+ scope = (build_scope(host, target.scope) if scan.mode == "active" else None)
43
+ ctx = ScanContext(host=host, target=target.host_or_url, scope=scope,
44
+ mode=scan.mode)
45
+
46
+ phases = ACTIVE_PHASES if scan.mode == "active" else PASSIVE_PHASES
47
+ if target.enabled_phases:
48
+ phases = [p for p in phases if p.value in target.enabled_phases]
49
+
50
+ try:
51
+ for i, phase in enumerate(phases):
52
+ scan.phase = phase.value
53
+ scan.progress = int(i / max(len(phases), 1) * 100)
54
+ self.scans.save(scan)
55
+ await self.hub.publish(scan.id, {
56
+ "phase": phase.value, "progress": scan.progress})
57
+ for adapter in available_adapters(phase):
58
+ new = await self._run_adapter(adapter, ctx)
59
+ ctx.findings.extend(new)
60
+ for f in new:
61
+ await self.hub.publish(scan.id, {
62
+ "phase": phase.value, "finding": f.title,
63
+ "severity": f.severity.value if hasattr(
64
+ f.severity, "value") else str(f.severity)})
65
+ except Exception as e: # one scan never crashes the control plane
66
+ return await self._fail(scan, f"{type(e).__name__}: {e}")
67
+
68
+ await self._complete(scan, ctx)
69
+
70
+ async def _run_adapter(self, adapter, ctx: ScanContext) -> list:
71
+ return await adapter.run(ctx)
72
+
73
+ async def _complete(self, scan: ScanRecord, ctx: ScanContext) -> None:
74
+ findings = ctx.findings
75
+ counts = {s: 0 for s in ("critical", "high", "medium", "low")}
76
+ for f in findings:
77
+ sev = f.severity.value if hasattr(f.severity, "value") else str(f.severity)
78
+ if sev in counts:
79
+ counts[sev] += 1
80
+ risk = min(100, counts["critical"] * 30 + counts["high"] * 15
81
+ + counts["medium"] * 5 + counts["low"] * 1)
82
+ scan.status = ScanStatus.COMPLETED
83
+ scan.completed_at = datetime.utcnow()
84
+ scan.progress = 100
85
+ scan.duration = (scan.completed_at - scan.started_at).total_seconds()
86
+ scan.report = {
87
+ "risk_score": risk,
88
+ "critical_count": counts["critical"], "high_count": counts["high"],
89
+ "medium_count": counts["medium"], "low_count": counts["low"],
90
+ "findings": [f.model_dump() for f in findings],
91
+ }
92
+ self.scans.save(scan)
93
+ await self.hub.publish(scan.id, {"status": "completed", "progress": 100,
94
+ "risk_score": risk})
95
+
96
+ async def _fail(self, scan: ScanRecord, reason: str) -> None:
97
+ scan.status = ScanStatus.FAILED
98
+ scan.completed_at = datetime.utcnow()
99
+ scan.report = {"error": reason}
100
+ self.scans.save(scan)
101
+ await self.hub.publish(scan.id, {"status": "failed", "error": reason})