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.
Files changed (96) hide show
  1. skill_sentry_cli/__init__.py +5 -0
  2. skill_sentry_cli/__main__.py +4 -0
  3. skill_sentry_cli/cli.py +918 -0
  4. skill_sentry_cli/core/__init__.py +1 -0
  5. skill_sentry_cli/core/anchors/__init__.py +5 -0
  6. skill_sentry_cli/core/anchors/credential_flow.py +440 -0
  7. skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
  8. skill_sentry_cli/core/cache/__init__.py +35 -0
  9. skill_sentry_cli/core/cache/hash_utils.py +206 -0
  10. skill_sentry_cli/core/cache/sqlite_store.py +925 -0
  11. skill_sentry_cli/core/config.py +299 -0
  12. skill_sentry_cli/core/contracts.py +551 -0
  13. skill_sentry_cli/core/decisions/__init__.py +44 -0
  14. skill_sentry_cli/core/decisions/base.py +174 -0
  15. skill_sentry_cli/core/decisions/calibration.py +325 -0
  16. skill_sentry_cli/core/decisions/laya.py +439 -0
  17. skill_sentry_cli/core/decisions/mapping.py +218 -0
  18. skill_sentry_cli/core/decisions/mock.py +118 -0
  19. skill_sentry_cli/core/decisions/questions.py +118 -0
  20. skill_sentry_cli/core/decisions/report.py +65 -0
  21. skill_sentry_cli/core/decisions/selector.py +151 -0
  22. skill_sentry_cli/core/engine.py +1412 -0
  23. skill_sentry_cli/core/evasion/__init__.py +27 -0
  24. skill_sentry_cli/core/evasion/detector.py +210 -0
  25. skill_sentry_cli/core/exit_codes.py +13 -0
  26. skill_sentry_cli/core/models/__init__.py +20 -0
  27. skill_sentry_cli/core/models/_json.py +147 -0
  28. skill_sentry_cli/core/models/base.py +50 -0
  29. skill_sentry_cli/core/models/gemma_cloud.py +240 -0
  30. skill_sentry_cli/core/models/gemma_local.py +273 -0
  31. skill_sentry_cli/core/models/image_utils.py +112 -0
  32. skill_sentry_cli/core/models/mock.py +152 -0
  33. skill_sentry_cli/core/models/selector.py +123 -0
  34. skill_sentry_cli/core/parser/__init__.py +29 -0
  35. skill_sentry_cli/core/parser/skill_parser.py +520 -0
  36. skill_sentry_cli/core/parser/typosquat.py +167 -0
  37. skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
  38. skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
  39. skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
  40. skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
  41. skill_sentry_cli/core/reports.py +114 -0
  42. skill_sentry_cli/core/sandbox/__init__.py +74 -0
  43. skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
  44. skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
  45. skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
  46. skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
  47. skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
  48. skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
  49. skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
  50. skill_sentry_cli/core/sandbox/fake_http.py +194 -0
  51. skill_sentry_cli/core/sandbox/instructions.py +170 -0
  52. skill_sentry_cli/core/sandbox/masquerade.py +265 -0
  53. skill_sentry_cli/core/sandbox/monitor.py +278 -0
  54. skill_sentry_cli/core/sandbox/runner.py +776 -0
  55. skill_sentry_cli/core/sandbox/sidecar.py +327 -0
  56. skill_sentry_cli/core/semantic/__init__.py +9 -0
  57. skill_sentry_cli/core/semantic/analyzer.py +443 -0
  58. skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
  59. skill_sentry_cli/core/semantic/ast_python.py +156 -0
  60. skill_sentry_cli/core/semantic/candidates.py +78 -0
  61. skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
  62. skill_sentry_cli/core/semantic/prompts.py +61 -0
  63. skill_sentry_cli/core/verdict/__init__.py +24 -0
  64. skill_sentry_cli/core/verdict/fusion.py +392 -0
  65. skill_sentry_cli/core/verdict/markdown_report.py +211 -0
  66. skill_sentry_cli/core/verdict/sarif_report.py +139 -0
  67. skill_sentry_cli/core/verdict/status.py +35 -0
  68. skill_sentry_cli/core/verdict/stix_report.py +132 -0
  69. skill_sentry_cli/core/version.py +38 -0
  70. skill_sentry_cli/core/vision/__init__.py +8 -0
  71. skill_sentry_cli/core/vision/image_extractor.py +91 -0
  72. skill_sentry_cli/core/vision/vision_scanner.py +274 -0
  73. skill_sentry_cli/data/__init__.py +1 -0
  74. skill_sentry_cli/data/known_skills_v1.json +276 -0
  75. skill_sentry_cli/data/sandbox_image.json +4 -0
  76. skill_sentry_cli/rules/credential_rules.toml +94 -0
  77. skill_sentry_cli/server/__init__.py +5 -0
  78. skill_sentry_cli/server/app.py +129 -0
  79. skill_sentry_cli/server/backend.py +63 -0
  80. skill_sentry_cli/server/demo_assets.py +62 -0
  81. skill_sentry_cli/server/demo_backend.py +475 -0
  82. skill_sentry_cli/server/demo_data.py +814 -0
  83. skill_sentry_cli/server/demo_mode.py +40 -0
  84. skill_sentry_cli/server/errors.py +48 -0
  85. skill_sentry_cli/server/live_backend.py +576 -0
  86. skill_sentry_cli/server/routes.py +191 -0
  87. skill_sentry_cli/skill/SKILL.md +67 -0
  88. skill_sentry_cli/skill/scripts/audit.sh +48 -0
  89. skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
  90. skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
  91. skill_sentry_cli/ui/dist/index.html +14 -0
  92. skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
  93. skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
  94. skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
  95. skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
  96. 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
+ ]