skill-sentry-cli 2.0.0__py3-none-any.whl

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