skill-sentry-cli 2.0.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.
- skill_sentry_cli/__init__.py +5 -0
- skill_sentry_cli/__main__.py +4 -0
- skill_sentry_cli/cli.py +918 -0
- skill_sentry_cli/core/__init__.py +1 -0
- skill_sentry_cli/core/anchors/__init__.py +5 -0
- skill_sentry_cli/core/anchors/credential_flow.py +440 -0
- skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
- skill_sentry_cli/core/cache/__init__.py +35 -0
- skill_sentry_cli/core/cache/hash_utils.py +206 -0
- skill_sentry_cli/core/cache/sqlite_store.py +925 -0
- skill_sentry_cli/core/config.py +299 -0
- skill_sentry_cli/core/contracts.py +551 -0
- skill_sentry_cli/core/decisions/__init__.py +44 -0
- skill_sentry_cli/core/decisions/base.py +174 -0
- skill_sentry_cli/core/decisions/calibration.py +325 -0
- skill_sentry_cli/core/decisions/laya.py +439 -0
- skill_sentry_cli/core/decisions/mapping.py +218 -0
- skill_sentry_cli/core/decisions/mock.py +118 -0
- skill_sentry_cli/core/decisions/questions.py +118 -0
- skill_sentry_cli/core/decisions/report.py +65 -0
- skill_sentry_cli/core/decisions/selector.py +151 -0
- skill_sentry_cli/core/engine.py +1412 -0
- skill_sentry_cli/core/evasion/__init__.py +27 -0
- skill_sentry_cli/core/evasion/detector.py +210 -0
- skill_sentry_cli/core/exit_codes.py +13 -0
- skill_sentry_cli/core/models/__init__.py +20 -0
- skill_sentry_cli/core/models/_json.py +147 -0
- skill_sentry_cli/core/models/base.py +50 -0
- skill_sentry_cli/core/models/gemma_cloud.py +240 -0
- skill_sentry_cli/core/models/gemma_local.py +273 -0
- skill_sentry_cli/core/models/image_utils.py +112 -0
- skill_sentry_cli/core/models/mock.py +152 -0
- skill_sentry_cli/core/models/selector.py +123 -0
- skill_sentry_cli/core/parser/__init__.py +29 -0
- skill_sentry_cli/core/parser/skill_parser.py +520 -0
- skill_sentry_cli/core/parser/typosquat.py +167 -0
- skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
- skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
- skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
- skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
- skill_sentry_cli/core/reports.py +114 -0
- skill_sentry_cli/core/sandbox/__init__.py +74 -0
- skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
- skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
- skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
- skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
- skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
- skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
- skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
- skill_sentry_cli/core/sandbox/fake_http.py +194 -0
- skill_sentry_cli/core/sandbox/instructions.py +170 -0
- skill_sentry_cli/core/sandbox/masquerade.py +265 -0
- skill_sentry_cli/core/sandbox/monitor.py +278 -0
- skill_sentry_cli/core/sandbox/runner.py +776 -0
- skill_sentry_cli/core/sandbox/sidecar.py +327 -0
- skill_sentry_cli/core/semantic/__init__.py +9 -0
- skill_sentry_cli/core/semantic/analyzer.py +443 -0
- skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
- skill_sentry_cli/core/semantic/ast_python.py +156 -0
- skill_sentry_cli/core/semantic/candidates.py +78 -0
- skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
- skill_sentry_cli/core/semantic/prompts.py +61 -0
- skill_sentry_cli/core/verdict/__init__.py +24 -0
- skill_sentry_cli/core/verdict/fusion.py +392 -0
- skill_sentry_cli/core/verdict/markdown_report.py +211 -0
- skill_sentry_cli/core/verdict/sarif_report.py +139 -0
- skill_sentry_cli/core/verdict/status.py +35 -0
- skill_sentry_cli/core/verdict/stix_report.py +132 -0
- skill_sentry_cli/core/version.py +38 -0
- skill_sentry_cli/core/vision/__init__.py +8 -0
- skill_sentry_cli/core/vision/image_extractor.py +91 -0
- skill_sentry_cli/core/vision/vision_scanner.py +274 -0
- skill_sentry_cli/data/__init__.py +1 -0
- skill_sentry_cli/data/known_skills_v1.json +276 -0
- skill_sentry_cli/data/sandbox_image.json +4 -0
- skill_sentry_cli/rules/credential_rules.toml +94 -0
- skill_sentry_cli/server/__init__.py +5 -0
- skill_sentry_cli/server/app.py +129 -0
- skill_sentry_cli/server/backend.py +63 -0
- skill_sentry_cli/server/demo_assets.py +62 -0
- skill_sentry_cli/server/demo_backend.py +475 -0
- skill_sentry_cli/server/demo_data.py +814 -0
- skill_sentry_cli/server/demo_mode.py +40 -0
- skill_sentry_cli/server/errors.py +48 -0
- skill_sentry_cli/server/live_backend.py +576 -0
- skill_sentry_cli/server/routes.py +191 -0
- skill_sentry_cli/skill/SKILL.md +67 -0
- skill_sentry_cli/skill/scripts/audit.sh +48 -0
- skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
- skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
- skill_sentry_cli/ui/dist/index.html +14 -0
- skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
- skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
- skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
- skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
- skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
"""SQLite-backed cache, scan history, and Poisoned Well evidence store.
|
|
2
|
+
|
|
3
|
+
The store is intentionally synchronous: scans run in a worker thread and the
|
|
4
|
+
API calls small methods through ``asyncio.to_thread``. A single connection is
|
|
5
|
+
guarded by a re-entrant lock, which keeps in-memory test databases and SQLite's
|
|
6
|
+
``check_same_thread`` rules simple.
|
|
7
|
+
|
|
8
|
+
Schema version 1 mirrors ``docs/03-database.md``. Two additive columns/tables
|
|
9
|
+
are used to keep the live API fully functional without mutating the frozen
|
|
10
|
+
verdict contract:
|
|
11
|
+
|
|
12
|
+
* ``scans.skill_id`` makes ``GET /skills/{id}`` addressable;
|
|
13
|
+
* ``scan_results`` stores the full ``SkillDetail`` payload and stage results.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import sqlite3
|
|
21
|
+
import threading
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from contextlib import contextmanager, suppress
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import UTC, datetime, timedelta
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
from uuid import UUID, uuid4
|
|
29
|
+
|
|
30
|
+
from skill_sentry_cli.core.cache.hash_utils import hash_bytes
|
|
31
|
+
from skill_sentry_cli.core.contracts import (
|
|
32
|
+
ScanStatus,
|
|
33
|
+
SkillDetail,
|
|
34
|
+
StageResult,
|
|
35
|
+
Verdict,
|
|
36
|
+
VerdictTier,
|
|
37
|
+
format_utc,
|
|
38
|
+
utc_now,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
SCHEMA_VERSION = 1
|
|
42
|
+
CACHE_KEY_VERSION = 1
|
|
43
|
+
|
|
44
|
+
_SCHEMA = """
|
|
45
|
+
PRAGMA journal_mode = WAL;
|
|
46
|
+
PRAGMA foreign_keys = ON;
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
49
|
+
key TEXT PRIMARY KEY,
|
|
50
|
+
value TEXT NOT NULL
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS scan_cache (
|
|
54
|
+
content_hash TEXT PRIMARY KEY,
|
|
55
|
+
skill_name TEXT NOT NULL,
|
|
56
|
+
verdict TEXT NOT NULL CHECK (verdict IN ('SAFE','SUSPICIOUS','MALICIOUS')),
|
|
57
|
+
risk_score INTEGER NOT NULL CHECK (risk_score BETWEEN 0 AND 100),
|
|
58
|
+
confidence REAL,
|
|
59
|
+
verdict_json TEXT NOT NULL,
|
|
60
|
+
sarif_json TEXT NOT NULL,
|
|
61
|
+
detail_json TEXT,
|
|
62
|
+
active_defense_json TEXT,
|
|
63
|
+
engine_version TEXT NOT NULL,
|
|
64
|
+
model_backend TEXT NOT NULL,
|
|
65
|
+
model_version TEXT NOT NULL,
|
|
66
|
+
scan_duration_ms INTEGER,
|
|
67
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS scans (
|
|
71
|
+
scan_id TEXT PRIMARY KEY,
|
|
72
|
+
skill_name TEXT NOT NULL,
|
|
73
|
+
skill_path TEXT NOT NULL,
|
|
74
|
+
content_hash TEXT NOT NULL,
|
|
75
|
+
status TEXT NOT NULL DEFAULT 'running'
|
|
76
|
+
CHECK (status IN ('queued','running','completed','failed','cached-hit')),
|
|
77
|
+
verdict TEXT,
|
|
78
|
+
risk_score INTEGER,
|
|
79
|
+
confidence REAL,
|
|
80
|
+
cached INTEGER NOT NULL DEFAULT 0,
|
|
81
|
+
backend TEXT,
|
|
82
|
+
skill_id TEXT,
|
|
83
|
+
started_at TEXT NOT NULL,
|
|
84
|
+
finished_at TEXT,
|
|
85
|
+
duration_ms INTEGER,
|
|
86
|
+
error TEXT
|
|
87
|
+
);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_scans_hash ON scans(content_hash);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_scans_started ON scans(started_at DESC);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_scans_skill ON scans(skill_id);
|
|
91
|
+
|
|
92
|
+
CREATE TABLE IF NOT EXISTS scan_results (
|
|
93
|
+
scan_id TEXT PRIMARY KEY REFERENCES scans(scan_id) ON DELETE CASCADE,
|
|
94
|
+
verdict_json TEXT NOT NULL,
|
|
95
|
+
detail_json TEXT NOT NULL,
|
|
96
|
+
stage_results_json TEXT,
|
|
97
|
+
created_at TEXT NOT NULL
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
CREATE TABLE IF NOT EXISTS findings (
|
|
101
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
102
|
+
scan_id TEXT NOT NULL REFERENCES scans(scan_id) ON DELETE CASCADE,
|
|
103
|
+
stage TEXT NOT NULL
|
|
104
|
+
CHECK (stage IN ('cache','spec','typosquat','deterministicAnchor',
|
|
105
|
+
'semantic','vision','sandbox','poisonedWell',
|
|
106
|
+
'evasion','verdict')),
|
|
107
|
+
severity TEXT NOT NULL CHECK (severity IN ('low','medium','high','critical','note')),
|
|
108
|
+
message TEXT NOT NULL,
|
|
109
|
+
file TEXT,
|
|
110
|
+
line INTEGER,
|
|
111
|
+
evidence_json TEXT
|
|
112
|
+
);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_findings_scan ON findings(scan_id);
|
|
114
|
+
|
|
115
|
+
CREATE TABLE IF NOT EXISTS active_defense (
|
|
116
|
+
scan_id TEXT PRIMARY KEY REFERENCES scans(scan_id) ON DELETE CASCADE,
|
|
117
|
+
triggered INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
targeted_assets TEXT,
|
|
119
|
+
exfil_channel TEXT CHECK (exfil_channel IN
|
|
120
|
+
('DNS_TUNNELING','HTTP_POST','RAW_SOCKET',NULL)),
|
|
121
|
+
canary_marker TEXT,
|
|
122
|
+
secondary_canary TEXT,
|
|
123
|
+
c2_indicators TEXT,
|
|
124
|
+
encoding TEXT,
|
|
125
|
+
sandbox_evasion_detected INTEGER NOT NULL DEFAULT 0,
|
|
126
|
+
declared_intent TEXT,
|
|
127
|
+
actual_behavior TEXT,
|
|
128
|
+
context_canary TEXT,
|
|
129
|
+
ioc_export_path TEXT
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
CREATE TABLE IF NOT EXISTS canary_events (
|
|
133
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
134
|
+
scan_id TEXT NOT NULL REFERENCES scans(scan_id) ON DELETE CASCADE,
|
|
135
|
+
ts TEXT NOT NULL,
|
|
136
|
+
event_type TEXT NOT NULL CHECK (event_type IN
|
|
137
|
+
('canary_seeded','canary_read','fake_served','probe_detected',
|
|
138
|
+
'exfil_raw','exfil_decoded','marker_matched','secondary_served')),
|
|
139
|
+
path_or_channel TEXT,
|
|
140
|
+
marker TEXT,
|
|
141
|
+
process TEXT,
|
|
142
|
+
detail_json TEXT
|
|
143
|
+
);
|
|
144
|
+
CREATE INDEX IF NOT EXISTS idx_canary_scan_ts ON canary_events(scan_id, ts);
|
|
145
|
+
|
|
146
|
+
CREATE TABLE IF NOT EXISTS sandbox_net_logs (
|
|
147
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
148
|
+
scan_id TEXT NOT NULL REFERENCES scans(scan_id) ON DELETE CASCADE,
|
|
149
|
+
ts TEXT NOT NULL,
|
|
150
|
+
protocol TEXT NOT NULL CHECK (protocol IN ('dns','http')),
|
|
151
|
+
query_or_url TEXT NOT NULL,
|
|
152
|
+
body_b64 TEXT,
|
|
153
|
+
responder_action TEXT
|
|
154
|
+
);
|
|
155
|
+
CREATE INDEX IF NOT EXISTS idx_net_scan_ts ON sandbox_net_logs(scan_id, ts);
|
|
156
|
+
|
|
157
|
+
CREATE TABLE IF NOT EXISTS iocs (
|
|
158
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
159
|
+
scan_id TEXT NOT NULL REFERENCES scans(scan_id) ON DELETE CASCADE,
|
|
160
|
+
ioc_type TEXT NOT NULL CHECK (ioc_type IN
|
|
161
|
+
('domain-name','ipv4-addr','url','hostname-pattern')),
|
|
162
|
+
value TEXT NOT NULL,
|
|
163
|
+
channel TEXT,
|
|
164
|
+
first_seen TEXT NOT NULL,
|
|
165
|
+
confirmed INTEGER NOT NULL DEFAULT 0,
|
|
166
|
+
UNIQUE (scan_id, ioc_type, value)
|
|
167
|
+
);
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_iocs_value ON iocs(value);
|
|
169
|
+
|
|
170
|
+
CREATE TABLE IF NOT EXISTS attacker_profiles (
|
|
171
|
+
id TEXT PRIMARY KEY,
|
|
172
|
+
scan_id TEXT NOT NULL UNIQUE REFERENCES scans(scan_id) ON DELETE CASCADE,
|
|
173
|
+
profile_json TEXT NOT NULL,
|
|
174
|
+
stix_bundle_path TEXT,
|
|
175
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
176
|
+
);
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class StoreError(RuntimeError):
|
|
181
|
+
"""The database is unavailable or an operation would corrupt evidence."""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class CachedResult:
|
|
186
|
+
content_hash: str
|
|
187
|
+
skill_name: str
|
|
188
|
+
verdict: Verdict
|
|
189
|
+
detail: SkillDetail
|
|
190
|
+
sarif_json: str
|
|
191
|
+
engine_version: str
|
|
192
|
+
model_backend: str
|
|
193
|
+
model_version: str
|
|
194
|
+
scan_duration_ms: int
|
|
195
|
+
created_at: str
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class ScanRecord:
|
|
200
|
+
scan_id: UUID
|
|
201
|
+
skill_name: str
|
|
202
|
+
skill_path: str
|
|
203
|
+
content_hash: str
|
|
204
|
+
status: ScanStatus
|
|
205
|
+
verdict: VerdictTier | None
|
|
206
|
+
risk_score: int | None
|
|
207
|
+
confidence: float | None
|
|
208
|
+
cached: bool
|
|
209
|
+
backend: str | None
|
|
210
|
+
skill_id: UUID | None
|
|
211
|
+
started_at: datetime
|
|
212
|
+
finished_at: datetime | None
|
|
213
|
+
duration_ms: int | None
|
|
214
|
+
error: str | None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cache_key(
|
|
218
|
+
content_hash: str,
|
|
219
|
+
engine_version: str,
|
|
220
|
+
model_version: str,
|
|
221
|
+
*,
|
|
222
|
+
backend: str | None = None,
|
|
223
|
+
backend_strict: bool = False,
|
|
224
|
+
) -> str:
|
|
225
|
+
"""Return a stable diagnostic key (the DB query uses the individual fields)."""
|
|
226
|
+
|
|
227
|
+
backend_part = backend or "*" if not backend_strict else ""
|
|
228
|
+
return hash_bytes(
|
|
229
|
+
f"{CACHE_KEY_VERSION}\0{content_hash}\0{engine_version}\0{model_version}\0{backend_part}".encode()
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _json(value: Any) -> str:
|
|
234
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _parse_ts(value: str | None) -> datetime | None:
|
|
238
|
+
if not value:
|
|
239
|
+
return None
|
|
240
|
+
try:
|
|
241
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
242
|
+
except ValueError:
|
|
243
|
+
return None
|
|
244
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_DURATION_RE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$", re.IGNORECASE)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_age(value: str | None) -> timedelta | None:
|
|
251
|
+
if value is None:
|
|
252
|
+
return None
|
|
253
|
+
match = _DURATION_RE.match(value)
|
|
254
|
+
if not match:
|
|
255
|
+
raise StoreError(f"invalid duration {value!r}; use e.g. 30d, 12h, 30m")
|
|
256
|
+
amount = int(match.group(1))
|
|
257
|
+
unit = match.group(2).lower()
|
|
258
|
+
factor = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}[unit]
|
|
259
|
+
return timedelta(seconds=amount * factor)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class SQLiteStore:
|
|
263
|
+
"""One SQLite connection with serialized access."""
|
|
264
|
+
|
|
265
|
+
def __init__(self, path: str | Path) -> None:
|
|
266
|
+
self.path = str(path)
|
|
267
|
+
self._lock = threading.RLock()
|
|
268
|
+
self._closed = False
|
|
269
|
+
if self.path != ":memory:":
|
|
270
|
+
target = Path(self.path).expanduser()
|
|
271
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
self._path = target
|
|
273
|
+
else:
|
|
274
|
+
self._path = None
|
|
275
|
+
try:
|
|
276
|
+
self._conn = sqlite3.connect(
|
|
277
|
+
self.path,
|
|
278
|
+
check_same_thread=False,
|
|
279
|
+
timeout=10.0,
|
|
280
|
+
isolation_level=None,
|
|
281
|
+
)
|
|
282
|
+
self._conn.row_factory = sqlite3.Row
|
|
283
|
+
self._conn.execute("PRAGMA foreign_keys = ON")
|
|
284
|
+
if self.path != ":memory:":
|
|
285
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
286
|
+
self._conn.executescript(_SCHEMA)
|
|
287
|
+
self._migrate()
|
|
288
|
+
except sqlite3.Error as exc:
|
|
289
|
+
raise StoreError(f"could not open database {self.path}: {exc}") from exc
|
|
290
|
+
|
|
291
|
+
# -- lifecycle ---------------------------------------------------------
|
|
292
|
+
def _migrate(self) -> None:
|
|
293
|
+
columns = {row["name"] for row in self._conn.execute("PRAGMA table_info(scans)").fetchall()}
|
|
294
|
+
if "skill_id" not in columns:
|
|
295
|
+
self._conn.execute("ALTER TABLE scans ADD COLUMN skill_id TEXT")
|
|
296
|
+
cache_columns = {
|
|
297
|
+
row["name"] for row in self._conn.execute("PRAGMA table_info(scan_cache)").fetchall()
|
|
298
|
+
}
|
|
299
|
+
if "detail_json" not in cache_columns:
|
|
300
|
+
self._conn.execute("ALTER TABLE scan_cache ADD COLUMN detail_json TEXT")
|
|
301
|
+
self._conn.execute(
|
|
302
|
+
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES('schema_version', ?)",
|
|
303
|
+
(str(SCHEMA_VERSION),),
|
|
304
|
+
)
|
|
305
|
+
row = self._conn.execute(
|
|
306
|
+
"SELECT value FROM schema_meta WHERE key='created_version'"
|
|
307
|
+
).fetchone()
|
|
308
|
+
if row is None:
|
|
309
|
+
self._conn.execute(
|
|
310
|
+
"INSERT INTO schema_meta(key, value) VALUES('created_version', ?)",
|
|
311
|
+
(str(SCHEMA_VERSION),),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def close(self) -> None:
|
|
315
|
+
with self._lock:
|
|
316
|
+
if not self._closed:
|
|
317
|
+
self._conn.close()
|
|
318
|
+
self._closed = True
|
|
319
|
+
|
|
320
|
+
def __enter__(self) -> SQLiteStore:
|
|
321
|
+
return self
|
|
322
|
+
|
|
323
|
+
def __exit__(self, *exc: object) -> None:
|
|
324
|
+
self.close()
|
|
325
|
+
|
|
326
|
+
@contextmanager
|
|
327
|
+
def _tx(self):
|
|
328
|
+
with self._lock:
|
|
329
|
+
try:
|
|
330
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
331
|
+
yield self._conn
|
|
332
|
+
except Exception:
|
|
333
|
+
self._conn.execute("ROLLBACK")
|
|
334
|
+
raise
|
|
335
|
+
else:
|
|
336
|
+
self._conn.execute("COMMIT")
|
|
337
|
+
|
|
338
|
+
def ping(self) -> bool:
|
|
339
|
+
try:
|
|
340
|
+
with self._lock:
|
|
341
|
+
self._conn.execute("SELECT 1").fetchone()
|
|
342
|
+
return True
|
|
343
|
+
except sqlite3.Error:
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def size_bytes(self) -> int:
|
|
348
|
+
if self._path is None:
|
|
349
|
+
return 0
|
|
350
|
+
total = 0
|
|
351
|
+
for suffix in ("", "-wal", "-shm"):
|
|
352
|
+
candidate = Path(str(self._path) + suffix)
|
|
353
|
+
with suppress(OSError):
|
|
354
|
+
total += candidate.stat().st_size
|
|
355
|
+
return total
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def schema_version(self) -> int:
|
|
359
|
+
with self._lock:
|
|
360
|
+
row = self._conn.execute(
|
|
361
|
+
"SELECT value FROM schema_meta WHERE key='schema_version'"
|
|
362
|
+
).fetchone()
|
|
363
|
+
return int(row["value"]) if row else 0
|
|
364
|
+
|
|
365
|
+
# -- scan lifecycle ----------------------------------------------------
|
|
366
|
+
def create_scan(
|
|
367
|
+
self,
|
|
368
|
+
scan_id: UUID,
|
|
369
|
+
*,
|
|
370
|
+
skill_name: str,
|
|
371
|
+
skill_path: str,
|
|
372
|
+
content_hash: str,
|
|
373
|
+
backend: str,
|
|
374
|
+
skill_id: UUID | None = None,
|
|
375
|
+
status: ScanStatus = ScanStatus.RUNNING,
|
|
376
|
+
started_at: datetime | None = None,
|
|
377
|
+
) -> None:
|
|
378
|
+
started_at = started_at or utc_now()
|
|
379
|
+
with self._lock:
|
|
380
|
+
self._conn.execute(
|
|
381
|
+
"""
|
|
382
|
+
INSERT INTO scans(scan_id, skill_name, skill_path, content_hash, status,
|
|
383
|
+
backend, skill_id, started_at)
|
|
384
|
+
VALUES(?,?,?,?,?,?,?,?)
|
|
385
|
+
""",
|
|
386
|
+
(
|
|
387
|
+
str(scan_id),
|
|
388
|
+
skill_name,
|
|
389
|
+
skill_path,
|
|
390
|
+
content_hash,
|
|
391
|
+
status.value,
|
|
392
|
+
backend,
|
|
393
|
+
str(skill_id) if skill_id else None,
|
|
394
|
+
format_utc(started_at),
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def update_status(
|
|
399
|
+
self,
|
|
400
|
+
scan_id: UUID,
|
|
401
|
+
status: ScanStatus,
|
|
402
|
+
*,
|
|
403
|
+
error: str | None = None,
|
|
404
|
+
) -> None:
|
|
405
|
+
with self._lock:
|
|
406
|
+
self._conn.execute(
|
|
407
|
+
"UPDATE scans SET status=?, error=? WHERE scan_id=?",
|
|
408
|
+
(status.value, error, str(scan_id)),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def fail_scan(self, scan_id: UUID, error: str) -> None:
|
|
412
|
+
now = utc_now()
|
|
413
|
+
with self._lock:
|
|
414
|
+
row = self._conn.execute(
|
|
415
|
+
"SELECT started_at FROM scans WHERE scan_id=?", (str(scan_id),)
|
|
416
|
+
).fetchone()
|
|
417
|
+
if row is None:
|
|
418
|
+
return
|
|
419
|
+
started = _parse_ts(row["started_at"]) or now
|
|
420
|
+
self._conn.execute(
|
|
421
|
+
"""
|
|
422
|
+
UPDATE scans SET status='failed', error=?, finished_at=?, duration_ms=?
|
|
423
|
+
WHERE scan_id=?
|
|
424
|
+
""",
|
|
425
|
+
(
|
|
426
|
+
error[:2000],
|
|
427
|
+
format_utc(now),
|
|
428
|
+
max(0, int((now - started).total_seconds() * 1000)),
|
|
429
|
+
str(scan_id),
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def finish_scan(
|
|
434
|
+
self,
|
|
435
|
+
verdict: Verdict,
|
|
436
|
+
detail: SkillDetail,
|
|
437
|
+
*,
|
|
438
|
+
stage_results: Sequence[StageResult] = (),
|
|
439
|
+
sarif_json: str = "",
|
|
440
|
+
cache: bool = True,
|
|
441
|
+
) -> None:
|
|
442
|
+
"""Persist a completed scan atomically.
|
|
443
|
+
|
|
444
|
+
``cache=False`` is used for ``--no-cache``: evidence and history are
|
|
445
|
+
still recorded, but the reusable content-addressed row is not written.
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
scan_id = verdict.scan_id
|
|
449
|
+
finished = utc_now()
|
|
450
|
+
with self._tx() as conn:
|
|
451
|
+
row = conn.execute(
|
|
452
|
+
"SELECT started_at, content_hash, backend, skill_path, skill_name FROM scans "
|
|
453
|
+
"WHERE scan_id=?",
|
|
454
|
+
(str(scan_id),),
|
|
455
|
+
).fetchone()
|
|
456
|
+
if row is None:
|
|
457
|
+
raise StoreError(f"scan row missing for {scan_id}")
|
|
458
|
+
started = _parse_ts(row["started_at"]) or finished
|
|
459
|
+
duration = verdict.scan_duration_ms
|
|
460
|
+
status = "cached-hit" if verdict.cached else "completed"
|
|
461
|
+
conn.execute(
|
|
462
|
+
"""
|
|
463
|
+
UPDATE scans SET status=?, verdict=?, risk_score=?, confidence=?, cached=?,
|
|
464
|
+
skill_name=?, skill_id=?, finished_at=?, duration_ms=?, error=NULL
|
|
465
|
+
WHERE scan_id=?
|
|
466
|
+
""",
|
|
467
|
+
(
|
|
468
|
+
status,
|
|
469
|
+
verdict.verdict.value,
|
|
470
|
+
verdict.risk_score,
|
|
471
|
+
verdict.confidence,
|
|
472
|
+
1 if verdict.cached else 0,
|
|
473
|
+
verdict.skill,
|
|
474
|
+
str(verdict.skill_id),
|
|
475
|
+
format_utc(finished),
|
|
476
|
+
duration,
|
|
477
|
+
str(scan_id),
|
|
478
|
+
),
|
|
479
|
+
)
|
|
480
|
+
verdict_json = verdict.model_dump_json(by_alias=True)
|
|
481
|
+
detail_json = detail.model_dump_json(by_alias=True)
|
|
482
|
+
conn.execute(
|
|
483
|
+
"""
|
|
484
|
+
INSERT OR REPLACE INTO scan_results(scan_id, verdict_json, detail_json,
|
|
485
|
+
stage_results_json, created_at)
|
|
486
|
+
VALUES(?,?,?,?,?)
|
|
487
|
+
""",
|
|
488
|
+
(
|
|
489
|
+
str(scan_id),
|
|
490
|
+
verdict_json,
|
|
491
|
+
detail_json,
|
|
492
|
+
_json(
|
|
493
|
+
[result.model_dump(by_alias=True, mode="json") for result in stage_results]
|
|
494
|
+
),
|
|
495
|
+
format_utc(finished),
|
|
496
|
+
),
|
|
497
|
+
)
|
|
498
|
+
self._write_findings(conn, scan_id, verdict)
|
|
499
|
+
self._write_active_defense(conn, scan_id, verdict)
|
|
500
|
+
self._write_trace(conn, scan_id, detail)
|
|
501
|
+
self._write_iocs(conn, scan_id, verdict, detail)
|
|
502
|
+
|
|
503
|
+
if cache:
|
|
504
|
+
sarif = sarif_json or ""
|
|
505
|
+
conn.execute(
|
|
506
|
+
"""
|
|
507
|
+
INSERT INTO scan_cache(content_hash, skill_name, verdict, risk_score,
|
|
508
|
+
confidence, verdict_json, sarif_json, detail_json,
|
|
509
|
+
active_defense_json, engine_version, model_backend, model_version,
|
|
510
|
+
scan_duration_ms, created_at)
|
|
511
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
512
|
+
ON CONFLICT(content_hash) DO UPDATE SET
|
|
513
|
+
skill_name=excluded.skill_name,
|
|
514
|
+
verdict=excluded.verdict,
|
|
515
|
+
risk_score=excluded.risk_score,
|
|
516
|
+
confidence=excluded.confidence,
|
|
517
|
+
verdict_json=excluded.verdict_json,
|
|
518
|
+
sarif_json=excluded.sarif_json,
|
|
519
|
+
detail_json=excluded.detail_json,
|
|
520
|
+
active_defense_json=excluded.active_defense_json,
|
|
521
|
+
engine_version=excluded.engine_version,
|
|
522
|
+
model_backend=excluded.model_backend,
|
|
523
|
+
model_version=excluded.model_version,
|
|
524
|
+
scan_duration_ms=excluded.scan_duration_ms,
|
|
525
|
+
created_at=excluded.created_at
|
|
526
|
+
""",
|
|
527
|
+
(
|
|
528
|
+
row["content_hash"],
|
|
529
|
+
verdict.skill,
|
|
530
|
+
verdict.verdict.value,
|
|
531
|
+
verdict.risk_score,
|
|
532
|
+
verdict.confidence,
|
|
533
|
+
verdict_json,
|
|
534
|
+
sarif,
|
|
535
|
+
detail_json,
|
|
536
|
+
(
|
|
537
|
+
verdict.active_defense.model_dump_json(by_alias=True)
|
|
538
|
+
if verdict.active_defense
|
|
539
|
+
else None
|
|
540
|
+
),
|
|
541
|
+
self._engine_version,
|
|
542
|
+
self._model_backend,
|
|
543
|
+
self._model_version,
|
|
544
|
+
duration,
|
|
545
|
+
format_utc(finished),
|
|
546
|
+
),
|
|
547
|
+
)
|
|
548
|
+
del started
|
|
549
|
+
|
|
550
|
+
# Values used when a result is finalized; set by the engine immediately
|
|
551
|
+
# before ``finish_scan``. Kept as attributes so the public method stays
|
|
552
|
+
# small and the cache rule lives in one place.
|
|
553
|
+
_engine_version = "unknown"
|
|
554
|
+
_model_backend = "unknown"
|
|
555
|
+
_model_version = "unknown"
|
|
556
|
+
|
|
557
|
+
def set_cache_identity(
|
|
558
|
+
self, *, engine_version: str, model_backend: str, model_version: str
|
|
559
|
+
) -> None:
|
|
560
|
+
self._engine_version = engine_version
|
|
561
|
+
self._model_backend = model_backend
|
|
562
|
+
self._model_version = model_version
|
|
563
|
+
|
|
564
|
+
def _write_findings(self, conn: sqlite3.Connection, scan_id: UUID, verdict: Verdict) -> None:
|
|
565
|
+
for finding in verdict.findings:
|
|
566
|
+
conn.execute(
|
|
567
|
+
"""
|
|
568
|
+
INSERT INTO findings(scan_id, stage, severity, message, file, line, evidence_json)
|
|
569
|
+
VALUES(?,?,?,?,?,?,?)
|
|
570
|
+
""",
|
|
571
|
+
(
|
|
572
|
+
str(scan_id),
|
|
573
|
+
finding.stage.value,
|
|
574
|
+
finding.severity.value,
|
|
575
|
+
finding.message,
|
|
576
|
+
finding.evidence.file,
|
|
577
|
+
finding.evidence.line,
|
|
578
|
+
finding.evidence.model_dump_json(by_alias=True, exclude_none=True),
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
def _write_active_defense(
|
|
583
|
+
self, conn: sqlite3.Connection, scan_id: UUID, verdict: Verdict
|
|
584
|
+
) -> None:
|
|
585
|
+
active = verdict.active_defense
|
|
586
|
+
if active is None:
|
|
587
|
+
return
|
|
588
|
+
conn.execute(
|
|
589
|
+
"""
|
|
590
|
+
INSERT OR REPLACE INTO active_defense(scan_id, triggered, targeted_assets,
|
|
591
|
+
exfil_channel, canary_marker, secondary_canary, c2_indicators, encoding,
|
|
592
|
+
sandbox_evasion_detected, declared_intent, actual_behavior, context_canary,
|
|
593
|
+
ioc_export_path)
|
|
594
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
595
|
+
""",
|
|
596
|
+
(
|
|
597
|
+
str(scan_id),
|
|
598
|
+
1 if active.triggered else 0,
|
|
599
|
+
_json(active.targeted_assets),
|
|
600
|
+
active.exfil_channel.value if active.exfil_channel else None,
|
|
601
|
+
active.canary_marker,
|
|
602
|
+
active.secondary_canary,
|
|
603
|
+
_json(active.c2_indicators),
|
|
604
|
+
active.encoding,
|
|
605
|
+
1 if active.sandbox_evasion_detected else 0,
|
|
606
|
+
active.declared_intent,
|
|
607
|
+
active.actual_behavior,
|
|
608
|
+
active.context_aware_canary.value if active.context_aware_canary else None,
|
|
609
|
+
active.ioc_export,
|
|
610
|
+
),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
def _write_trace(self, conn: sqlite3.Connection, scan_id: UUID, detail: SkillDetail) -> None:
|
|
614
|
+
for event in detail.sandbox_trace.events:
|
|
615
|
+
conn.execute(
|
|
616
|
+
"""
|
|
617
|
+
INSERT INTO canary_events(scan_id, ts, event_type, path_or_channel, marker,
|
|
618
|
+
process, detail_json)
|
|
619
|
+
VALUES(?,?,?,?,?,?,?)
|
|
620
|
+
""",
|
|
621
|
+
(
|
|
622
|
+
str(scan_id),
|
|
623
|
+
format_utc(event.ts),
|
|
624
|
+
event.event_type,
|
|
625
|
+
event.path_or_channel,
|
|
626
|
+
event.marker,
|
|
627
|
+
event.process,
|
|
628
|
+
_json(event.detail or {}),
|
|
629
|
+
),
|
|
630
|
+
)
|
|
631
|
+
for net in detail.sandbox_trace.net_logs:
|
|
632
|
+
conn.execute(
|
|
633
|
+
"""
|
|
634
|
+
INSERT INTO sandbox_net_logs(scan_id, ts, protocol, query_or_url, body_b64,
|
|
635
|
+
responder_action)
|
|
636
|
+
VALUES(?,?,?,?,?,?)
|
|
637
|
+
""",
|
|
638
|
+
(
|
|
639
|
+
str(scan_id),
|
|
640
|
+
format_utc(net.ts),
|
|
641
|
+
net.protocol,
|
|
642
|
+
net.query_or_url,
|
|
643
|
+
net.body_b64,
|
|
644
|
+
net.responder_action,
|
|
645
|
+
),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
def _write_iocs(
|
|
649
|
+
self, conn: sqlite3.Connection, scan_id: UUID, verdict: Verdict, detail: SkillDetail
|
|
650
|
+
) -> None:
|
|
651
|
+
profile = detail.poisoned_well
|
|
652
|
+
if profile.active_defense is None:
|
|
653
|
+
return
|
|
654
|
+
active = profile.active_defense
|
|
655
|
+
confirmed = bool(profile.confirmed or active.triggered)
|
|
656
|
+
seen = format_utc(utc_now())
|
|
657
|
+
values: set[tuple[str, str, str | None]] = set()
|
|
658
|
+
for indicator in active.c2_indicators:
|
|
659
|
+
ioc_type = _classify_ioc(indicator)
|
|
660
|
+
if ioc_type:
|
|
661
|
+
values.add((ioc_type, indicator, "dns" if _looks_domain(indicator) else "http"))
|
|
662
|
+
for node in profile.graph.nodes:
|
|
663
|
+
if node.kind not in {"c2", "channel"}:
|
|
664
|
+
continue
|
|
665
|
+
if node.kind == "c2" and node.label and ("." in node.label or ":" in node.label):
|
|
666
|
+
ioc_type = _classify_ioc(node.label)
|
|
667
|
+
if ioc_type:
|
|
668
|
+
values.add((ioc_type, node.label, "graph"))
|
|
669
|
+
for ioc_type, value, channel in sorted(values):
|
|
670
|
+
conn.execute(
|
|
671
|
+
"""
|
|
672
|
+
INSERT OR IGNORE INTO iocs(scan_id, ioc_type, value, channel, first_seen, confirmed)
|
|
673
|
+
VALUES(?,?,?,?,?,?)
|
|
674
|
+
""",
|
|
675
|
+
(str(scan_id), ioc_type, value[:2048], channel, seen, 1 if confirmed else 0),
|
|
676
|
+
)
|
|
677
|
+
if values:
|
|
678
|
+
profile_id = str(uuid4())
|
|
679
|
+
profile_json = _json(
|
|
680
|
+
{
|
|
681
|
+
"scan_id": str(scan_id),
|
|
682
|
+
"triggered": active.triggered,
|
|
683
|
+
"targeted_assets": active.targeted_assets,
|
|
684
|
+
"channel": active.exfil_channel.value if active.exfil_channel else None,
|
|
685
|
+
"marker": active.canary_marker,
|
|
686
|
+
"secondary": active.secondary_canary,
|
|
687
|
+
"c2": active.c2_indicators,
|
|
688
|
+
"encoding": active.encoding,
|
|
689
|
+
"declared_intent": active.declared_intent,
|
|
690
|
+
"actual_behavior": active.actual_behavior,
|
|
691
|
+
"context_canary": (
|
|
692
|
+
active.context_aware_canary.value if active.context_aware_canary else None
|
|
693
|
+
),
|
|
694
|
+
}
|
|
695
|
+
)
|
|
696
|
+
conn.execute(
|
|
697
|
+
"""
|
|
698
|
+
INSERT OR REPLACE INTO attacker_profiles(id, scan_id, profile_json, created_at)
|
|
699
|
+
VALUES(?,?,?,?)
|
|
700
|
+
""",
|
|
701
|
+
(profile_id, str(scan_id), profile_json, seen),
|
|
702
|
+
)
|
|
703
|
+
del verdict
|
|
704
|
+
|
|
705
|
+
# -- reads -------------------------------------------------------------
|
|
706
|
+
def lookup_cache(
|
|
707
|
+
self,
|
|
708
|
+
content_hash: str,
|
|
709
|
+
*,
|
|
710
|
+
engine_version: str,
|
|
711
|
+
model_version: str,
|
|
712
|
+
model_backend: str | None = None,
|
|
713
|
+
backend_strict: bool = False,
|
|
714
|
+
) -> CachedResult | None:
|
|
715
|
+
sql = (
|
|
716
|
+
"SELECT * FROM scan_cache WHERE content_hash=? AND engine_version=? AND model_version=?"
|
|
717
|
+
)
|
|
718
|
+
params: list[Any] = [content_hash, engine_version, model_version]
|
|
719
|
+
if backend_strict and model_backend:
|
|
720
|
+
sql += " AND model_backend=?"
|
|
721
|
+
params.append(model_backend)
|
|
722
|
+
with self._lock:
|
|
723
|
+
row = self._conn.execute(sql, params).fetchone()
|
|
724
|
+
if row is None:
|
|
725
|
+
return None
|
|
726
|
+
try:
|
|
727
|
+
verdict = Verdict.model_validate_json(row["verdict_json"])
|
|
728
|
+
if row["detail_json"]:
|
|
729
|
+
detail = SkillDetail.model_validate_json(row["detail_json"])
|
|
730
|
+
else: # old rows: rebuild a minimal detail payload
|
|
731
|
+
detail = SkillDetail(
|
|
732
|
+
verdict=verdict,
|
|
733
|
+
report_links={
|
|
734
|
+
"sarif": f"/skills/{verdict.skill_id}/export?format=sarif",
|
|
735
|
+
"markdown": f"/skills/{verdict.skill_id}/export?format=markdown",
|
|
736
|
+
},
|
|
737
|
+
)
|
|
738
|
+
except Exception as exc:
|
|
739
|
+
raise StoreError(f"cached verdict is corrupt: {exc}") from exc
|
|
740
|
+
return CachedResult(
|
|
741
|
+
content_hash=row["content_hash"],
|
|
742
|
+
skill_name=row["skill_name"],
|
|
743
|
+
verdict=verdict,
|
|
744
|
+
detail=detail,
|
|
745
|
+
sarif_json=row["sarif_json"],
|
|
746
|
+
engine_version=row["engine_version"],
|
|
747
|
+
model_backend=row["model_backend"],
|
|
748
|
+
model_version=row["model_version"],
|
|
749
|
+
scan_duration_ms=int(row["scan_duration_ms"] or 0),
|
|
750
|
+
created_at=row["created_at"],
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
def get_scan(self, scan_id: UUID) -> ScanRecord | None:
|
|
754
|
+
with self._lock:
|
|
755
|
+
row = self._conn.execute(
|
|
756
|
+
"SELECT * FROM scans WHERE scan_id=?", (str(scan_id),)
|
|
757
|
+
).fetchone()
|
|
758
|
+
return _scan_record(row) if row else None
|
|
759
|
+
|
|
760
|
+
def list_scans(
|
|
761
|
+
self,
|
|
762
|
+
*,
|
|
763
|
+
limit: int = 100,
|
|
764
|
+
offset: int = 0,
|
|
765
|
+
status: ScanStatus | None = None,
|
|
766
|
+
) -> list[dict[str, Any]]:
|
|
767
|
+
sql = """
|
|
768
|
+
SELECT s.*, (
|
|
769
|
+
SELECT f.message FROM findings f
|
|
770
|
+
WHERE f.scan_id=s.scan_id
|
|
771
|
+
ORDER BY CASE f.severity
|
|
772
|
+
WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
|
773
|
+
WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END
|
|
774
|
+
LIMIT 1
|
|
775
|
+
) AS key_finding
|
|
776
|
+
FROM scans s
|
|
777
|
+
"""
|
|
778
|
+
params: list[Any] = []
|
|
779
|
+
if status is not None:
|
|
780
|
+
sql += " WHERE s.status=?"
|
|
781
|
+
params.append(status.value)
|
|
782
|
+
sql += " ORDER BY s.started_at DESC LIMIT ? OFFSET ?"
|
|
783
|
+
params.extend([limit, offset])
|
|
784
|
+
with self._lock:
|
|
785
|
+
rows = self._conn.execute(sql, params).fetchall()
|
|
786
|
+
result: list[dict[str, Any]] = []
|
|
787
|
+
for row in rows:
|
|
788
|
+
record = _scan_record(row)
|
|
789
|
+
result.append(
|
|
790
|
+
{
|
|
791
|
+
"scan_id": record.scan_id,
|
|
792
|
+
"skill_id": record.skill_id,
|
|
793
|
+
"skill": record.skill_name,
|
|
794
|
+
"verdict": record.verdict,
|
|
795
|
+
"risk_score": record.risk_score,
|
|
796
|
+
"cached": record.cached,
|
|
797
|
+
"status": record.status,
|
|
798
|
+
"started_at": record.started_at,
|
|
799
|
+
"duration_ms": record.duration_ms,
|
|
800
|
+
"key_finding": row["key_finding"],
|
|
801
|
+
}
|
|
802
|
+
)
|
|
803
|
+
return result
|
|
804
|
+
|
|
805
|
+
def get_result(
|
|
806
|
+
self, scan_id: UUID
|
|
807
|
+
) -> tuple[Verdict, SkillDetail, list[StageResult], bool] | None:
|
|
808
|
+
with self._lock:
|
|
809
|
+
row = self._conn.execute(
|
|
810
|
+
"SELECT * FROM scan_results WHERE scan_id=?", (str(scan_id),)
|
|
811
|
+
).fetchone()
|
|
812
|
+
if row is None:
|
|
813
|
+
return None
|
|
814
|
+
verdict = Verdict.model_validate_json(row["verdict_json"])
|
|
815
|
+
detail = SkillDetail.model_validate_json(row["detail_json"])
|
|
816
|
+
stages: list[StageResult] = []
|
|
817
|
+
if row["stage_results_json"]:
|
|
818
|
+
try:
|
|
819
|
+
stages = [
|
|
820
|
+
StageResult.model_validate(item)
|
|
821
|
+
for item in json.loads(row["stage_results_json"])
|
|
822
|
+
]
|
|
823
|
+
except Exception:
|
|
824
|
+
stages = []
|
|
825
|
+
return verdict, detail, stages, bool(verdict.cached)
|
|
826
|
+
|
|
827
|
+
def get_result_for_skill(
|
|
828
|
+
self, skill_id: UUID
|
|
829
|
+
) -> tuple[Verdict, SkillDetail, list[StageResult], bool] | None:
|
|
830
|
+
with self._lock:
|
|
831
|
+
row = self._conn.execute(
|
|
832
|
+
"SELECT scan_id FROM scans WHERE skill_id=? AND verdict IS NOT NULL "
|
|
833
|
+
"ORDER BY started_at DESC LIMIT 1",
|
|
834
|
+
(str(skill_id),),
|
|
835
|
+
).fetchone()
|
|
836
|
+
if row is None:
|
|
837
|
+
return None
|
|
838
|
+
return self.get_result(UUID(row["scan_id"]))
|
|
839
|
+
|
|
840
|
+
# -- cache administration ---------------------------------------------
|
|
841
|
+
def cache_stats(self) -> tuple[int, float]:
|
|
842
|
+
with self._lock:
|
|
843
|
+
entries = int(
|
|
844
|
+
self._conn.execute("SELECT COUNT(*) AS n FROM scan_cache").fetchone()["n"]
|
|
845
|
+
)
|
|
846
|
+
total = int(
|
|
847
|
+
self._conn.execute(
|
|
848
|
+
"SELECT COUNT(*) AS n FROM scans WHERE status IN ('completed','cached-hit')"
|
|
849
|
+
).fetchone()["n"]
|
|
850
|
+
)
|
|
851
|
+
hits = int(
|
|
852
|
+
self._conn.execute(
|
|
853
|
+
"SELECT COUNT(*) AS n FROM scans WHERE cached=1 AND status IN "
|
|
854
|
+
"('completed','cached-hit')"
|
|
855
|
+
).fetchone()["n"]
|
|
856
|
+
)
|
|
857
|
+
rate = (hits / total) if total else 0.0
|
|
858
|
+
return entries, round(rate, 3)
|
|
859
|
+
|
|
860
|
+
def clear_cache(self, older_than: str | None = None) -> tuple[int, int]:
|
|
861
|
+
age = _parse_age(older_than)
|
|
862
|
+
with self._lock:
|
|
863
|
+
total = int(self._conn.execute("SELECT COUNT(*) AS n FROM scan_cache").fetchone()["n"])
|
|
864
|
+
if age is None:
|
|
865
|
+
deleted = int(self._conn.execute("DELETE FROM scan_cache").rowcount)
|
|
866
|
+
else:
|
|
867
|
+
cutoff = format_utc(utc_now() - age)
|
|
868
|
+
deleted = int(
|
|
869
|
+
self._conn.execute(
|
|
870
|
+
"DELETE FROM scan_cache WHERE created_at < ?", (cutoff,)
|
|
871
|
+
).rowcount
|
|
872
|
+
)
|
|
873
|
+
return deleted, max(0, total - deleted)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _looks_domain(value: str) -> bool:
|
|
877
|
+
return "." in value and " " not in value and "/" not in value
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _classify_ioc(value: str) -> str | None:
|
|
881
|
+
value = value.strip()
|
|
882
|
+
if not value:
|
|
883
|
+
return None
|
|
884
|
+
if value.startswith(("http://", "https://")):
|
|
885
|
+
return "url" if len(value) <= 2048 else None
|
|
886
|
+
if _looks_domain(value):
|
|
887
|
+
return "domain-name"
|
|
888
|
+
import ipaddress
|
|
889
|
+
|
|
890
|
+
try:
|
|
891
|
+
ipaddress.ip_address(value)
|
|
892
|
+
except ValueError:
|
|
893
|
+
return "hostname-pattern" if "*" in value else None
|
|
894
|
+
return "ipv4-addr" if ":" not in value else None
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _scan_record(row: sqlite3.Row) -> ScanRecord:
|
|
898
|
+
return ScanRecord(
|
|
899
|
+
scan_id=UUID(row["scan_id"]),
|
|
900
|
+
skill_name=row["skill_name"],
|
|
901
|
+
skill_path=row["skill_path"],
|
|
902
|
+
content_hash=row["content_hash"],
|
|
903
|
+
status=ScanStatus(row["status"]),
|
|
904
|
+
verdict=VerdictTier(row["verdict"]) if row["verdict"] else None,
|
|
905
|
+
risk_score=row["risk_score"],
|
|
906
|
+
confidence=row["confidence"],
|
|
907
|
+
cached=bool(row["cached"]),
|
|
908
|
+
backend=row["backend"],
|
|
909
|
+
skill_id=UUID(row["skill_id"]) if row["skill_id"] else None,
|
|
910
|
+
started_at=_parse_ts(row["started_at"]) or utc_now(),
|
|
911
|
+
finished_at=_parse_ts(row["finished_at"]),
|
|
912
|
+
duration_ms=row["duration_ms"],
|
|
913
|
+
error=row["error"],
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
__all__ = [
|
|
918
|
+
"CACHE_KEY_VERSION",
|
|
919
|
+
"SCHEMA_VERSION",
|
|
920
|
+
"CachedResult",
|
|
921
|
+
"SQLiteStore",
|
|
922
|
+
"ScanRecord",
|
|
923
|
+
"StoreError",
|
|
924
|
+
"cache_key",
|
|
925
|
+
]
|