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,278 @@
|
|
|
1
|
+
"""Parse and normalise telemetry emitted by the in-container monitor shim.
|
|
2
|
+
|
|
3
|
+
The ``LD_PRELOAD`` shim writes one JSON object per line to
|
|
4
|
+
``/tmp/skillsentry/events.jsonl``. This module is deliberately tolerant: a
|
|
5
|
+
truncated or hostile log line must never break a scan, so malformed records are
|
|
6
|
+
skipped and counted rather than raised.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from skill_sentry_cli.core.contracts import SandboxEvent, format_utc, utc_now
|
|
17
|
+
from skill_sentry_cli.core.poisoned_well.canary_gen import MARKER_RE, CanarySet
|
|
18
|
+
|
|
19
|
+
MAX_LOG_BYTES = 4 * 1024 * 1024
|
|
20
|
+
MAX_LOG_LINES = 10_000
|
|
21
|
+
MAX_LINE_CHARS = 16_384
|
|
22
|
+
MAX_EVENTS = 5_000
|
|
23
|
+
MAX_PATH_CHARS = 1_024
|
|
24
|
+
MAX_DETAIL_JSON_CHARS = 8_192
|
|
25
|
+
|
|
26
|
+
CANARY_EVENT_TYPES = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"canary_seeded",
|
|
29
|
+
"canary_read",
|
|
30
|
+
"fake_served",
|
|
31
|
+
"probe_detected",
|
|
32
|
+
"exfil_raw",
|
|
33
|
+
"exfil_decoded",
|
|
34
|
+
"marker_matched",
|
|
35
|
+
"secondary_served",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class MonitorEvent:
|
|
42
|
+
ts: datetime
|
|
43
|
+
event_type: str
|
|
44
|
+
path_or_channel: str | None = None
|
|
45
|
+
marker: str | None = None
|
|
46
|
+
process: str | None = None
|
|
47
|
+
detail: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_canary_read(self) -> bool:
|
|
51
|
+
return self.event_type == "canary_read"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_probe(self) -> bool:
|
|
55
|
+
return self.event_type == "probe_detected"
|
|
56
|
+
|
|
57
|
+
def to_contract(self) -> SandboxEvent:
|
|
58
|
+
return SandboxEvent(
|
|
59
|
+
ts=self.ts,
|
|
60
|
+
event_type=self.event_type,
|
|
61
|
+
path_or_channel=self.path_or_channel,
|
|
62
|
+
marker=self.marker,
|
|
63
|
+
process=self.process,
|
|
64
|
+
detail=dict(self.detail) or None,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ParseResult:
|
|
70
|
+
events: list[MonitorEvent]
|
|
71
|
+
skipped: int = 0
|
|
72
|
+
error: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_ts(value: Any) -> datetime | None:
|
|
76
|
+
if isinstance(value, datetime):
|
|
77
|
+
parsed = value
|
|
78
|
+
elif isinstance(value, str):
|
|
79
|
+
text = value.strip()
|
|
80
|
+
if text.endswith("Z"):
|
|
81
|
+
text = f"{text[:-1]}+00:00"
|
|
82
|
+
try:
|
|
83
|
+
parsed = datetime.fromisoformat(text)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
else:
|
|
87
|
+
return None
|
|
88
|
+
if parsed.tzinfo is None:
|
|
89
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
90
|
+
return parsed.astimezone(UTC)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_monitor_line(line: str) -> MonitorEvent | None:
|
|
94
|
+
if len(line) > MAX_LINE_CHARS:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
payload = json.loads(line)
|
|
98
|
+
except (TypeError, ValueError):
|
|
99
|
+
return None
|
|
100
|
+
if not isinstance(payload, dict):
|
|
101
|
+
return None
|
|
102
|
+
ts = _parse_ts(payload.get("ts"))
|
|
103
|
+
event_type = payload.get("event_type")
|
|
104
|
+
if ts is None or not isinstance(event_type, str) or event_type not in CANARY_EVENT_TYPES:
|
|
105
|
+
return None
|
|
106
|
+
path = payload.get("path")
|
|
107
|
+
if path is None:
|
|
108
|
+
path = payload.get("path_or_channel")
|
|
109
|
+
if path is not None and (not isinstance(path, str) or len(path) > MAX_PATH_CHARS):
|
|
110
|
+
return None
|
|
111
|
+
marker = payload.get("marker")
|
|
112
|
+
if marker is not None and (not isinstance(marker, str) or not MARKER_RE.fullmatch(marker)):
|
|
113
|
+
# A marker is evidence, not a free-form field. Drop forged/broken
|
|
114
|
+
# values rather than trusting arbitrary log content.
|
|
115
|
+
marker = None
|
|
116
|
+
process = payload.get("process")
|
|
117
|
+
detail = payload.get("detail")
|
|
118
|
+
if isinstance(detail, dict):
|
|
119
|
+
try:
|
|
120
|
+
if len(json.dumps(detail, sort_keys=True)) > MAX_DETAIL_JSON_CHARS:
|
|
121
|
+
detail = {"truncated": True}
|
|
122
|
+
except (TypeError, ValueError):
|
|
123
|
+
detail = {}
|
|
124
|
+
else:
|
|
125
|
+
detail = {}
|
|
126
|
+
return MonitorEvent(
|
|
127
|
+
ts=ts,
|
|
128
|
+
event_type=event_type,
|
|
129
|
+
path_or_channel=path if isinstance(path, str) else None,
|
|
130
|
+
marker=marker if isinstance(marker, str) and marker else None,
|
|
131
|
+
process=process[:256] if isinstance(process, str) else None,
|
|
132
|
+
detail=detail,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_monitor_log(text: str | bytes | None) -> ParseResult:
|
|
137
|
+
if text is None:
|
|
138
|
+
return ParseResult(events=[])
|
|
139
|
+
if isinstance(text, bytes):
|
|
140
|
+
if len(text) > MAX_LOG_BYTES:
|
|
141
|
+
text = text[-MAX_LOG_BYTES:]
|
|
142
|
+
text = text.decode("utf-8", errors="replace")
|
|
143
|
+
elif len(text) > MAX_LOG_BYTES:
|
|
144
|
+
text = text[-MAX_LOG_BYTES:]
|
|
145
|
+
events: list[MonitorEvent] = []
|
|
146
|
+
skipped = 0
|
|
147
|
+
for index, line in enumerate(text.splitlines()):
|
|
148
|
+
if index >= MAX_LOG_LINES:
|
|
149
|
+
skipped += 1
|
|
150
|
+
break
|
|
151
|
+
line = line.strip()
|
|
152
|
+
if not line:
|
|
153
|
+
continue
|
|
154
|
+
event = parse_monitor_line(line)
|
|
155
|
+
if event is None:
|
|
156
|
+
skipped += 1
|
|
157
|
+
else:
|
|
158
|
+
events.append(event)
|
|
159
|
+
if len(events) >= MAX_EVENTS:
|
|
160
|
+
skipped += 1
|
|
161
|
+
break
|
|
162
|
+
events.sort(key=lambda event: event.ts)
|
|
163
|
+
return ParseResult(events=events, skipped=skipped)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def is_seeded_canary_read(event: MonitorEvent, canaries: CanarySet) -> bool:
|
|
167
|
+
"""True only for an exact seeded file or environment canary.
|
|
168
|
+
|
|
169
|
+
The LD_PRELOAD shim logs every read under ``/home/sandbox`` because it
|
|
170
|
+
cannot know the host-side seed set. Treating such a read as canary
|
|
171
|
+
evidence would turn ordinary cache/config reads into false positives.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
if event.event_type != "canary_read":
|
|
175
|
+
return False
|
|
176
|
+
path = event.path_or_channel
|
|
177
|
+
if not path:
|
|
178
|
+
return False
|
|
179
|
+
if path.startswith("env:"):
|
|
180
|
+
return path.removeprefix("env:") in canaries.env
|
|
181
|
+
return canaries.file_for(path) is not None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def normalise_paths(events: list[MonitorEvent], canaries: CanarySet) -> list[MonitorEvent]:
|
|
185
|
+
normalised: list[MonitorEvent] = []
|
|
186
|
+
for event in events:
|
|
187
|
+
path = event.path_or_channel
|
|
188
|
+
if path and path.startswith("/"):
|
|
189
|
+
path = canaries.display_path(path)
|
|
190
|
+
marker = event.marker
|
|
191
|
+
if event.event_type == "canary_read":
|
|
192
|
+
# Drop shim reads that do not hit a seeded canary. This is the
|
|
193
|
+
# host-side source of truth; the shim's broader path heuristic must
|
|
194
|
+
# never become a finding by itself.
|
|
195
|
+
if not path:
|
|
196
|
+
continue
|
|
197
|
+
if path.startswith("env:"):
|
|
198
|
+
if path.removeprefix("env:") not in canaries.env:
|
|
199
|
+
continue
|
|
200
|
+
marker = canaries.marker
|
|
201
|
+
else:
|
|
202
|
+
file = canaries.file_for(path)
|
|
203
|
+
if file is None:
|
|
204
|
+
continue
|
|
205
|
+
marker = file.marker
|
|
206
|
+
normalised.append(
|
|
207
|
+
MonitorEvent(
|
|
208
|
+
ts=event.ts,
|
|
209
|
+
event_type=event.event_type,
|
|
210
|
+
path_or_channel=path,
|
|
211
|
+
marker=marker,
|
|
212
|
+
process=event.process,
|
|
213
|
+
detail=event.detail,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
return normalised
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def canary_reads(events: list[MonitorEvent]) -> list[MonitorEvent]:
|
|
220
|
+
return [event for event in events if event.is_canary_read]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def probe_events(events: list[MonitorEvent]) -> list[MonitorEvent]:
|
|
224
|
+
return [event for event in events if event.is_probe]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def synthetic_event(
|
|
228
|
+
event_type: str,
|
|
229
|
+
*,
|
|
230
|
+
path_or_channel: str | None = None,
|
|
231
|
+
marker: str | None = None,
|
|
232
|
+
process: str | None = None,
|
|
233
|
+
detail: dict[str, Any] | None = None,
|
|
234
|
+
ts: datetime | None = None,
|
|
235
|
+
) -> MonitorEvent:
|
|
236
|
+
"""Create an event from host-side knowledge (seed/injection events)."""
|
|
237
|
+
|
|
238
|
+
return MonitorEvent(
|
|
239
|
+
ts=ts or utc_now(),
|
|
240
|
+
event_type=event_type,
|
|
241
|
+
path_or_channel=path_or_channel,
|
|
242
|
+
marker=marker,
|
|
243
|
+
process=process,
|
|
244
|
+
detail=detail or {},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def to_contract_events(events: list[MonitorEvent]) -> list[SandboxEvent]:
|
|
249
|
+
return [event.to_contract() for event in events]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def event_summary(events: list[MonitorEvent]) -> dict[str, int]:
|
|
253
|
+
counts: dict[str, int] = {}
|
|
254
|
+
for event in events:
|
|
255
|
+
counts[event.event_type] = counts.get(event.event_type, 0) + 1
|
|
256
|
+
return counts
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def format_events_jsonl(events: list[MonitorEvent]) -> str:
|
|
260
|
+
"""Serialise events for tests or for pre-seeding a monitor log."""
|
|
261
|
+
|
|
262
|
+
lines: list[str] = []
|
|
263
|
+
for event in events:
|
|
264
|
+
lines.append(
|
|
265
|
+
json.dumps(
|
|
266
|
+
{
|
|
267
|
+
"ts": format_utc(event.ts),
|
|
268
|
+
"event_type": event.event_type,
|
|
269
|
+
"path": event.path_or_channel,
|
|
270
|
+
"marker": event.marker,
|
|
271
|
+
"process": event.process,
|
|
272
|
+
"detail": event.detail,
|
|
273
|
+
},
|
|
274
|
+
sort_keys=True,
|
|
275
|
+
separators=(",", ":"),
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
return "\n".join(lines) + ("\n" if lines else "")
|