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,1412 @@
|
|
|
1
|
+
"""Live scan orchestrator.
|
|
2
|
+
|
|
3
|
+
This module is the single place where the stages are ordered. It is
|
|
4
|
+
synchronous by design: the API runs it in a worker thread and streams the
|
|
5
|
+
``WSMessage`` callback into an asyncio queue, while the CLI calls it directly.
|
|
6
|
+
No demo/canned verdict path exists here; every field comes from the package,
|
|
7
|
+
the model, the sandbox, or an explicit degradation note.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
from uuid import UUID, uuid4, uuid5
|
|
22
|
+
|
|
23
|
+
from skill_sentry_cli.core.anchors.credential_flow import RULES_FINGERPRINT
|
|
24
|
+
from skill_sentry_cli.core.cache import HashError, SQLiteStore
|
|
25
|
+
from skill_sentry_cli.core.cache.sqlite_store import StoreError
|
|
26
|
+
from skill_sentry_cli.core.config import SkillsEntryConfig, load_config
|
|
27
|
+
from skill_sentry_cli.core.contracts import (
|
|
28
|
+
ActiveDefense,
|
|
29
|
+
Finding,
|
|
30
|
+
FindingEvidence,
|
|
31
|
+
IOCGraph,
|
|
32
|
+
IOCGraphEdge,
|
|
33
|
+
IOCGraphNode,
|
|
34
|
+
PoisonedWellView,
|
|
35
|
+
SandboxTrace,
|
|
36
|
+
ScanOptions,
|
|
37
|
+
ScanStatus,
|
|
38
|
+
Severity,
|
|
39
|
+
SkillDetail,
|
|
40
|
+
Stage,
|
|
41
|
+
StageResult,
|
|
42
|
+
StaticFinding,
|
|
43
|
+
TimelineRow,
|
|
44
|
+
Verdict,
|
|
45
|
+
VisionEvidence,
|
|
46
|
+
WSEventType,
|
|
47
|
+
WSMessage,
|
|
48
|
+
utc_now,
|
|
49
|
+
)
|
|
50
|
+
from skill_sentry_cli.core.decisions import (
|
|
51
|
+
DecisionClient,
|
|
52
|
+
DecisionSelection,
|
|
53
|
+
DecisionUnavailable,
|
|
54
|
+
select_decision_model,
|
|
55
|
+
)
|
|
56
|
+
from skill_sentry_cli.core.decisions.calibration import CalibrationError, load_calibration
|
|
57
|
+
from skill_sentry_cli.core.decisions.mapping import CAPABILITY_THRESHOLD
|
|
58
|
+
from skill_sentry_cli.core.evasion.detector import EvasionResult, detect_probes
|
|
59
|
+
from skill_sentry_cli.core.models.base import ModelClient, ModelError
|
|
60
|
+
from skill_sentry_cli.core.models.mock import MockGemma
|
|
61
|
+
from skill_sentry_cli.core.models.selector import select_model
|
|
62
|
+
from skill_sentry_cli.core.parser import SkillPackage, SkillParseError, parse_skill
|
|
63
|
+
from skill_sentry_cli.core.parser.typosquat import TyposquatMatch, check_typosquat
|
|
64
|
+
from skill_sentry_cli.core.poisoned_well.attacker_profile import build_attacker_profile
|
|
65
|
+
from skill_sentry_cli.core.poisoned_well.canary_gen import (
|
|
66
|
+
CanarySet,
|
|
67
|
+
normalize_intent,
|
|
68
|
+
)
|
|
69
|
+
from skill_sentry_cli.core.poisoned_well.exfil_tracker import Correlation, ExfilTracker
|
|
70
|
+
from skill_sentry_cli.core.reports import render_report
|
|
71
|
+
from skill_sentry_cli.core.sandbox.instructions import build_plan as build_sandbox_plan
|
|
72
|
+
from skill_sentry_cli.core.sandbox.masquerade import MasqueradeConfig
|
|
73
|
+
from skill_sentry_cli.core.sandbox.runner import (
|
|
74
|
+
SandboxError,
|
|
75
|
+
SandboxRequest,
|
|
76
|
+
SandboxResult,
|
|
77
|
+
SandboxRunner,
|
|
78
|
+
SandboxUnavailable,
|
|
79
|
+
)
|
|
80
|
+
from skill_sentry_cli.core.sandbox.sidecar import SinkholeConfig, SinkholeSidecar
|
|
81
|
+
from skill_sentry_cli.core.semantic import analyze_semantics, scan_anchors
|
|
82
|
+
from skill_sentry_cli.core.semantic.laya_analyzer import (
|
|
83
|
+
analyze_semantics_laya,
|
|
84
|
+
degraded_semantic_stage,
|
|
85
|
+
deterministic_only_stage,
|
|
86
|
+
)
|
|
87
|
+
from skill_sentry_cli.core.verdict.fusion import FusionError, FusionResult, fuse
|
|
88
|
+
from skill_sentry_cli.core.version import engine_version
|
|
89
|
+
from skill_sentry_cli.core.vision import scan_vision
|
|
90
|
+
|
|
91
|
+
SCAN_NAMESPACE = UUID("1b0f6d9e-3f7c-4a62-a2d8-2b9a0c6e5f41")
|
|
92
|
+
MAX_DISCOVERED_SKILLS = 500
|
|
93
|
+
_SKILL_STAGE_ORDER: tuple[Stage, ...] = (
|
|
94
|
+
Stage.CACHE,
|
|
95
|
+
Stage.SPEC,
|
|
96
|
+
Stage.TYPOSQUAT,
|
|
97
|
+
Stage.DETERMINISTIC_ANCHOR,
|
|
98
|
+
Stage.SEMANTIC,
|
|
99
|
+
Stage.VISION,
|
|
100
|
+
Stage.SANDBOX,
|
|
101
|
+
Stage.POISONED_WELL,
|
|
102
|
+
Stage.EVASION,
|
|
103
|
+
Stage.VERDICT,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ScanError(RuntimeError):
|
|
108
|
+
"""A scan cannot continue; the caller must surface an error, not a verdict."""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
message: str,
|
|
113
|
+
*,
|
|
114
|
+
code: str = "scan_failed",
|
|
115
|
+
stage: Stage | None = None,
|
|
116
|
+
degraded: bool = True,
|
|
117
|
+
status_code: int = 500,
|
|
118
|
+
) -> None:
|
|
119
|
+
super().__init__(message)
|
|
120
|
+
self.code = code
|
|
121
|
+
self.stage = stage
|
|
122
|
+
self.degraded = degraded
|
|
123
|
+
self.status_code = status_code
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ScanCancelled(ScanError):
|
|
127
|
+
def __init__(self, message: str = "cancelled by client") -> None:
|
|
128
|
+
super().__init__(message, code="cancelled", stage=None, degraded=True, status_code=409)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class ScanOutcome:
|
|
133
|
+
scan_id: UUID
|
|
134
|
+
skill_id: UUID
|
|
135
|
+
skill_name: str
|
|
136
|
+
verdict: Verdict
|
|
137
|
+
detail: SkillDetail
|
|
138
|
+
stage_results: list[StageResult]
|
|
139
|
+
cached: bool
|
|
140
|
+
started_at: datetime
|
|
141
|
+
duration_ms: int
|
|
142
|
+
status: ScanStatus = ScanStatus.COMPLETED
|
|
143
|
+
error: str | None = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class _ScanContext:
|
|
148
|
+
scan_id: UUID
|
|
149
|
+
started_at: datetime
|
|
150
|
+
started_monotonic: float
|
|
151
|
+
package: SkillPackage
|
|
152
|
+
options: ScanOptions
|
|
153
|
+
skill_name: str
|
|
154
|
+
skill_id: UUID
|
|
155
|
+
cache_hit: bool = False
|
|
156
|
+
stage_results: list[StageResult] = field(default_factory=list)
|
|
157
|
+
sandbox_result: SandboxResult | None = None
|
|
158
|
+
sandbox_skipped: bool = False
|
|
159
|
+
canaries: CanarySet | None = None
|
|
160
|
+
correlation: Correlation | None = None
|
|
161
|
+
evasion: EvasionResult | None = None
|
|
162
|
+
active: ActiveDefense | None = None
|
|
163
|
+
trace: SandboxTrace = field(default_factory=SandboxTrace)
|
|
164
|
+
vision_evidence: list[VisionEvidence] = field(default_factory=list)
|
|
165
|
+
pw_view: PoisonedWellView = field(default_factory=PoisonedWellView)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
ProgressCallback = Callable[[WSMessage], None]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ScanEngine:
|
|
172
|
+
"""Content-addressed, fail-closed scan pipeline."""
|
|
173
|
+
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
config: SkillsEntryConfig | None = None,
|
|
177
|
+
*,
|
|
178
|
+
store: SQLiteStore | None = None,
|
|
179
|
+
model: ModelClient | None = None,
|
|
180
|
+
sandbox_runner: SandboxRunner | None = None,
|
|
181
|
+
sandbox_runner_factory: Callable[[], SandboxRunner] | None = None,
|
|
182
|
+
progress: ProgressCallback | None = None,
|
|
183
|
+
decision_client: DecisionClient | None = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
self.config = config or load_config()
|
|
186
|
+
self._owns_store = store is None
|
|
187
|
+
self.store = store or SQLiteStore(self.config.db_path)
|
|
188
|
+
self._model = model
|
|
189
|
+
self._model_selected = model is not None
|
|
190
|
+
self._model_error: str | None = None
|
|
191
|
+
self._decision_client = decision_client
|
|
192
|
+
self._decision: DecisionSelection | None = None
|
|
193
|
+
self._sandbox_runner = sandbox_runner
|
|
194
|
+
self._sandbox_runner_factory = sandbox_runner_factory
|
|
195
|
+
self._progress = progress
|
|
196
|
+
|
|
197
|
+
def close(self) -> None:
|
|
198
|
+
runner_close = getattr(self._sandbox_runner, "close", None)
|
|
199
|
+
if callable(runner_close):
|
|
200
|
+
runner_close()
|
|
201
|
+
if self._decision is not None and self._decision.client is not None:
|
|
202
|
+
# Releases resident checkpoints; a no-op for clients without close().
|
|
203
|
+
close = getattr(self._decision.client, "close", None)
|
|
204
|
+
if callable(close):
|
|
205
|
+
close()
|
|
206
|
+
self._decision = None
|
|
207
|
+
if self._owns_store:
|
|
208
|
+
self.store.close()
|
|
209
|
+
|
|
210
|
+
# -- discovery ---------------------------------------------------------
|
|
211
|
+
def discover(self, path: str | Path) -> list[Path]:
|
|
212
|
+
"""Return every skill package beneath ``path``, in deterministic order.
|
|
213
|
+
|
|
214
|
+
The root itself wins: if it contains ``SKILL.md`` it is the only result,
|
|
215
|
+
even when it also contains nested packages. Symlinked directories are
|
|
216
|
+
never followed, and the result count is bounded so a hostile corpus
|
|
217
|
+
cannot make the CLI walk forever.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
root = Path(path).expanduser()
|
|
221
|
+
if not root.exists() or not root.is_dir():
|
|
222
|
+
raise ScanError(
|
|
223
|
+
f"skill path does not exist or is not a directory: {path}",
|
|
224
|
+
code="no_skill_manifest",
|
|
225
|
+
stage=Stage.SPEC,
|
|
226
|
+
status_code=404,
|
|
227
|
+
)
|
|
228
|
+
manifest = root / "SKILL.md"
|
|
229
|
+
if manifest.is_file() and not manifest.is_symlink():
|
|
230
|
+
return [root]
|
|
231
|
+
found: list[Path] = []
|
|
232
|
+
ignored = {".git", ".hg", ".svn", ".venv", "venv", "node_modules", "__pycache__"}
|
|
233
|
+
for current, dirnames, filenames in os.walk(root, followlinks=False):
|
|
234
|
+
current_path = Path(current)
|
|
235
|
+
dirnames[:] = sorted(
|
|
236
|
+
name
|
|
237
|
+
for name in dirnames
|
|
238
|
+
if name not in ignored and not (current_path / name).is_symlink()
|
|
239
|
+
)
|
|
240
|
+
current_manifest = current_path / "SKILL.md"
|
|
241
|
+
if (
|
|
242
|
+
"SKILL.md" in filenames
|
|
243
|
+
and current_manifest.is_file()
|
|
244
|
+
and not current_manifest.is_symlink()
|
|
245
|
+
):
|
|
246
|
+
found.append(current_path)
|
|
247
|
+
if len(found) > MAX_DISCOVERED_SKILLS:
|
|
248
|
+
raise ScanError(
|
|
249
|
+
f"more than {MAX_DISCOVERED_SKILLS} skills found under {root}; "
|
|
250
|
+
"scan a smaller directory",
|
|
251
|
+
code="too_many_skills",
|
|
252
|
+
stage=Stage.SPEC,
|
|
253
|
+
status_code=422,
|
|
254
|
+
)
|
|
255
|
+
found.sort(key=lambda item: item.as_posix())
|
|
256
|
+
if not found:
|
|
257
|
+
raise ScanError(
|
|
258
|
+
f"no SKILL.md found under {root}",
|
|
259
|
+
code="no_skill_manifest",
|
|
260
|
+
stage=Stage.SPEC,
|
|
261
|
+
status_code=404,
|
|
262
|
+
)
|
|
263
|
+
return found
|
|
264
|
+
|
|
265
|
+
# -- public scan -------------------------------------------------------
|
|
266
|
+
def scan(
|
|
267
|
+
self,
|
|
268
|
+
path: str | Path,
|
|
269
|
+
*,
|
|
270
|
+
options: ScanOptions | None = None,
|
|
271
|
+
scan_id: UUID | None = None,
|
|
272
|
+
callback: ProgressCallback | None = None,
|
|
273
|
+
cancel: threading.Event | None = None,
|
|
274
|
+
) -> ScanOutcome:
|
|
275
|
+
opts = options or ScanOptions()
|
|
276
|
+
sid = scan_id or uuid4()
|
|
277
|
+
started_at = utc_now()
|
|
278
|
+
started_monotonic = time.monotonic()
|
|
279
|
+
emit = callback or self._progress
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
package = parse_skill(path)
|
|
283
|
+
except SkillParseError as exc:
|
|
284
|
+
code = "no_skill_manifest" if "no SKILL.md" in str(exc) else "invalid_skill"
|
|
285
|
+
raise ScanError(
|
|
286
|
+
str(exc), code=code, stage=Stage.SPEC, degraded=True, status_code=404
|
|
287
|
+
) from exc
|
|
288
|
+
except (OSError, HashError) as exc:
|
|
289
|
+
raise ScanError(
|
|
290
|
+
f"could not read skill package: {exc}",
|
|
291
|
+
code="invalid_skill",
|
|
292
|
+
stage=Stage.SPEC,
|
|
293
|
+
degraded=True,
|
|
294
|
+
status_code=422,
|
|
295
|
+
) from exc
|
|
296
|
+
|
|
297
|
+
skill_id = uuid5(SCAN_NAMESPACE, f"{package.name}:{package.content_hash}")
|
|
298
|
+
self._emit(
|
|
299
|
+
emit,
|
|
300
|
+
Stage.CACHE,
|
|
301
|
+
package.name,
|
|
302
|
+
WSEventType.STARTED,
|
|
303
|
+
{"message": "hashing package", "hash": package.content_hash[:12]},
|
|
304
|
+
)
|
|
305
|
+
backend, model_version = self._model_identity()
|
|
306
|
+
try:
|
|
307
|
+
self.store.create_scan(
|
|
308
|
+
sid,
|
|
309
|
+
skill_name=package.name,
|
|
310
|
+
skill_path=str(package.root),
|
|
311
|
+
content_hash=package.content_hash,
|
|
312
|
+
backend=backend,
|
|
313
|
+
skill_id=skill_id,
|
|
314
|
+
status=ScanStatus.RUNNING,
|
|
315
|
+
started_at=started_at,
|
|
316
|
+
)
|
|
317
|
+
except StoreError as exc:
|
|
318
|
+
raise ScanError(f"could not create scan history: {exc}") from exc
|
|
319
|
+
|
|
320
|
+
cache_eligible = self._cache_eligible(opts)
|
|
321
|
+
try:
|
|
322
|
+
cache_hit = None
|
|
323
|
+
if cache_eligible and not opts.no_cache:
|
|
324
|
+
cache_hit = self.store.lookup_cache(
|
|
325
|
+
package.content_hash,
|
|
326
|
+
engine_version=engine_version(self.config.scoring.weights),
|
|
327
|
+
model_version=model_version,
|
|
328
|
+
model_backend=backend,
|
|
329
|
+
backend_strict=self.config.cache.backend_strict,
|
|
330
|
+
)
|
|
331
|
+
if cache_hit is not None:
|
|
332
|
+
return self._cached_outcome(
|
|
333
|
+
cache_hit,
|
|
334
|
+
sid=sid,
|
|
335
|
+
started_at=started_at,
|
|
336
|
+
started_monotonic=started_monotonic,
|
|
337
|
+
emit=emit,
|
|
338
|
+
)
|
|
339
|
+
if cache_eligible:
|
|
340
|
+
cache_message = "cache miss"
|
|
341
|
+
elif opts.no_cache:
|
|
342
|
+
cache_message = "cache bypassed by --no-cache"
|
|
343
|
+
else:
|
|
344
|
+
cache_message = "cache bypassed for static-only or non-default analysis mode"
|
|
345
|
+
self._emit(
|
|
346
|
+
emit,
|
|
347
|
+
Stage.CACHE,
|
|
348
|
+
package.name,
|
|
349
|
+
WSEventType.PROGRESS,
|
|
350
|
+
{"message": cache_message, "hash": package.content_hash[:12]},
|
|
351
|
+
)
|
|
352
|
+
ctx = _ScanContext(
|
|
353
|
+
scan_id=sid,
|
|
354
|
+
started_at=started_at,
|
|
355
|
+
started_monotonic=started_monotonic,
|
|
356
|
+
package=package,
|
|
357
|
+
options=opts,
|
|
358
|
+
skill_name=package.name,
|
|
359
|
+
skill_id=skill_id,
|
|
360
|
+
)
|
|
361
|
+
return self._run_pipeline(ctx, emit=emit, cancel=cancel)
|
|
362
|
+
except ScanCancelled:
|
|
363
|
+
self.store.fail_scan(sid, "cancelled by client")
|
|
364
|
+
raise
|
|
365
|
+
except ScanError as exc:
|
|
366
|
+
# A storage/verdict failure must not leave a RUNNING row behind;
|
|
367
|
+
# the scan history is the audit trail for an operational error.
|
|
368
|
+
with contextlib.suppress(Exception):
|
|
369
|
+
self.store.fail_scan(sid, str(exc)[:2000])
|
|
370
|
+
raise
|
|
371
|
+
except Exception as exc:
|
|
372
|
+
message = f"scan failed: {exc}"
|
|
373
|
+
self.store.fail_scan(sid, message[:2000])
|
|
374
|
+
self._emit(
|
|
375
|
+
emit,
|
|
376
|
+
Stage.VERDICT,
|
|
377
|
+
package.name,
|
|
378
|
+
WSEventType.ERROR,
|
|
379
|
+
{"code": "scan_failed", "message": message, "degraded": True},
|
|
380
|
+
)
|
|
381
|
+
raise ScanError(message) from exc
|
|
382
|
+
|
|
383
|
+
# -- pipeline ----------------------------------------------------------
|
|
384
|
+
def _run_pipeline(
|
|
385
|
+
self,
|
|
386
|
+
ctx: _ScanContext,
|
|
387
|
+
*,
|
|
388
|
+
emit: ProgressCallback | None,
|
|
389
|
+
cancel: threading.Event | None,
|
|
390
|
+
) -> ScanOutcome:
|
|
391
|
+
self._check_cancel(cancel)
|
|
392
|
+
|
|
393
|
+
spec = self._spec_stage(ctx.package)
|
|
394
|
+
self._record(ctx, spec, emit)
|
|
395
|
+
self._check_cancel(cancel)
|
|
396
|
+
|
|
397
|
+
typosquat = self._typosquat_stage(ctx.package.name)
|
|
398
|
+
self._record(ctx, typosquat, emit)
|
|
399
|
+
self._check_cancel(cancel)
|
|
400
|
+
|
|
401
|
+
anchors = scan_anchors(ctx.package)
|
|
402
|
+
self._record(ctx, anchors, emit)
|
|
403
|
+
self._check_cancel(cancel)
|
|
404
|
+
|
|
405
|
+
model = self._get_model()
|
|
406
|
+
semantic = self._semantic_stage(ctx.package, anchors, model)
|
|
407
|
+
self._record(ctx, semantic, emit)
|
|
408
|
+
self._check_cancel(cancel)
|
|
409
|
+
|
|
410
|
+
vision = scan_vision(ctx.package, model, max_px=self.config.model.image_max_px)
|
|
411
|
+
self._record(ctx, vision, emit)
|
|
412
|
+
ctx.vision_evidence = self._vision_evidence(vision)
|
|
413
|
+
self._check_cancel(cancel)
|
|
414
|
+
|
|
415
|
+
sandbox = self._sandbox_stage(ctx, emit)
|
|
416
|
+
self._record(ctx, sandbox, emit)
|
|
417
|
+
if ctx.sandbox_result is not None:
|
|
418
|
+
ctx.trace = ctx.sandbox_result.to_trace()
|
|
419
|
+
self._check_cancel(cancel)
|
|
420
|
+
|
|
421
|
+
pw_stage, evasion_stage = self._active_defense_stages(ctx, emit)
|
|
422
|
+
self._record(ctx, pw_stage, emit)
|
|
423
|
+
self._record(ctx, evasion_stage, emit)
|
|
424
|
+
self._check_cancel(cancel)
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
fused = fuse(
|
|
428
|
+
ctx.stage_results,
|
|
429
|
+
weights=self.config.scoring.weights,
|
|
430
|
+
typosquat_bonus=self.config.scoring.typosquat_bonus,
|
|
431
|
+
safe_max=self.config.scoring.tiers.safe_max,
|
|
432
|
+
malicious_min=self.config.scoring.tiers.malicious_min,
|
|
433
|
+
typosquat_detected=bool(typosquat.findings),
|
|
434
|
+
)
|
|
435
|
+
except FusionError as exc:
|
|
436
|
+
raise ScanError(
|
|
437
|
+
f"could not fuse stage results: {exc}",
|
|
438
|
+
code="fusion_failed",
|
|
439
|
+
stage=Stage.VERDICT,
|
|
440
|
+
degraded=True,
|
|
441
|
+
) from exc
|
|
442
|
+
|
|
443
|
+
duration_ms = max(0, int((time.monotonic() - ctx.started_monotonic) * 1000))
|
|
444
|
+
active = ctx.active
|
|
445
|
+
findings = list(fused.findings)
|
|
446
|
+
# Ensure a confirmed profile's finding is present even if a caller
|
|
447
|
+
# injected a custom stage list.
|
|
448
|
+
if (
|
|
449
|
+
active is not None
|
|
450
|
+
and active.triggered
|
|
451
|
+
and not any(finding.severity.rank >= Severity.CRITICAL.rank for finding in findings)
|
|
452
|
+
):
|
|
453
|
+
profile_finding = self._profile_finding(ctx)
|
|
454
|
+
if profile_finding is not None:
|
|
455
|
+
findings.append(profile_finding)
|
|
456
|
+
verdict = Verdict(
|
|
457
|
+
skill=ctx.skill_name,
|
|
458
|
+
skill_id=ctx.skill_id,
|
|
459
|
+
scan_id=ctx.scan_id,
|
|
460
|
+
verdict=fused.verdict,
|
|
461
|
+
risk_score=fused.risk_score,
|
|
462
|
+
confidence=fused.confidence,
|
|
463
|
+
cached=False,
|
|
464
|
+
scan_duration_ms=duration_ms,
|
|
465
|
+
findings=findings,
|
|
466
|
+
active_defense=active,
|
|
467
|
+
)
|
|
468
|
+
detail = self._detail(ctx, verdict, duration_ms)
|
|
469
|
+
self._finalize(
|
|
470
|
+
ctx,
|
|
471
|
+
verdict,
|
|
472
|
+
detail,
|
|
473
|
+
fused,
|
|
474
|
+
cache=self._cache_eligible(ctx.options)
|
|
475
|
+
and not any(result.degraded or result.error for result in ctx.stage_results),
|
|
476
|
+
)
|
|
477
|
+
self._emit(
|
|
478
|
+
emit,
|
|
479
|
+
Stage.VERDICT,
|
|
480
|
+
ctx.skill_name,
|
|
481
|
+
WSEventType.COMPLETED,
|
|
482
|
+
{
|
|
483
|
+
"verdict": verdict.model_dump(by_alias=True, mode="json"),
|
|
484
|
+
"durationMs": duration_ms,
|
|
485
|
+
"degraded": [stage.value for stage in fused.degraded_stages],
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
return ScanOutcome(
|
|
489
|
+
scan_id=ctx.scan_id,
|
|
490
|
+
skill_id=ctx.skill_id,
|
|
491
|
+
skill_name=ctx.skill_name,
|
|
492
|
+
verdict=verdict,
|
|
493
|
+
detail=detail,
|
|
494
|
+
stage_results=list(ctx.stage_results),
|
|
495
|
+
cached=False,
|
|
496
|
+
started_at=ctx.started_at,
|
|
497
|
+
duration_ms=duration_ms,
|
|
498
|
+
status=ScanStatus.COMPLETED,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# -- stages ------------------------------------------------------------
|
|
502
|
+
def _spec_stage(self, package: SkillPackage) -> StageResult:
|
|
503
|
+
findings: list[Finding] = []
|
|
504
|
+
high = False
|
|
505
|
+
for issue in package.issues:
|
|
506
|
+
severity = Severity(issue.severity)
|
|
507
|
+
high = high or severity.rank >= Severity.HIGH.rank
|
|
508
|
+
findings.append(
|
|
509
|
+
Finding(
|
|
510
|
+
stage=Stage.SPEC,
|
|
511
|
+
severity=severity,
|
|
512
|
+
message=issue.message,
|
|
513
|
+
type="manifest-issue",
|
|
514
|
+
evidence=FindingEvidence(
|
|
515
|
+
file=issue.file,
|
|
516
|
+
line=max(1, issue.line),
|
|
517
|
+
code=issue.code,
|
|
518
|
+
),
|
|
519
|
+
)
|
|
520
|
+
)
|
|
521
|
+
if not findings:
|
|
522
|
+
findings.append(
|
|
523
|
+
Finding(
|
|
524
|
+
stage=Stage.SPEC,
|
|
525
|
+
severity=Severity.NOTE,
|
|
526
|
+
message="Frontmatter valid - typosquat check clean",
|
|
527
|
+
type="spec-valid",
|
|
528
|
+
evidence=FindingEvidence(file="SKILL.md", line=1),
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
return StageResult(
|
|
532
|
+
stage=Stage.SPEC,
|
|
533
|
+
findings=findings,
|
|
534
|
+
telemetry={
|
|
535
|
+
"name": package.name,
|
|
536
|
+
"description": package.description,
|
|
537
|
+
"files": len(package.files),
|
|
538
|
+
"text_files": len(package.text_files),
|
|
539
|
+
"entrypoint": package.entrypoint,
|
|
540
|
+
"entrypoint_declared": package.entrypoint_declared,
|
|
541
|
+
"content_hash": package.content_hash,
|
|
542
|
+
},
|
|
543
|
+
degraded=high,
|
|
544
|
+
error="manifest validation failed" if high else None,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
def _typosquat_stage(self, name: str) -> StageResult:
|
|
548
|
+
match: TyposquatMatch | None = check_typosquat(name)
|
|
549
|
+
if match is None:
|
|
550
|
+
return StageResult(
|
|
551
|
+
stage=Stage.TYPOSQUAT,
|
|
552
|
+
findings=[],
|
|
553
|
+
telemetry={"name": name, "matched": False},
|
|
554
|
+
)
|
|
555
|
+
finding = Finding(
|
|
556
|
+
stage=Stage.TYPOSQUAT,
|
|
557
|
+
severity=Severity.MEDIUM,
|
|
558
|
+
message=match.message,
|
|
559
|
+
type="typosquat",
|
|
560
|
+
evidence=FindingEvidence(
|
|
561
|
+
file="SKILL.md",
|
|
562
|
+
line=1,
|
|
563
|
+
code=f"{name} -> {match.candidate}",
|
|
564
|
+
**{"candidate": match.candidate, "distance": match.distance},
|
|
565
|
+
),
|
|
566
|
+
)
|
|
567
|
+
return StageResult(
|
|
568
|
+
stage=Stage.TYPOSQUAT,
|
|
569
|
+
findings=[finding],
|
|
570
|
+
signals={"typosquat": 100.0},
|
|
571
|
+
telemetry={
|
|
572
|
+
"name": name,
|
|
573
|
+
"matched": True,
|
|
574
|
+
"candidate": match.candidate,
|
|
575
|
+
"distance": match.distance,
|
|
576
|
+
},
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
def _sandbox_stage(self, ctx: _ScanContext, emit: ProgressCallback | None) -> StageResult:
|
|
580
|
+
if not ctx.options.sandbox or not self.config.sandbox.enabled:
|
|
581
|
+
ctx.sandbox_skipped = True
|
|
582
|
+
self._emit(
|
|
583
|
+
emit,
|
|
584
|
+
Stage.SANDBOX,
|
|
585
|
+
ctx.skill_name,
|
|
586
|
+
WSEventType.PROGRESS,
|
|
587
|
+
{"message": "sandbox disabled; static-only evidence collected"},
|
|
588
|
+
)
|
|
589
|
+
return StageResult(
|
|
590
|
+
stage=Stage.SANDBOX,
|
|
591
|
+
findings=[
|
|
592
|
+
Finding(
|
|
593
|
+
stage=Stage.SANDBOX,
|
|
594
|
+
severity=Severity.NOTE,
|
|
595
|
+
message="Sandbox execution disabled for this scan (static-only mode).",
|
|
596
|
+
type="sandbox-disabled",
|
|
597
|
+
)
|
|
598
|
+
],
|
|
599
|
+
telemetry={"skipped": True, "reason": "disabled by request or config"},
|
|
600
|
+
)
|
|
601
|
+
if ctx.package.entrypoint_declared and ctx.package.entrypoint is None:
|
|
602
|
+
return StageResult(
|
|
603
|
+
stage=Stage.SANDBOX,
|
|
604
|
+
degraded=True,
|
|
605
|
+
error="declared entrypoint is invalid or missing; refusing to execute a fallback",
|
|
606
|
+
)
|
|
607
|
+
if ctx.package.entrypoint is None and not _has_script(ctx.package):
|
|
608
|
+
# Nothing executable in the package: there is nothing to run, so this is
|
|
609
|
+
# not a degraded sandbox. Scripts without a declared entrypoint still degrade.
|
|
610
|
+
return StageResult(
|
|
611
|
+
stage=Stage.SANDBOX,
|
|
612
|
+
findings=[],
|
|
613
|
+
telemetry={"skipped": True, "reason": "no executable content in the package"},
|
|
614
|
+
)
|
|
615
|
+
plan = build_sandbox_plan(
|
|
616
|
+
ctx.package,
|
|
617
|
+
StageResult(stage=Stage.DETERMINISTIC_ANCHOR),
|
|
618
|
+
timeout_s=min(self.config.sandbox.timeout_s, ctx.options.timeout_s),
|
|
619
|
+
)
|
|
620
|
+
if plan.validation_status != "valid":
|
|
621
|
+
# An invalid plan never runs: a partial check set must not read as a clean sandbox.
|
|
622
|
+
return StageResult(
|
|
623
|
+
stage=Stage.SANDBOX,
|
|
624
|
+
degraded=True,
|
|
625
|
+
error="sandbox plan rejected: " + "; ".join(plan.problems),
|
|
626
|
+
)
|
|
627
|
+
runner = self._get_sandbox_runner()
|
|
628
|
+
request = SandboxRequest(
|
|
629
|
+
scan_id=ctx.scan_id,
|
|
630
|
+
skill_dir=ctx.package.root,
|
|
631
|
+
declared_intent=ctx.package.declared_intent,
|
|
632
|
+
command=tuple(ctx.package.entrypoint) if ctx.package.entrypoint else None,
|
|
633
|
+
timeout_s=float(min(self.config.sandbox.timeout_s, ctx.options.timeout_s)),
|
|
634
|
+
)
|
|
635
|
+
self._emit(
|
|
636
|
+
emit,
|
|
637
|
+
Stage.SANDBOX,
|
|
638
|
+
ctx.skill_name,
|
|
639
|
+
WSEventType.STARTED,
|
|
640
|
+
{
|
|
641
|
+
"message": "container starting",
|
|
642
|
+
"memoryMb": self.config.sandbox.memory_mb,
|
|
643
|
+
"cpus": self.config.sandbox.cpus,
|
|
644
|
+
"timeoutS": request.timeout_s,
|
|
645
|
+
},
|
|
646
|
+
)
|
|
647
|
+
try:
|
|
648
|
+
result = runner.run(request)
|
|
649
|
+
except SandboxUnavailable as exc:
|
|
650
|
+
return StageResult(
|
|
651
|
+
stage=Stage.SANDBOX,
|
|
652
|
+
degraded=True,
|
|
653
|
+
error=f"Docker unavailable: {exc}",
|
|
654
|
+
findings=[
|
|
655
|
+
Finding(
|
|
656
|
+
stage=Stage.SANDBOX,
|
|
657
|
+
severity=Severity.NOTE,
|
|
658
|
+
message="Sandbox unavailable; no execution evidence was collected.",
|
|
659
|
+
type="sandbox-unavailable",
|
|
660
|
+
)
|
|
661
|
+
],
|
|
662
|
+
)
|
|
663
|
+
except (SandboxError, Exception) as exc:
|
|
664
|
+
return StageResult(
|
|
665
|
+
stage=Stage.SANDBOX,
|
|
666
|
+
degraded=True,
|
|
667
|
+
error=f"sandbox failed: {exc}",
|
|
668
|
+
findings=[
|
|
669
|
+
Finding(
|
|
670
|
+
stage=Stage.SANDBOX,
|
|
671
|
+
severity=Severity.NOTE,
|
|
672
|
+
message=f"Sandbox failed; scan is degraded: {exc}",
|
|
673
|
+
type="sandbox-unavailable",
|
|
674
|
+
)
|
|
675
|
+
],
|
|
676
|
+
)
|
|
677
|
+
ctx.sandbox_result = result
|
|
678
|
+
ctx.canaries = result.canaries
|
|
679
|
+
stage = result.to_stage_result()
|
|
680
|
+
if result.error:
|
|
681
|
+
stage.degraded = True
|
|
682
|
+
stage.error = result.error
|
|
683
|
+
for event in result.events:
|
|
684
|
+
self._emit(
|
|
685
|
+
emit,
|
|
686
|
+
Stage.SANDBOX,
|
|
687
|
+
ctx.skill_name,
|
|
688
|
+
WSEventType.PROGRESS,
|
|
689
|
+
{
|
|
690
|
+
"message": event.event_type,
|
|
691
|
+
"path": event.path_or_channel,
|
|
692
|
+
"marker": event.marker,
|
|
693
|
+
},
|
|
694
|
+
)
|
|
695
|
+
return stage
|
|
696
|
+
|
|
697
|
+
def _active_defense_stages(
|
|
698
|
+
self, ctx: _ScanContext, emit: ProgressCallback | None
|
|
699
|
+
) -> tuple[StageResult, StageResult]:
|
|
700
|
+
if ctx.sandbox_result is None:
|
|
701
|
+
ctx.active = ActiveDefense(
|
|
702
|
+
triggered=False,
|
|
703
|
+
declared_intent=ctx.package.declared_intent or None,
|
|
704
|
+
context_aware_canary=normalize_intent(ctx.package.declared_intent),
|
|
705
|
+
)
|
|
706
|
+
return (
|
|
707
|
+
StageResult(
|
|
708
|
+
stage=Stage.POISONED_WELL,
|
|
709
|
+
telemetry={"skipped": True, "reason": "sandbox not run"},
|
|
710
|
+
),
|
|
711
|
+
StageResult(
|
|
712
|
+
stage=Stage.EVASION,
|
|
713
|
+
telemetry={"skipped": True, "reason": "sandbox not run"},
|
|
714
|
+
),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
result = ctx.sandbox_result
|
|
718
|
+
canaries = result.canaries
|
|
719
|
+
tracker = ExfilTracker(
|
|
720
|
+
canaries,
|
|
721
|
+
window_s=self.config.poisoned_well.correlation_window_s,
|
|
722
|
+
)
|
|
723
|
+
for event in result.events:
|
|
724
|
+
if event.event_type == "canary_read":
|
|
725
|
+
tracker.record_canary_read(event)
|
|
726
|
+
# Shell expansion cannot be observed by shim getenv(); the environment
|
|
727
|
+
# itself is exposure. The within-window + marker rule still applies.
|
|
728
|
+
if self.config.poisoned_well.enabled:
|
|
729
|
+
tracker.record_implicit_env_reads(ts=result.started_at)
|
|
730
|
+
for observation in result.sinkhole_observations:
|
|
731
|
+
if observation.protocol == "dns":
|
|
732
|
+
tracker.observe_dns(
|
|
733
|
+
observation.query_or_url,
|
|
734
|
+
ts=observation.ts,
|
|
735
|
+
client=observation.client,
|
|
736
|
+
responder_action=observation.responder_action,
|
|
737
|
+
)
|
|
738
|
+
else:
|
|
739
|
+
method, _, url = observation.query_or_url.partition(" ")
|
|
740
|
+
if not url:
|
|
741
|
+
method, url = "POST", observation.query_or_url
|
|
742
|
+
tracker.observe_http(
|
|
743
|
+
method,
|
|
744
|
+
url,
|
|
745
|
+
body=observation.body,
|
|
746
|
+
headers=observation.headers,
|
|
747
|
+
ts=observation.ts,
|
|
748
|
+
client=observation.client,
|
|
749
|
+
responder_action=observation.responder_action,
|
|
750
|
+
)
|
|
751
|
+
correlations = [item for item in tracker.results if item.matched]
|
|
752
|
+
ctx.correlation = correlations[-1] if correlations else None
|
|
753
|
+
# Do not let an unverified, writable shim log alone produce an
|
|
754
|
+
# evasion override. The current shim has no signed channel; probes are
|
|
755
|
+
# still surfaced as findings, but only a sandbox result the engine
|
|
756
|
+
# actually produced is eligible for the hard override.
|
|
757
|
+
ctx.evasion = detect_probes(result.events)
|
|
758
|
+
secondary_served = any(
|
|
759
|
+
event.event_type == "secondary_served" for event in result.events
|
|
760
|
+
) or any(
|
|
761
|
+
observation.responder_action == "200+secondary"
|
|
762
|
+
for observation in result.sinkhole_observations
|
|
763
|
+
)
|
|
764
|
+
profile = build_attacker_profile(
|
|
765
|
+
canaries,
|
|
766
|
+
correlation=ctx.correlation,
|
|
767
|
+
evasion=ctx.evasion if result.events else EvasionResult(detected=False),
|
|
768
|
+
secondary_served=secondary_served,
|
|
769
|
+
ioc_export="stix-bundle.json" if correlations else None,
|
|
770
|
+
)
|
|
771
|
+
ctx.active = profile.to_active_defense()
|
|
772
|
+
ctx.pw_view = self._poisoned_well_view(ctx, tracker, profile)
|
|
773
|
+
ctx.trace = result.to_trace()
|
|
774
|
+
|
|
775
|
+
pw_findings: list[Finding] = []
|
|
776
|
+
if ctx.correlation is not None:
|
|
777
|
+
profile_finding = profile.to_finding()
|
|
778
|
+
if profile_finding is not None:
|
|
779
|
+
pw_findings.append(profile_finding)
|
|
780
|
+
self._emit(
|
|
781
|
+
emit,
|
|
782
|
+
Stage.POISONED_WELL,
|
|
783
|
+
ctx.skill_name,
|
|
784
|
+
WSEventType.OVERRIDE,
|
|
785
|
+
{
|
|
786
|
+
"kind": "poisoned_well",
|
|
787
|
+
"message": "CANARY MARKER CONFIRMED",
|
|
788
|
+
"marker": ctx.correlation.marker,
|
|
789
|
+
"c2": list(ctx.correlation.c2_indicators),
|
|
790
|
+
"channel": ctx.correlation.channel.value,
|
|
791
|
+
},
|
|
792
|
+
)
|
|
793
|
+
pw_stage = StageResult(
|
|
794
|
+
stage=Stage.POISONED_WELL,
|
|
795
|
+
findings=pw_findings,
|
|
796
|
+
telemetry={
|
|
797
|
+
"confirmed": ctx.correlation is not None,
|
|
798
|
+
"reads": len(tracker.reads),
|
|
799
|
+
"observations": len(tracker.observations),
|
|
800
|
+
"summary": tracker.summary(),
|
|
801
|
+
},
|
|
802
|
+
override=ctx.correlation.to_override() if ctx.correlation else None,
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
evasion_findings: list[Finding] = []
|
|
806
|
+
if ctx.evasion is not None and ctx.evasion.detected:
|
|
807
|
+
evasion_findings.append(
|
|
808
|
+
Finding(
|
|
809
|
+
stage=Stage.EVASION,
|
|
810
|
+
severity=Severity.CRITICAL,
|
|
811
|
+
message="Sandbox evasion probe(s) detected: " + ", ".join(ctx.evasion.targets),
|
|
812
|
+
type="evasion-probe",
|
|
813
|
+
evidence=FindingEvidence(
|
|
814
|
+
path=ctx.evasion.targets[0] if ctx.evasion.targets else None,
|
|
815
|
+
**{"probes": list(ctx.evasion.targets)},
|
|
816
|
+
),
|
|
817
|
+
)
|
|
818
|
+
)
|
|
819
|
+
self._emit(
|
|
820
|
+
emit,
|
|
821
|
+
Stage.EVASION,
|
|
822
|
+
ctx.skill_name,
|
|
823
|
+
WSEventType.OVERRIDE,
|
|
824
|
+
{"kind": "evasion", "probes": list(ctx.evasion.targets)},
|
|
825
|
+
)
|
|
826
|
+
evasion_stage = StageResult(
|
|
827
|
+
stage=Stage.EVASION,
|
|
828
|
+
findings=evasion_findings,
|
|
829
|
+
telemetry=ctx.evasion.summary() if ctx.evasion else {},
|
|
830
|
+
override=ctx.evasion.to_override() if ctx.evasion and ctx.evasion.detected else None,
|
|
831
|
+
)
|
|
832
|
+
return pw_stage, evasion_stage
|
|
833
|
+
|
|
834
|
+
# -- persistence / details --------------------------------------------
|
|
835
|
+
def _finalize(
|
|
836
|
+
self,
|
|
837
|
+
ctx: _ScanContext,
|
|
838
|
+
verdict: Verdict,
|
|
839
|
+
detail: SkillDetail,
|
|
840
|
+
fused: FusionResult,
|
|
841
|
+
*,
|
|
842
|
+
cache: bool,
|
|
843
|
+
) -> None:
|
|
844
|
+
backend, model_version = self._model_identity()
|
|
845
|
+
self.store.set_cache_identity(
|
|
846
|
+
engine_version=engine_version(self.config.scoring.weights),
|
|
847
|
+
model_backend=backend,
|
|
848
|
+
model_version=model_version,
|
|
849
|
+
)
|
|
850
|
+
sarif = ""
|
|
851
|
+
try:
|
|
852
|
+
sarif = render_report(
|
|
853
|
+
verdict,
|
|
854
|
+
_sarif_format(),
|
|
855
|
+
stage_results=ctx.stage_results,
|
|
856
|
+
).content.decode("utf-8")
|
|
857
|
+
except Exception:
|
|
858
|
+
# SARIF is a report view; its failure must not fabricate a clean
|
|
859
|
+
# verdict or block the evidence trail.
|
|
860
|
+
sarif = ""
|
|
861
|
+
try:
|
|
862
|
+
self.store.finish_scan(
|
|
863
|
+
verdict,
|
|
864
|
+
detail,
|
|
865
|
+
stage_results=ctx.stage_results,
|
|
866
|
+
sarif_json=sarif,
|
|
867
|
+
cache=cache,
|
|
868
|
+
)
|
|
869
|
+
except StoreError as exc:
|
|
870
|
+
raise ScanError(
|
|
871
|
+
f"could not persist scan evidence: {exc}",
|
|
872
|
+
code="storage_error",
|
|
873
|
+
stage=Stage.VERDICT,
|
|
874
|
+
degraded=True,
|
|
875
|
+
) from exc
|
|
876
|
+
del fused
|
|
877
|
+
|
|
878
|
+
def _cached_outcome(
|
|
879
|
+
self,
|
|
880
|
+
cached: Any,
|
|
881
|
+
*,
|
|
882
|
+
sid: UUID,
|
|
883
|
+
started_at: datetime,
|
|
884
|
+
started_monotonic: float,
|
|
885
|
+
emit: ProgressCallback | None,
|
|
886
|
+
) -> ScanOutcome:
|
|
887
|
+
duration_ms = max(0, int((time.monotonic() - started_monotonic) * 1000))
|
|
888
|
+
old_verdict: Verdict = cached.verdict
|
|
889
|
+
verdict = old_verdict.model_copy(
|
|
890
|
+
update={
|
|
891
|
+
"scan_id": sid,
|
|
892
|
+
"cached": True,
|
|
893
|
+
"scan_duration_ms": duration_ms,
|
|
894
|
+
}
|
|
895
|
+
)
|
|
896
|
+
detail = cached.detail.model_copy(update={"verdict": verdict})
|
|
897
|
+
detail = detail.model_copy(
|
|
898
|
+
update={
|
|
899
|
+
"report_links": detail.report_links.model_copy(
|
|
900
|
+
update={
|
|
901
|
+
"sarif": f"/skills/{verdict.skill_id}/export?format=sarif",
|
|
902
|
+
"markdown": f"/skills/{verdict.skill_id}/export?format=markdown",
|
|
903
|
+
"stix": (
|
|
904
|
+
f"/skills/{verdict.skill_id}/export?format=stix"
|
|
905
|
+
if verdict.active_defense and verdict.active_defense.triggered
|
|
906
|
+
else None
|
|
907
|
+
),
|
|
908
|
+
}
|
|
909
|
+
)
|
|
910
|
+
}
|
|
911
|
+
)
|
|
912
|
+
self._emit(
|
|
913
|
+
emit,
|
|
914
|
+
Stage.CACHE,
|
|
915
|
+
verdict.skill,
|
|
916
|
+
WSEventType.CACHED,
|
|
917
|
+
{
|
|
918
|
+
"message": "cached verdict",
|
|
919
|
+
"durationMs": duration_ms,
|
|
920
|
+
"hash": cached.content_hash[:12],
|
|
921
|
+
},
|
|
922
|
+
)
|
|
923
|
+
backend, model_version = self._model_identity()
|
|
924
|
+
self.store.set_cache_identity(
|
|
925
|
+
engine_version=engine_version(self.config.scoring.weights),
|
|
926
|
+
model_backend=backend,
|
|
927
|
+
model_version=model_version,
|
|
928
|
+
)
|
|
929
|
+
self.store.finish_scan(
|
|
930
|
+
verdict,
|
|
931
|
+
detail,
|
|
932
|
+
sarif_json=cached.sarif_json,
|
|
933
|
+
cache=False,
|
|
934
|
+
)
|
|
935
|
+
self._emit(
|
|
936
|
+
emit,
|
|
937
|
+
Stage.VERDICT,
|
|
938
|
+
verdict.skill,
|
|
939
|
+
WSEventType.COMPLETED,
|
|
940
|
+
{"verdict": verdict.model_dump(by_alias=True, mode="json"), "durationMs": duration_ms},
|
|
941
|
+
)
|
|
942
|
+
return ScanOutcome(
|
|
943
|
+
scan_id=sid,
|
|
944
|
+
skill_id=verdict.skill_id,
|
|
945
|
+
skill_name=verdict.skill,
|
|
946
|
+
verdict=verdict,
|
|
947
|
+
detail=detail,
|
|
948
|
+
stage_results=[],
|
|
949
|
+
cached=True,
|
|
950
|
+
started_at=started_at,
|
|
951
|
+
duration_ms=duration_ms,
|
|
952
|
+
status=ScanStatus.CACHED_HIT,
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
def _detail(self, ctx: _ScanContext, verdict: Verdict, duration_ms: int) -> SkillDetail:
|
|
956
|
+
del duration_ms
|
|
957
|
+
active = verdict.active_defense
|
|
958
|
+
links = {
|
|
959
|
+
"sarif": f"/skills/{ctx.skill_id}/export?format=sarif",
|
|
960
|
+
"markdown": f"/skills/{ctx.skill_id}/export?format=markdown",
|
|
961
|
+
"stix": (
|
|
962
|
+
f"/skills/{ctx.skill_id}/export?format=stix"
|
|
963
|
+
if active is not None and active.triggered
|
|
964
|
+
else None
|
|
965
|
+
),
|
|
966
|
+
}
|
|
967
|
+
return SkillDetail(
|
|
968
|
+
verdict=verdict,
|
|
969
|
+
static_findings=self._static_findings(verdict),
|
|
970
|
+
vision_evidence=ctx.vision_evidence,
|
|
971
|
+
sandbox_trace=ctx.trace,
|
|
972
|
+
poisoned_well=ctx.pw_view.model_copy(update={"active_defense": active}),
|
|
973
|
+
report_links=_report_links(links),
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
def _static_findings(self, verdict: Verdict) -> list[StaticFinding]:
|
|
977
|
+
results: list[StaticFinding] = []
|
|
978
|
+
for finding in verdict.findings:
|
|
979
|
+
if finding.stage is Stage.VISION:
|
|
980
|
+
continue
|
|
981
|
+
if finding.severity is Severity.NOTE and finding.stage not in {
|
|
982
|
+
Stage.SPEC,
|
|
983
|
+
Stage.SEMANTIC,
|
|
984
|
+
}:
|
|
985
|
+
continue
|
|
986
|
+
results.append(
|
|
987
|
+
StaticFinding(
|
|
988
|
+
file=finding.evidence.file or "SKILL.md",
|
|
989
|
+
line=max(1, finding.evidence.line or 1),
|
|
990
|
+
code=finding.evidence.code,
|
|
991
|
+
stage=finding.stage,
|
|
992
|
+
severity=finding.severity,
|
|
993
|
+
message=finding.message,
|
|
994
|
+
anchor=finding.stage is Stage.DETERMINISTIC_ANCHOR,
|
|
995
|
+
)
|
|
996
|
+
)
|
|
997
|
+
return results
|
|
998
|
+
|
|
999
|
+
def _vision_evidence(self, stage: StageResult) -> list[VisionEvidence]:
|
|
1000
|
+
items: list[VisionEvidence] = []
|
|
1001
|
+
raw = (stage.telemetry or {}).get("evidence")
|
|
1002
|
+
if isinstance(raw, list):
|
|
1003
|
+
for item in raw:
|
|
1004
|
+
try:
|
|
1005
|
+
items.append(VisionEvidence.model_validate(item))
|
|
1006
|
+
except Exception:
|
|
1007
|
+
continue
|
|
1008
|
+
return items
|
|
1009
|
+
|
|
1010
|
+
def _poisoned_well_view(
|
|
1011
|
+
self,
|
|
1012
|
+
ctx: _ScanContext,
|
|
1013
|
+
tracker: ExfilTracker,
|
|
1014
|
+
profile: Any,
|
|
1015
|
+
) -> PoisonedWellView:
|
|
1016
|
+
timeline: list[TimelineRow] = []
|
|
1017
|
+
if ctx.sandbox_result is not None:
|
|
1018
|
+
for event in ctx.sandbox_result.events:
|
|
1019
|
+
kind = {
|
|
1020
|
+
"canary_read": "canary_read",
|
|
1021
|
+
"probe_detected": "probe",
|
|
1022
|
+
"fake_served": "fake_served",
|
|
1023
|
+
"marker_matched": "confirmed",
|
|
1024
|
+
}.get(event.event_type, event.event_type)
|
|
1025
|
+
timeline.append(
|
|
1026
|
+
TimelineRow(
|
|
1027
|
+
ts=event.ts,
|
|
1028
|
+
label=(
|
|
1029
|
+
f"{event.event_type}: {event.path_or_channel or event.process or ''}".strip()
|
|
1030
|
+
),
|
|
1031
|
+
kind=kind,
|
|
1032
|
+
marker=event.marker,
|
|
1033
|
+
)
|
|
1034
|
+
)
|
|
1035
|
+
for observation in ctx.sandbox_result.sinkhole_observations:
|
|
1036
|
+
timeline.append(
|
|
1037
|
+
TimelineRow(
|
|
1038
|
+
ts=observation.ts,
|
|
1039
|
+
label=observation.query_or_url,
|
|
1040
|
+
kind="dns" if observation.protocol == "dns" else "http",
|
|
1041
|
+
)
|
|
1042
|
+
)
|
|
1043
|
+
timeline.sort(key=lambda row: row.ts)
|
|
1044
|
+
graph = IOCGraph()
|
|
1045
|
+
if ctx.correlation is not None:
|
|
1046
|
+
correlation = ctx.correlation
|
|
1047
|
+
c2 = correlation.c2_indicators[0] if correlation.c2_indicators else "unknown"
|
|
1048
|
+
graph = IOCGraph(
|
|
1049
|
+
nodes=[
|
|
1050
|
+
IOCGraphNode(id="script", label=ctx.skill_name, kind="script"),
|
|
1051
|
+
IOCGraphNode(
|
|
1052
|
+
id="canary",
|
|
1053
|
+
label=", ".join(correlation.targeted_assets) or "canary",
|
|
1054
|
+
kind="canary_file",
|
|
1055
|
+
),
|
|
1056
|
+
IOCGraphNode(
|
|
1057
|
+
id="marker",
|
|
1058
|
+
label=correlation.marker or "marker",
|
|
1059
|
+
kind="marker",
|
|
1060
|
+
),
|
|
1061
|
+
IOCGraphNode(
|
|
1062
|
+
id="channel",
|
|
1063
|
+
label=correlation.channel.value,
|
|
1064
|
+
kind="channel",
|
|
1065
|
+
),
|
|
1066
|
+
IOCGraphNode(id="c2", label=c2, kind="c2"),
|
|
1067
|
+
],
|
|
1068
|
+
edges=[
|
|
1069
|
+
IOCGraphEdge(source="script", target="canary", label="reads"),
|
|
1070
|
+
IOCGraphEdge(source="canary", target="marker", label="fake"),
|
|
1071
|
+
IOCGraphEdge(source="marker", target="channel", label="exfil"),
|
|
1072
|
+
IOCGraphEdge(source="channel", target="c2", label="resolves"),
|
|
1073
|
+
],
|
|
1074
|
+
)
|
|
1075
|
+
return PoisonedWellView(
|
|
1076
|
+
active_defense=profile.to_active_defense() if profile else None,
|
|
1077
|
+
timeline=timeline,
|
|
1078
|
+
graph=graph,
|
|
1079
|
+
confirmed=ctx.correlation is not None,
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
def _profile_finding(self, ctx: _ScanContext) -> Finding | None:
|
|
1083
|
+
if ctx.correlation is not None:
|
|
1084
|
+
return Finding(
|
|
1085
|
+
stage=Stage.POISONED_WELL,
|
|
1086
|
+
severity=Severity.CRITICAL,
|
|
1087
|
+
message=(
|
|
1088
|
+
f"Canary marker exfiltrated via {ctx.correlation.channel.value} "
|
|
1089
|
+
f"to {', '.join(ctx.correlation.c2_indicators) or 'unknown'}"
|
|
1090
|
+
),
|
|
1091
|
+
type="exfil-confirmed",
|
|
1092
|
+
evidence=FindingEvidence(
|
|
1093
|
+
marker=ctx.correlation.marker,
|
|
1094
|
+
channel=ctx.correlation.channel.value,
|
|
1095
|
+
c2=ctx.correlation.c2_indicators[0] if ctx.correlation.c2_indicators else None,
|
|
1096
|
+
path=ctx.correlation.targeted_assets[0]
|
|
1097
|
+
if ctx.correlation.targeted_assets
|
|
1098
|
+
else None,
|
|
1099
|
+
code=ctx.correlation.observation.query_or_url[:500],
|
|
1100
|
+
),
|
|
1101
|
+
)
|
|
1102
|
+
if ctx.evasion is not None and ctx.evasion.detected:
|
|
1103
|
+
return Finding(
|
|
1104
|
+
stage=Stage.EVASION,
|
|
1105
|
+
severity=Severity.CRITICAL,
|
|
1106
|
+
message="Sandbox evasion probe detected",
|
|
1107
|
+
type="evasion-probe",
|
|
1108
|
+
evidence=FindingEvidence(
|
|
1109
|
+
path=ctx.evasion.targets[0] if ctx.evasion.targets else None,
|
|
1110
|
+
**{"probes": list(ctx.evasion.targets)},
|
|
1111
|
+
),
|
|
1112
|
+
)
|
|
1113
|
+
return None
|
|
1114
|
+
|
|
1115
|
+
# -- helpers -----------------------------------------------------------
|
|
1116
|
+
def _record(
|
|
1117
|
+
self,
|
|
1118
|
+
ctx: _ScanContext,
|
|
1119
|
+
result: StageResult,
|
|
1120
|
+
emit: ProgressCallback | None,
|
|
1121
|
+
) -> None:
|
|
1122
|
+
ctx.stage_results.append(result)
|
|
1123
|
+
self._emit(
|
|
1124
|
+
emit,
|
|
1125
|
+
result.stage,
|
|
1126
|
+
ctx.skill_name,
|
|
1127
|
+
WSEventType.STARTED,
|
|
1128
|
+
{"message": f"{result.stage.value} started"},
|
|
1129
|
+
)
|
|
1130
|
+
for finding in result.findings:
|
|
1131
|
+
if finding.severity is Severity.NOTE and finding.finding_type in {
|
|
1132
|
+
"semantic-clean",
|
|
1133
|
+
"spec-valid",
|
|
1134
|
+
}:
|
|
1135
|
+
continue
|
|
1136
|
+
self._emit(
|
|
1137
|
+
emit,
|
|
1138
|
+
result.stage,
|
|
1139
|
+
ctx.skill_name,
|
|
1140
|
+
WSEventType.FINDING,
|
|
1141
|
+
{"finding": finding.model_dump(by_alias=True, mode="json")},
|
|
1142
|
+
)
|
|
1143
|
+
if result.degraded:
|
|
1144
|
+
self._emit(
|
|
1145
|
+
emit,
|
|
1146
|
+
result.stage,
|
|
1147
|
+
ctx.skill_name,
|
|
1148
|
+
WSEventType.PROGRESS,
|
|
1149
|
+
{"message": f"degraded: {result.error or 'stage unavailable'}"},
|
|
1150
|
+
)
|
|
1151
|
+
self._emit(
|
|
1152
|
+
emit,
|
|
1153
|
+
result.stage,
|
|
1154
|
+
ctx.skill_name,
|
|
1155
|
+
WSEventType.PROGRESS,
|
|
1156
|
+
{"message": f"{result.stage.value} complete"},
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
def _cache_eligible(self, opts: ScanOptions) -> bool:
|
|
1160
|
+
"""Whether this analysis mode can safely reuse a cached verdict.
|
|
1161
|
+
|
|
1162
|
+
A static-only scan lacks sandbox evidence and must never poison the
|
|
1163
|
+
cache for a later full scan. A non-default timeout is also treated as
|
|
1164
|
+
a different analysis mode because execution/evidence can differ.
|
|
1165
|
+
"""
|
|
1166
|
+
|
|
1167
|
+
if not opts.sandbox or not self.config.sandbox.enabled:
|
|
1168
|
+
return False
|
|
1169
|
+
if opts.timeout_s != self.config.sandbox.timeout_s:
|
|
1170
|
+
return False
|
|
1171
|
+
return opts.poisoned_well == self.config.poisoned_well.enabled
|
|
1172
|
+
|
|
1173
|
+
def _check_cancel(self, cancel: threading.Event | None) -> None:
|
|
1174
|
+
if cancel is not None and cancel.is_set():
|
|
1175
|
+
raise ScanCancelled()
|
|
1176
|
+
|
|
1177
|
+
def _semantic_stage(
|
|
1178
|
+
self,
|
|
1179
|
+
package: SkillPackage,
|
|
1180
|
+
anchors: StageResult,
|
|
1181
|
+
model: ModelClient | None,
|
|
1182
|
+
) -> StageResult:
|
|
1183
|
+
"""Route the semantic stage: typed decision backend, then legacy model."""
|
|
1184
|
+
|
|
1185
|
+
try:
|
|
1186
|
+
selection = self._decision_selection(model)
|
|
1187
|
+
except DecisionUnavailable as exc:
|
|
1188
|
+
return degraded_semantic_stage(anchors, str(exc))
|
|
1189
|
+
if selection.client is not None:
|
|
1190
|
+
try:
|
|
1191
|
+
calibration = load_calibration(self.config.decision.calibration_path)
|
|
1192
|
+
except CalibrationError as exc:
|
|
1193
|
+
# An unreadable calibration must not silently fall back to the provisional threshold.
|
|
1194
|
+
return degraded_semantic_stage(anchors, f"calibration unavailable: {exc}")
|
|
1195
|
+
threshold = calibration.capability_threshold if calibration else CAPABILITY_THRESHOLD
|
|
1196
|
+
temperature = calibration.temperature if calibration else 1.0
|
|
1197
|
+
label = (
|
|
1198
|
+
calibration.label
|
|
1199
|
+
if calibration
|
|
1200
|
+
else (self.config.decision.calibration_version.strip() or "uncalibrated")
|
|
1201
|
+
)
|
|
1202
|
+
stage = analyze_semantics_laya(
|
|
1203
|
+
package,
|
|
1204
|
+
anchors,
|
|
1205
|
+
selection.client,
|
|
1206
|
+
threshold=threshold,
|
|
1207
|
+
temperature=temperature,
|
|
1208
|
+
)
|
|
1209
|
+
return stage.model_copy(
|
|
1210
|
+
update={
|
|
1211
|
+
"telemetry": {
|
|
1212
|
+
**(stage.telemetry or {}),
|
|
1213
|
+
"calibration_version": label,
|
|
1214
|
+
"capability_threshold": threshold,
|
|
1215
|
+
"temperature": temperature,
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
)
|
|
1219
|
+
if selection.backend == "legacy":
|
|
1220
|
+
return self._legacy_semantic(package, anchors, model)
|
|
1221
|
+
if selection.backend == "none":
|
|
1222
|
+
off_values = {"none", "off", "disabled"}
|
|
1223
|
+
explicitly_off = self.config.decision.backend.strip().lower() in off_values
|
|
1224
|
+
if explicitly_off and not selection.required:
|
|
1225
|
+
return deterministic_only_stage(anchors)
|
|
1226
|
+
return degraded_semantic_stage(
|
|
1227
|
+
anchors, selection.reason or "semantic decision backend unavailable"
|
|
1228
|
+
)
|
|
1229
|
+
return self._legacy_semantic(package, anchors, model)
|
|
1230
|
+
|
|
1231
|
+
def _legacy_semantic(
|
|
1232
|
+
self,
|
|
1233
|
+
package: SkillPackage,
|
|
1234
|
+
anchors: StageResult,
|
|
1235
|
+
model: ModelClient | None,
|
|
1236
|
+
) -> StageResult:
|
|
1237
|
+
"""Legacy analysis; with no model, the reason the model could not be selected is kept."""
|
|
1238
|
+
|
|
1239
|
+
if model is None:
|
|
1240
|
+
reason = self._model_error or "no model backend is configured"
|
|
1241
|
+
return degraded_semantic_stage(anchors, f"model unavailable: {reason}")
|
|
1242
|
+
return analyze_semantics(package, anchors, model)
|
|
1243
|
+
|
|
1244
|
+
def _decision_selection(self, model: ModelClient | None) -> DecisionSelection:
|
|
1245
|
+
"""Select the typed decision backend once per engine, not once per scan.
|
|
1246
|
+
|
|
1247
|
+
Selection is memoised because a Laya client loads checkpoint weights on
|
|
1248
|
+
first use. A failed selection raises and is retried on the next scan.
|
|
1249
|
+
"""
|
|
1250
|
+
|
|
1251
|
+
if self._decision is None:
|
|
1252
|
+
self._decision = select_decision_model(
|
|
1253
|
+
self.config,
|
|
1254
|
+
laya_client=self._decision_client,
|
|
1255
|
+
legacy_available=model is not None,
|
|
1256
|
+
)
|
|
1257
|
+
return self._decision
|
|
1258
|
+
|
|
1259
|
+
def _semantic_identity(self, model: ModelClient | None) -> str:
|
|
1260
|
+
"""Cache-identity fragment for the typed decision layer; empty when unused.
|
|
1261
|
+
|
|
1262
|
+
Empty for the legacy and deterministic-only paths so their cache keys do
|
|
1263
|
+
not change. Laya's identity includes the calibration version, so
|
|
1264
|
+
changing the checkpoint or calibration invalidates cached verdicts.
|
|
1265
|
+
"""
|
|
1266
|
+
|
|
1267
|
+
try:
|
|
1268
|
+
selection = self._decision_selection(model)
|
|
1269
|
+
except DecisionUnavailable:
|
|
1270
|
+
return "laya:unavailable"
|
|
1271
|
+
if selection.client is None:
|
|
1272
|
+
return ""
|
|
1273
|
+
try:
|
|
1274
|
+
calibration = load_calibration(self.config.decision.calibration_path)
|
|
1275
|
+
except CalibrationError:
|
|
1276
|
+
return "laya:calibration-invalid"
|
|
1277
|
+
version = str(getattr(selection.client, "model_version", "unknown"))
|
|
1278
|
+
fitted = (
|
|
1279
|
+
f"{calibration.version}:{calibration.capability_threshold}" if calibration else "none"
|
|
1280
|
+
)
|
|
1281
|
+
return f"laya:{version}:{self.config.decision.cache_identity}:{fitted}"
|
|
1282
|
+
|
|
1283
|
+
def _get_model(self) -> ModelClient | None:
|
|
1284
|
+
if self._model_selected:
|
|
1285
|
+
return self._model
|
|
1286
|
+
self._model_selected = True
|
|
1287
|
+
if self.config.model.backend == "demo":
|
|
1288
|
+
self._model = MockGemma()
|
|
1289
|
+
return self._model
|
|
1290
|
+
try:
|
|
1291
|
+
self._model = select_model(self.config)
|
|
1292
|
+
except (ModelError, ValueError, OSError) as exc:
|
|
1293
|
+
self._model = None
|
|
1294
|
+
self._model_error = str(exc)
|
|
1295
|
+
except Exception as exc: # network clients can raise provider exceptions
|
|
1296
|
+
self._model = None
|
|
1297
|
+
self._model_error = f"model selection failed: {exc}"
|
|
1298
|
+
return self._model
|
|
1299
|
+
|
|
1300
|
+
def model_identity(self) -> tuple[str, str]:
|
|
1301
|
+
"""Return ``(backend, model_version)`` for the selected model.
|
|
1302
|
+
|
|
1303
|
+
Public because health/meta endpoints must report the identity the
|
|
1304
|
+
engine will actually use (including an injected deterministic model),
|
|
1305
|
+
rather than re-running backend selection and guessing.
|
|
1306
|
+
"""
|
|
1307
|
+
|
|
1308
|
+
return self._model_identity()
|
|
1309
|
+
|
|
1310
|
+
def _model_identity(self) -> tuple[str, str]:
|
|
1311
|
+
model = self._get_model()
|
|
1312
|
+
if model is None:
|
|
1313
|
+
backend, version = "unavailable", "unavailable"
|
|
1314
|
+
else:
|
|
1315
|
+
backend = str(getattr(model, "backend", "unknown"))
|
|
1316
|
+
version = str(getattr(model, "model_version", "unknown"))
|
|
1317
|
+
# A rules edit changes findings, so it must never reuse a cached verdict.
|
|
1318
|
+
version = f"{version}|rules={RULES_FINGERPRINT}"
|
|
1319
|
+
semantic = self._semantic_identity(model)
|
|
1320
|
+
if semantic:
|
|
1321
|
+
version = f"{version}|{semantic}"
|
|
1322
|
+
return backend, version
|
|
1323
|
+
|
|
1324
|
+
def _get_sandbox_runner(self) -> SandboxRunner:
|
|
1325
|
+
if self._sandbox_runner is not None:
|
|
1326
|
+
return self._sandbox_runner
|
|
1327
|
+
if self._sandbox_runner_factory is not None:
|
|
1328
|
+
self._sandbox_runner = self._sandbox_runner_factory()
|
|
1329
|
+
return self._sandbox_runner
|
|
1330
|
+
from skill_sentry_cli.core.sandbox.runner import SandboxConfig
|
|
1331
|
+
|
|
1332
|
+
config = SandboxConfig(
|
|
1333
|
+
image=self.config.sandbox.base_image,
|
|
1334
|
+
memory_mb=self.config.sandbox.memory_mb,
|
|
1335
|
+
cpus=self.config.sandbox.cpus,
|
|
1336
|
+
timeout_s=float(self.config.sandbox.timeout_s),
|
|
1337
|
+
# ``SandboxSettings.masquerade`` is a boolean feature flag; the
|
|
1338
|
+
# runner expects a MasqueradeConfig, not the raw flag. Passing the
|
|
1339
|
+
# bool here made dataclasses.replace() fail on every full scan.
|
|
1340
|
+
masquerade=MasqueradeConfig(require_shim=self.config.sandbox.masquerade),
|
|
1341
|
+
verify_masquerade=self.config.sandbox.masquerade,
|
|
1342
|
+
)
|
|
1343
|
+
self._sandbox_runner = SandboxRunner(
|
|
1344
|
+
config=config,
|
|
1345
|
+
sidecar_factory=lambda client: SinkholeSidecar(
|
|
1346
|
+
client,
|
|
1347
|
+
SinkholeConfig(
|
|
1348
|
+
image="python:3.11-slim",
|
|
1349
|
+
ip=config.sidecar_ip,
|
|
1350
|
+
subnet=config.sidecar_subnet,
|
|
1351
|
+
),
|
|
1352
|
+
),
|
|
1353
|
+
)
|
|
1354
|
+
return self._sandbox_runner
|
|
1355
|
+
|
|
1356
|
+
def _emit(
|
|
1357
|
+
self,
|
|
1358
|
+
callback: ProgressCallback | None,
|
|
1359
|
+
stage: Stage,
|
|
1360
|
+
skill: str,
|
|
1361
|
+
event: WSEventType,
|
|
1362
|
+
detail: dict[str, Any] | None = None,
|
|
1363
|
+
) -> None:
|
|
1364
|
+
if callback is None:
|
|
1365
|
+
return
|
|
1366
|
+
# A broken UI subscriber must not break evidence collection.
|
|
1367
|
+
with contextlib.suppress(Exception):
|
|
1368
|
+
callback(
|
|
1369
|
+
WSMessage(
|
|
1370
|
+
stage=stage,
|
|
1371
|
+
skill=skill,
|
|
1372
|
+
event=event,
|
|
1373
|
+
ts=utc_now(),
|
|
1374
|
+
detail=detail,
|
|
1375
|
+
)
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
_SCRIPT_SUFFIXES = frozenset(
|
|
1380
|
+
{".py", ".sh", ".bash", ".zsh", ".js", ".mjs", ".ts", ".ps1", ".rb", ".pl", ".bat", ".cmd"}
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _has_script(package: SkillPackage) -> bool:
|
|
1385
|
+
"""True when the package contains anything the sandbox could execute."""
|
|
1386
|
+
|
|
1387
|
+
return any(
|
|
1388
|
+
Path(text_file.relative).suffix.lower() in _SCRIPT_SUFFIXES
|
|
1389
|
+
for text_file in package.text_files
|
|
1390
|
+
if text_file.relative != "SKILL.md"
|
|
1391
|
+
)
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def _sarif_format():
|
|
1395
|
+
from skill_sentry_cli.core.contracts import ExportFormat
|
|
1396
|
+
|
|
1397
|
+
return ExportFormat.SARIF
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _report_links(values: dict[str, str | None]):
|
|
1401
|
+
from skill_sentry_cli.core.contracts import ReportLinks
|
|
1402
|
+
|
|
1403
|
+
return ReportLinks(**values)
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
__all__ = [
|
|
1407
|
+
"SCAN_NAMESPACE",
|
|
1408
|
+
"ScanCancelled",
|
|
1409
|
+
"ScanEngine",
|
|
1410
|
+
"ScanError",
|
|
1411
|
+
"ScanOutcome",
|
|
1412
|
+
]
|