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,40 @@
|
|
|
1
|
+
"""Mock server: ``python -m skill_sentry_cli.server.demo_mode --port 8000``.
|
|
2
|
+
|
|
3
|
+
Serves every endpoint from docs/05-api-reference.md §5.2 with canned data from
|
|
4
|
+
the 7-skill store. No Docker, model, or database required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
|
|
11
|
+
import uvicorn
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
|
|
14
|
+
from skill_sentry_cli.server.app import create_app
|
|
15
|
+
from skill_sentry_cli.server.demo_backend import DemoBackend
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_demo_app(*, speed: float | None = None) -> FastAPI:
|
|
19
|
+
return create_app(DemoBackend(speed=speed))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
app = create_demo_app()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
parser = argparse.ArgumentParser(description="SkillSentry demo server")
|
|
27
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
28
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--speed",
|
|
31
|
+
type=float,
|
|
32
|
+
default=1.0,
|
|
33
|
+
help="WS replay speed multiplier (0 disables delays; used by tests)",
|
|
34
|
+
)
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
uvicorn.run(create_demo_app(speed=args.speed), host=args.host, port=args.port, log_level="info")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""API error model (docs/05-api-reference.md §5.8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from skill_sentry_cli.core.contracts import ErrorDetail, ErrorResponse, Stage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiError(Exception):
|
|
11
|
+
"""A structured HTTP error rendered as ``{"error": {...}}``."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
status_code: int,
|
|
16
|
+
code: str,
|
|
17
|
+
message: str,
|
|
18
|
+
*,
|
|
19
|
+
stage: Stage | None = None,
|
|
20
|
+
degraded: bool = False,
|
|
21
|
+
scan_id: UUID | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.status_code = status_code
|
|
25
|
+
self.code = code
|
|
26
|
+
self.message = message
|
|
27
|
+
self.stage = stage
|
|
28
|
+
self.degraded = degraded
|
|
29
|
+
self.scan_id = scan_id
|
|
30
|
+
|
|
31
|
+
def to_response(self) -> ErrorResponse:
|
|
32
|
+
return ErrorResponse(
|
|
33
|
+
error=ErrorDetail(
|
|
34
|
+
code=self.code,
|
|
35
|
+
message=self.message,
|
|
36
|
+
stage=self.stage,
|
|
37
|
+
degraded=self.degraded,
|
|
38
|
+
scan_id=self.scan_id,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def not_found(code: str, message: str) -> ApiError:
|
|
44
|
+
return ApiError(404, code, message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def bad_request(code: str, message: str) -> ApiError:
|
|
48
|
+
return ApiError(400, code, message)
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""Live REST/WebSocket backend backed by :class:`ScanEngine`.
|
|
2
|
+
|
|
3
|
+
The backend owns only scheduling and presentation. All analysis is performed
|
|
4
|
+
by the synchronous engine on a worker thread; events are replayed to every
|
|
5
|
+
WebSocket subscriber from a bounded in-memory log. Completed results are read
|
|
6
|
+
from SQLite so history survives a server restart.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
from uuid import UUID, uuid4
|
|
20
|
+
|
|
21
|
+
from skill_sentry_cli.core.config import SkillsEntryConfig, load_config
|
|
22
|
+
from skill_sentry_cli.core.contracts import (
|
|
23
|
+
CacheStatsResponse,
|
|
24
|
+
DeleteCacheResponse,
|
|
25
|
+
ExportFormat,
|
|
26
|
+
HealthResponse,
|
|
27
|
+
MetaResponse,
|
|
28
|
+
ScanListResponse,
|
|
29
|
+
ScanRequest,
|
|
30
|
+
ScanStartResponse,
|
|
31
|
+
ScanStatus,
|
|
32
|
+
ScanStatusResponse,
|
|
33
|
+
ScanSummary,
|
|
34
|
+
SkillDetail,
|
|
35
|
+
Stage,
|
|
36
|
+
Verdict,
|
|
37
|
+
VerdictsResponse,
|
|
38
|
+
WSEventType,
|
|
39
|
+
WSMessage,
|
|
40
|
+
utc_now,
|
|
41
|
+
)
|
|
42
|
+
from skill_sentry_cli.core.engine import ScanEngine, ScanError, ScanOutcome
|
|
43
|
+
from skill_sentry_cli.core.reports import RenderedReport, render_report
|
|
44
|
+
from skill_sentry_cli.core.verdict.stix_report import NoIOCsError
|
|
45
|
+
from skill_sentry_cli.core.version import CONTRACT_VERSION, GEMMA_BADGE, __version__, engine_version
|
|
46
|
+
from skill_sentry_cli.server.errors import ApiError
|
|
47
|
+
|
|
48
|
+
_MAX_EVENTS = 5_000
|
|
49
|
+
_STREAM_HEARTBEAT_S = 20.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _LiveScan:
|
|
54
|
+
scan_id: UUID
|
|
55
|
+
path: Path
|
|
56
|
+
started_at: datetime
|
|
57
|
+
status: ScanStatus = ScanStatus.QUEUED
|
|
58
|
+
current_stage: Stage | None = None
|
|
59
|
+
events: list[WSMessage] = field(default_factory=list)
|
|
60
|
+
changed: asyncio.Event = field(default_factory=asyncio.Event)
|
|
61
|
+
done: asyncio.Event = field(default_factory=asyncio.Event)
|
|
62
|
+
cancel_event: threading.Event = field(default_factory=threading.Event)
|
|
63
|
+
outcome: ScanOutcome | None = None
|
|
64
|
+
error: str | None = None
|
|
65
|
+
task: asyncio.Task[None] | None = None
|
|
66
|
+
elapsed_ms: int = 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _ollama_ready(config: SkillsEntryConfig) -> bool:
|
|
70
|
+
"""True only when the local server answers and the configured model is pulled."""
|
|
71
|
+
|
|
72
|
+
from skill_sentry_cli.core.models.gemma_local import GemmaLocal
|
|
73
|
+
|
|
74
|
+
with GemmaLocal(base_url=config.model.ollama_url, model=config.model.ollama_model) as model:
|
|
75
|
+
return model.health()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LiveBackend:
|
|
79
|
+
"""Production backend. ``demo_mode`` is always false."""
|
|
80
|
+
|
|
81
|
+
demo_mode = False
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
config: SkillsEntryConfig | None = None,
|
|
86
|
+
*,
|
|
87
|
+
engine: ScanEngine | None = None,
|
|
88
|
+
store: Any | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
self.config = config or load_config()
|
|
91
|
+
self._engine = engine
|
|
92
|
+
self._store = store
|
|
93
|
+
self._engines: dict[str, ScanEngine] = {}
|
|
94
|
+
self._scans: dict[UUID, _LiveScan] = {}
|
|
95
|
+
self._by_skill: dict[UUID, UUID] = {}
|
|
96
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
97
|
+
if engine is not None:
|
|
98
|
+
self._store = engine.store
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def engine(self) -> ScanEngine:
|
|
102
|
+
if self._engine is None:
|
|
103
|
+
self._engine = ScanEngine(self.config)
|
|
104
|
+
self._store = self._engine.store
|
|
105
|
+
return self._engine
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def store(self) -> Any:
|
|
109
|
+
if self._store is None:
|
|
110
|
+
self._store = self.engine.store
|
|
111
|
+
return self._store
|
|
112
|
+
|
|
113
|
+
def close(self) -> None:
|
|
114
|
+
if self._engine is not None:
|
|
115
|
+
self._engine.close()
|
|
116
|
+
for engine in self._engines.values():
|
|
117
|
+
engine.close()
|
|
118
|
+
self._engines.clear()
|
|
119
|
+
|
|
120
|
+
# -- scan lifecycle ----------------------------------------------------
|
|
121
|
+
async def start_scan(self, request: ScanRequest) -> ScanStartResponse:
|
|
122
|
+
if request.path is None or not request.path.strip():
|
|
123
|
+
raise ApiError(
|
|
124
|
+
422,
|
|
125
|
+
"missing_path",
|
|
126
|
+
"a skill path is required for live scans",
|
|
127
|
+
stage=Stage.SPEC,
|
|
128
|
+
degraded=True,
|
|
129
|
+
)
|
|
130
|
+
path = Path(request.path).expanduser()
|
|
131
|
+
try:
|
|
132
|
+
path = path.resolve(strict=True)
|
|
133
|
+
except OSError as exc:
|
|
134
|
+
raise ApiError(
|
|
135
|
+
404,
|
|
136
|
+
"no_skill_manifest",
|
|
137
|
+
f"skill path does not exist: {request.path}",
|
|
138
|
+
stage=Stage.SPEC,
|
|
139
|
+
degraded=True,
|
|
140
|
+
) from exc
|
|
141
|
+
if not path.is_dir():
|
|
142
|
+
raise ApiError(
|
|
143
|
+
422,
|
|
144
|
+
"invalid_skill",
|
|
145
|
+
f"skill path is not a directory: {path}",
|
|
146
|
+
stage=Stage.SPEC,
|
|
147
|
+
degraded=True,
|
|
148
|
+
)
|
|
149
|
+
if not (path / "SKILL.md").is_file():
|
|
150
|
+
# Allow a folder-of-skills by selecting the first package, but make
|
|
151
|
+
# the limitation explicit rather than silently scanning one child.
|
|
152
|
+
children = sorted(
|
|
153
|
+
child
|
|
154
|
+
for child in path.iterdir()
|
|
155
|
+
if child.is_dir() and (child / "SKILL.md").is_file()
|
|
156
|
+
)
|
|
157
|
+
if not children:
|
|
158
|
+
raise ApiError(
|
|
159
|
+
404,
|
|
160
|
+
"no_skill_manifest",
|
|
161
|
+
f"no SKILL.md found in {path}",
|
|
162
|
+
stage=Stage.SPEC,
|
|
163
|
+
degraded=True,
|
|
164
|
+
)
|
|
165
|
+
path = children[0]
|
|
166
|
+
|
|
167
|
+
for existing in self._scans.values():
|
|
168
|
+
if existing.path == path and not existing.done.is_set():
|
|
169
|
+
raise ApiError(
|
|
170
|
+
409,
|
|
171
|
+
"scan_in_progress",
|
|
172
|
+
f"a scan of {path} is already running",
|
|
173
|
+
stage=Stage.CACHE,
|
|
174
|
+
degraded=True,
|
|
175
|
+
scan_id=existing.scan_id,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
scan_id = uuid4()
|
|
179
|
+
item = _LiveScan(scan_id=scan_id, path=path, started_at=utc_now())
|
|
180
|
+
self._scans[scan_id] = item
|
|
181
|
+
self._loop = asyncio.get_running_loop()
|
|
182
|
+
item.task = asyncio.create_task(self._run_scan(item, request))
|
|
183
|
+
return ScanStartResponse(
|
|
184
|
+
scan_id=scan_id,
|
|
185
|
+
status=ScanStatus.QUEUED,
|
|
186
|
+
stream=f"/scan/{scan_id}/stream",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def _run_scan(self, item: _LiveScan, request: ScanRequest) -> None:
|
|
190
|
+
loop = self._loop or asyncio.get_running_loop()
|
|
191
|
+
|
|
192
|
+
def callback(message: WSMessage) -> None:
|
|
193
|
+
loop.call_soon_threadsafe(self._on_event, item, message)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
item.status = ScanStatus.RUNNING
|
|
197
|
+
engine = self._engine_for(request)
|
|
198
|
+
outcome = await asyncio.to_thread(
|
|
199
|
+
engine.scan,
|
|
200
|
+
item.path,
|
|
201
|
+
options=request.options,
|
|
202
|
+
scan_id=item.scan_id,
|
|
203
|
+
callback=callback,
|
|
204
|
+
cancel=item.cancel_event,
|
|
205
|
+
)
|
|
206
|
+
item.outcome = outcome
|
|
207
|
+
item.status = outcome.status
|
|
208
|
+
item.current_stage = Stage.VERDICT
|
|
209
|
+
item.elapsed_ms = outcome.duration_ms
|
|
210
|
+
self._by_skill[outcome.skill_id] = item.scan_id
|
|
211
|
+
except ScanError as exc:
|
|
212
|
+
item.status = ScanStatus.FAILED
|
|
213
|
+
item.error = str(exc)
|
|
214
|
+
item.current_stage = exc.stage or Stage.VERDICT
|
|
215
|
+
message = WSMessage(
|
|
216
|
+
stage=exc.stage or Stage.VERDICT,
|
|
217
|
+
skill=item.path.name,
|
|
218
|
+
event=WSEventType.ERROR,
|
|
219
|
+
ts=utc_now(),
|
|
220
|
+
detail={
|
|
221
|
+
"code": exc.code,
|
|
222
|
+
"message": str(exc),
|
|
223
|
+
"degraded": exc.degraded,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
self._on_event(item, message)
|
|
227
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
228
|
+
item.status = ScanStatus.FAILED
|
|
229
|
+
item.error = f"internal scan error: {exc}"
|
|
230
|
+
item.current_stage = Stage.VERDICT
|
|
231
|
+
self._on_event(
|
|
232
|
+
item,
|
|
233
|
+
WSMessage(
|
|
234
|
+
stage=Stage.VERDICT,
|
|
235
|
+
skill=item.path.name,
|
|
236
|
+
event=WSEventType.ERROR,
|
|
237
|
+
ts=utc_now(),
|
|
238
|
+
detail={"code": "internal_error", "message": str(exc), "degraded": True},
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
finally:
|
|
242
|
+
item.elapsed_ms = max(
|
|
243
|
+
item.elapsed_ms,
|
|
244
|
+
int((utc_now() - item.started_at).total_seconds() * 1000),
|
|
245
|
+
)
|
|
246
|
+
item.done.set()
|
|
247
|
+
item.changed.set()
|
|
248
|
+
|
|
249
|
+
def _engine_for(self, request: ScanRequest) -> ScanEngine:
|
|
250
|
+
if self._engine is not None:
|
|
251
|
+
return self._engine
|
|
252
|
+
backend = request.options.backend.value
|
|
253
|
+
if backend == "auto":
|
|
254
|
+
return self.engine
|
|
255
|
+
# A non-auto backend override is a per-request choice. Keep one
|
|
256
|
+
# engine per backend so repeated API scans do not leak a new store
|
|
257
|
+
# owner/thread pool on every request, and close them with the backend.
|
|
258
|
+
engine = self._engines.get(backend)
|
|
259
|
+
if engine is None:
|
|
260
|
+
cfg = self.config.model_copy(deep=True)
|
|
261
|
+
cfg.model.backend = backend
|
|
262
|
+
engine = ScanEngine(cfg, store=self.store)
|
|
263
|
+
self._engines[backend] = engine
|
|
264
|
+
return engine
|
|
265
|
+
|
|
266
|
+
def _on_event(self, item: _LiveScan, message: WSMessage) -> None:
|
|
267
|
+
if len(item.events) >= _MAX_EVENTS:
|
|
268
|
+
return
|
|
269
|
+
item.events.append(message)
|
|
270
|
+
item.current_stage = message.stage
|
|
271
|
+
if item.status is ScanStatus.QUEUED:
|
|
272
|
+
item.status = ScanStatus.RUNNING
|
|
273
|
+
item.changed.set()
|
|
274
|
+
|
|
275
|
+
async def get_scan(self, scan_id: UUID) -> ScanStatusResponse:
|
|
276
|
+
item = self._scans.get(scan_id)
|
|
277
|
+
if item is not None:
|
|
278
|
+
elapsed = item.elapsed_ms
|
|
279
|
+
if not item.done.is_set():
|
|
280
|
+
elapsed = max(0, int((utc_now() - item.started_at).total_seconds() * 1000))
|
|
281
|
+
return ScanStatusResponse(
|
|
282
|
+
scan_id=scan_id,
|
|
283
|
+
status=item.status,
|
|
284
|
+
stage=item.current_stage,
|
|
285
|
+
skill=item.outcome.skill_name if item.outcome else item.path.name,
|
|
286
|
+
started_at=item.started_at,
|
|
287
|
+
elapsed_ms=elapsed,
|
|
288
|
+
error=item.error,
|
|
289
|
+
)
|
|
290
|
+
record = await asyncio.to_thread(self.store.get_scan, scan_id)
|
|
291
|
+
if record is None:
|
|
292
|
+
raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
|
|
293
|
+
return ScanStatusResponse(
|
|
294
|
+
scan_id=record.scan_id,
|
|
295
|
+
status=record.status,
|
|
296
|
+
stage=Stage.VERDICT
|
|
297
|
+
if record.status in {ScanStatus.COMPLETED, ScanStatus.CACHED_HIT}
|
|
298
|
+
else None,
|
|
299
|
+
skill=record.skill_name,
|
|
300
|
+
started_at=record.started_at,
|
|
301
|
+
elapsed_ms=record.duration_ms,
|
|
302
|
+
error=record.error,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
async def get_verdicts(self, scan_id: UUID) -> VerdictsResponse:
|
|
306
|
+
item = self._scans.get(scan_id)
|
|
307
|
+
if item is not None:
|
|
308
|
+
if item.status is ScanStatus.FAILED:
|
|
309
|
+
raise ApiError(500, "scan_failed", item.error or "scan failed", scan_id=scan_id)
|
|
310
|
+
if item.outcome is None:
|
|
311
|
+
raise ApiError(
|
|
312
|
+
409,
|
|
313
|
+
"scan_in_progress",
|
|
314
|
+
"scan has not completed yet",
|
|
315
|
+
stage=item.current_stage or Stage.CACHE,
|
|
316
|
+
degraded=True,
|
|
317
|
+
scan_id=scan_id,
|
|
318
|
+
)
|
|
319
|
+
return VerdictsResponse(scan_id=scan_id, verdicts=[item.outcome.verdict])
|
|
320
|
+
stored = await asyncio.to_thread(self.store.get_result, scan_id)
|
|
321
|
+
if stored is None:
|
|
322
|
+
raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
|
|
323
|
+
verdict, _detail, _stages, _cached = stored
|
|
324
|
+
return VerdictsResponse(scan_id=scan_id, verdicts=[verdict])
|
|
325
|
+
|
|
326
|
+
async def list_scans(
|
|
327
|
+
self,
|
|
328
|
+
*,
|
|
329
|
+
limit: int = 100,
|
|
330
|
+
offset: int = 0,
|
|
331
|
+
status: ScanStatus | None = None,
|
|
332
|
+
) -> ScanListResponse:
|
|
333
|
+
rows = await asyncio.to_thread(
|
|
334
|
+
self.store.list_scans, limit=limit, offset=offset, status=status
|
|
335
|
+
)
|
|
336
|
+
summaries: list[ScanSummary] = []
|
|
337
|
+
for row in rows:
|
|
338
|
+
if row["skill_id"] is None:
|
|
339
|
+
continue
|
|
340
|
+
summaries.append(
|
|
341
|
+
ScanSummary(
|
|
342
|
+
scan_id=row["scan_id"],
|
|
343
|
+
skill_id=row["skill_id"],
|
|
344
|
+
skill=row["skill"],
|
|
345
|
+
verdict=row["verdict"],
|
|
346
|
+
risk_score=row["risk_score"],
|
|
347
|
+
cached=row["cached"],
|
|
348
|
+
status=row["status"],
|
|
349
|
+
started_at=row["started_at"],
|
|
350
|
+
duration_ms=row["duration_ms"],
|
|
351
|
+
key_finding=row["key_finding"],
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
# Surface a just-started scan before its engine row is visible. The
|
|
355
|
+
# store has already applied pagination; only page zero may include the
|
|
356
|
+
# in-memory rows, and the final list must not be sliced a second time.
|
|
357
|
+
if offset == 0:
|
|
358
|
+
seen = {summary.scan_id for summary in summaries}
|
|
359
|
+
live: list[ScanSummary] = []
|
|
360
|
+
for item in self._scans.values():
|
|
361
|
+
if item.scan_id in seen or (status is not None and item.status is not status):
|
|
362
|
+
continue
|
|
363
|
+
if item.outcome is not None:
|
|
364
|
+
outcome = item.outcome
|
|
365
|
+
live.append(
|
|
366
|
+
ScanSummary(
|
|
367
|
+
scan_id=item.scan_id,
|
|
368
|
+
skill_id=outcome.skill_id,
|
|
369
|
+
skill=outcome.skill_name,
|
|
370
|
+
verdict=outcome.verdict.verdict,
|
|
371
|
+
risk_score=outcome.verdict.risk_score,
|
|
372
|
+
cached=outcome.cached,
|
|
373
|
+
status=outcome.status,
|
|
374
|
+
started_at=item.started_at,
|
|
375
|
+
duration_ms=outcome.duration_ms,
|
|
376
|
+
key_finding=_key_finding(outcome.verdict),
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
summaries = live + summaries
|
|
380
|
+
return ScanListResponse(scans=summaries[:limit])
|
|
381
|
+
|
|
382
|
+
async def get_skill(self, skill_id: UUID) -> SkillDetail:
|
|
383
|
+
scan_id = self._by_skill.get(skill_id)
|
|
384
|
+
item = self._scans.get(scan_id) if scan_id else None
|
|
385
|
+
if item is not None and item.outcome is not None and item.outcome.skill_id == skill_id:
|
|
386
|
+
return item.outcome.detail
|
|
387
|
+
stored = await asyncio.to_thread(self.store.get_result_for_skill, skill_id)
|
|
388
|
+
if stored is None:
|
|
389
|
+
raise ApiError(404, "unknown_scan", f"unknown skill: {skill_id}")
|
|
390
|
+
_verdict, detail, _stages, _cached = stored
|
|
391
|
+
return detail
|
|
392
|
+
|
|
393
|
+
# -- streaming ---------------------------------------------------------
|
|
394
|
+
async def stream(self, scan_id: UUID) -> AsyncIterator[WSMessage]:
|
|
395
|
+
item = self._scans.get(scan_id)
|
|
396
|
+
if item is None:
|
|
397
|
+
stored = await asyncio.to_thread(self.store.get_result, scan_id)
|
|
398
|
+
if stored is None:
|
|
399
|
+
raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
|
|
400
|
+
verdict, _detail, _stages, _cached = stored
|
|
401
|
+
yield WSMessage(
|
|
402
|
+
stage=Stage.VERDICT,
|
|
403
|
+
skill=verdict.skill,
|
|
404
|
+
event=WSEventType.COMPLETED,
|
|
405
|
+
ts=utc_now(),
|
|
406
|
+
detail={"verdict": verdict.model_dump(by_alias=True, mode="json")},
|
|
407
|
+
)
|
|
408
|
+
return
|
|
409
|
+
index = 0
|
|
410
|
+
while True:
|
|
411
|
+
while index < len(item.events):
|
|
412
|
+
yield item.events[index]
|
|
413
|
+
index += 1
|
|
414
|
+
if item.done.is_set() and index >= len(item.events):
|
|
415
|
+
break
|
|
416
|
+
item.changed.clear()
|
|
417
|
+
if index < len(item.events) or item.done.is_set():
|
|
418
|
+
continue
|
|
419
|
+
try:
|
|
420
|
+
await asyncio.wait_for(item.changed.wait(), timeout=_STREAM_HEARTBEAT_S)
|
|
421
|
+
except TimeoutError:
|
|
422
|
+
yield WSMessage(
|
|
423
|
+
stage=item.current_stage or Stage.CACHE,
|
|
424
|
+
skill=item.outcome.skill_name if item.outcome else item.path.name,
|
|
425
|
+
event=WSEventType.PROGRESS,
|
|
426
|
+
ts=utc_now(),
|
|
427
|
+
detail={"heartbeat": True, "message": "scan still running"},
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
async def cancel(self, scan_id: UUID) -> None:
|
|
431
|
+
item = self._scans.get(scan_id)
|
|
432
|
+
if item is None:
|
|
433
|
+
raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
|
|
434
|
+
if item.done.is_set():
|
|
435
|
+
return
|
|
436
|
+
item.cancel_event.set()
|
|
437
|
+
# Give the worker one boundary to observe cancellation; if it is stuck
|
|
438
|
+
# in a third-party call, report it as failed rather than leaving the UI
|
|
439
|
+
# spinning forever. The worker's own error path wins if it arrives.
|
|
440
|
+
await asyncio.sleep(0)
|
|
441
|
+
if not item.done.is_set():
|
|
442
|
+
item.status = ScanStatus.FAILED
|
|
443
|
+
item.error = "cancelled by client"
|
|
444
|
+
item.current_stage = Stage.VERDICT
|
|
445
|
+
item.done.set()
|
|
446
|
+
item.changed.set()
|
|
447
|
+
|
|
448
|
+
# -- meta / health -----------------------------------------------------
|
|
449
|
+
async def health(self) -> HealthResponse:
|
|
450
|
+
db_ok = False
|
|
451
|
+
try:
|
|
452
|
+
store = self.store
|
|
453
|
+
db_ok = bool(await asyncio.to_thread(store.ping))
|
|
454
|
+
except Exception:
|
|
455
|
+
# Health is diagnostic: a broken database must produce "down", not
|
|
456
|
+
# a 500 response that hides the rest of the system state.
|
|
457
|
+
db_ok = False
|
|
458
|
+
|
|
459
|
+
backend = await asyncio.to_thread(self._active_backend)
|
|
460
|
+
ollama_ok = False
|
|
461
|
+
if backend in {"mock", "demo"}:
|
|
462
|
+
ollama_ok = True
|
|
463
|
+
elif backend in {"ollama", "local"}:
|
|
464
|
+
# Same readiness test as the model layer: the server answers and the
|
|
465
|
+
# configured model is pulled. "ready" is the only state reported as ok.
|
|
466
|
+
with contextlib.suppress(Exception):
|
|
467
|
+
ollama_ok = await asyncio.to_thread(_ollama_ready, self.config)
|
|
468
|
+
elif backend == "cloud":
|
|
469
|
+
with contextlib.suppress(Exception):
|
|
470
|
+
from skill_sentry_cli.core.models.selector import select_model
|
|
471
|
+
|
|
472
|
+
model = select_model(self.config, probe=False)
|
|
473
|
+
ollama_ok = bool(await asyncio.to_thread(model.health))
|
|
474
|
+
|
|
475
|
+
docker_ok = False
|
|
476
|
+
with contextlib.suppress(Exception):
|
|
477
|
+
from skill_sentry_cli.core.sandbox.runner import SandboxConfig, SandboxRunner
|
|
478
|
+
|
|
479
|
+
runner = SandboxRunner(config=SandboxConfig(image=self.config.sandbox.base_image))
|
|
480
|
+
try:
|
|
481
|
+
docker_ok = await asyncio.to_thread(runner.available)
|
|
482
|
+
finally:
|
|
483
|
+
runner.close()
|
|
484
|
+
return HealthResponse(
|
|
485
|
+
ollama="ok" if ollama_ok else "down",
|
|
486
|
+
docker="ok" if docker_ok else "down",
|
|
487
|
+
db="ok" if db_ok else "down",
|
|
488
|
+
demo_mode=False,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def _active_backend(self) -> str:
|
|
492
|
+
"""Report the model identity this backend will actually use.
|
|
493
|
+
|
|
494
|
+
An injected engine may be running ``MockGemma`` even when the TOML says
|
|
495
|
+
``auto``; re-running selection here would lie about that. Only fall
|
|
496
|
+
back to configuration when there is no engine to inspect.
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
# Do not trigger a real health probe/selection from a status endpoint.
|
|
500
|
+
# If a model has already been selected (including an injected one),
|
|
501
|
+
# report it; otherwise fall back to configuration.
|
|
502
|
+
if self._engine is not None and getattr(self._engine, "_model_selected", False):
|
|
503
|
+
try:
|
|
504
|
+
backend, _ = self._engine.model_identity()
|
|
505
|
+
return backend
|
|
506
|
+
except Exception:
|
|
507
|
+
return "unavailable"
|
|
508
|
+
try:
|
|
509
|
+
from skill_sentry_cli.core.models.selector import select_model
|
|
510
|
+
|
|
511
|
+
return str(select_model(self.config, probe=False).backend)
|
|
512
|
+
except Exception:
|
|
513
|
+
return "unavailable"
|
|
514
|
+
|
|
515
|
+
async def meta(self) -> MetaResponse:
|
|
516
|
+
backend = await asyncio.to_thread(self._active_backend)
|
|
517
|
+
return MetaResponse(
|
|
518
|
+
version=__version__,
|
|
519
|
+
backend_active=backend,
|
|
520
|
+
gemma_badge=GEMMA_BADGE,
|
|
521
|
+
demo_mode=False,
|
|
522
|
+
contract_version=CONTRACT_VERSION,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
async def cache_stats(self) -> CacheStatsResponse:
|
|
526
|
+
entries, hit_rate = await asyncio.to_thread(self.store.cache_stats)
|
|
527
|
+
size = await asyncio.to_thread(lambda: self.store.size_bytes)
|
|
528
|
+
model_version = "unavailable"
|
|
529
|
+
if self._engine is not None and getattr(self._engine, "_model_selected", False):
|
|
530
|
+
with contextlib.suppress(Exception):
|
|
531
|
+
_backend, model_version = self._engine.model_identity()
|
|
532
|
+
else:
|
|
533
|
+
with contextlib.suppress(Exception):
|
|
534
|
+
from skill_sentry_cli.core.models.selector import select_model
|
|
535
|
+
|
|
536
|
+
model = select_model(self.config, probe=False)
|
|
537
|
+
model_version = model.model_version
|
|
538
|
+
return CacheStatsResponse(
|
|
539
|
+
entries=entries,
|
|
540
|
+
hit_rate=hit_rate,
|
|
541
|
+
db_size_bytes=size if isinstance(size, int) else 0,
|
|
542
|
+
engine_version=engine_version(self.config.scoring.weights),
|
|
543
|
+
model_version=model_version,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
async def clear_cache(self, older_than: str | None = None) -> DeleteCacheResponse:
|
|
547
|
+
try:
|
|
548
|
+
deleted, retained = await asyncio.to_thread(self.store.clear_cache, older_than)
|
|
549
|
+
except Exception as exc:
|
|
550
|
+
raise ApiError(422, "invalid_duration", str(exc), degraded=True) from exc
|
|
551
|
+
return DeleteCacheResponse(deleted=deleted, retained=retained)
|
|
552
|
+
|
|
553
|
+
def export(self, skill_id: UUID, fmt: ExportFormat) -> RenderedReport:
|
|
554
|
+
stored = self.store.get_result_for_skill(skill_id)
|
|
555
|
+
if stored is None:
|
|
556
|
+
raise ApiError(404, "unknown_scan", f"unknown skill: {skill_id}")
|
|
557
|
+
verdict, _detail, stages, _cached = stored
|
|
558
|
+
try:
|
|
559
|
+
return render_report(verdict, fmt, stage_results=stages)
|
|
560
|
+
except NoIOCsError as exc:
|
|
561
|
+
raise ApiError(
|
|
562
|
+
409,
|
|
563
|
+
"no_iocs",
|
|
564
|
+
"STIX export requires an active-defense scan with confirmed IOCs",
|
|
565
|
+
stage=Stage.POISONED_WELL,
|
|
566
|
+
) from exc
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _key_finding(verdict: Verdict) -> str | None:
|
|
570
|
+
for finding in sorted(verdict.findings, key=lambda item: -item.severity.rank):
|
|
571
|
+
if finding.severity.rank > 0:
|
|
572
|
+
return finding.message
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
__all__ = ["LiveBackend"]
|