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 +0 -0
- redrun/__main__.py +5 -0
- redrun/app/__init__.py +1 -0
- redrun/app/auth.py +50 -0
- redrun/app/db.py +57 -0
- redrun/app/events.py +31 -0
- redrun/app/models.py +60 -0
- redrun/app/phases.py +257 -0
- redrun/app/runner.py +101 -0
- redrun/app/server.py +281 -0
- redrun/app/store.py +159 -0
- redrun/cli.py +409 -0
- redrun/cloud.py +112 -0
- redrun/engine/__init__.py +0 -0
- redrun/engine/active_scanner.py +887 -0
- redrun/engine/config.py +42 -0
- redrun/engine/docker/egress-proxy/Dockerfile +23 -0
- redrun/engine/docker/egress-proxy/entrypoint.sh +55 -0
- redrun/engine/docker_sandbox.py +190 -0
- redrun/engine/egress.py +98 -0
- redrun/engine/exploitation.py +231 -0
- redrun/engine/nuclei_scanner.py +170 -0
- redrun/engine/planner.py +211 -0
- redrun/engine/recon.py +612 -0
- redrun/engine/reporter.py +315 -0
- redrun/engine/sandbox.py +187 -0
- redrun/engine/schemas.py +258 -0
- redrun/engine/scope.py +237 -0
- redrun/engine/tools/__init__.py +0 -0
- redrun/engine/tools/base.py +244 -0
- redrun/engine/tools/broken_auth.py +183 -0
- redrun/engine/tools/sqli.py +162 -0
- redrun/engine/tools/ssrf.py +176 -0
- redrun/engine/tools/xss.py +104 -0
- redrun/engine/zone.py +159 -0
- redrun/licensing.py +116 -0
- redrun/output.py +93 -0
- redrun_scan-0.1.0.dist-info/METADATA +178 -0
- redrun_scan-0.1.0.dist-info/RECORD +42 -0
- redrun_scan-0.1.0.dist-info/WHEEL +5 -0
- redrun_scan-0.1.0.dist-info/entry_points.txt +2 -0
- redrun_scan-0.1.0.dist-info/top_level.txt +1 -0
redrun/__init__.py
ADDED
|
File without changes
|
redrun/__main__.py
ADDED
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})
|