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,470 @@
1
+ """Poisoned Well correlation engine.
2
+
3
+ Event A is a canary read (file access or an injected env var); Event B is a DNS
4
+ query or HTTP request observed by the sinkhole. A correlation is confirmed only
5
+ when B falls inside the configured window and contains the canary marker in one
6
+ of its supported encodings: raw, base64 (standard or URL-safe), hex, or split
7
+ across DNS labels.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import binascii
14
+ import hashlib
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime, timedelta
18
+ from typing import Any, Literal
19
+ from urllib.parse import unquote_plus, urlsplit
20
+
21
+ from skill_sentry_cli.core.contracts import (
22
+ ExfilChannel,
23
+ OverrideSignal,
24
+ Stage,
25
+ VerdictTier,
26
+ format_utc,
27
+ utc_now,
28
+ )
29
+ from skill_sentry_cli.core.poisoned_well.canary_gen import CanarySet
30
+ from skill_sentry_cli.core.sandbox.monitor import MonitorEvent
31
+
32
+ _BASE64_RE = re.compile(r"[A-Za-z0-9+/_=-]{8,}")
33
+ _HEX_RE = re.compile(r"(?:[0-9a-fA-F]{2}){8,}")
34
+ _IPV4_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}$")
35
+
36
+ # Hints used to narrow a confirmed observation to the canary source(s) that
37
+ # actually appear in the decoded payload. If none match, the tracker keeps all
38
+ # in-window reads and reports the broader set rather than losing evidence.
39
+ _ASSET_HINTS: dict[str, tuple[str, ...]] = {
40
+ "aws": (
41
+ "AKIA",
42
+ "aws_access_key_id",
43
+ "aws_secret_access_key",
44
+ "AWS_ACCESS_KEY_ID",
45
+ "AWS_SECRET_ACCESS_KEY",
46
+ ),
47
+ "github": ("ghp_", "oauth_token", "GITHUB_TOKEN", "GH_TOKEN", "github.com"),
48
+ "slack": ("xoxb-", "SLACK_BOT_TOKEN", "SLACK_WEBHOOK_URL", "hooks.slack"),
49
+ "ssh": ("BEGIN OPENSSH PRIVATE KEY", "id_rsa", "SSH_KEY_PATH"),
50
+ "generic": (
51
+ "OPENAI_API_KEY",
52
+ "DATABASE_URL",
53
+ "NPM_TOKEN",
54
+ "passwords.kdbx",
55
+ "npm_",
56
+ "postgres://",
57
+ ),
58
+ }
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CanaryRead:
63
+ ts: datetime
64
+ path: str
65
+ asset: str
66
+ marker: str
67
+ process: str | None = None
68
+ implicit: bool = False
69
+
70
+ @property
71
+ def is_env(self) -> bool:
72
+ return self.path.startswith("env:")
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class NetObservation:
77
+ ts: datetime
78
+ protocol: Literal["dns", "http"]
79
+ query_or_url: str
80
+ body: bytes = b""
81
+ headers: dict[str, str] = field(default_factory=dict)
82
+ client: str | None = None
83
+ method: str | None = None
84
+ responder_action: str | None = None
85
+
86
+ @property
87
+ def channel(self) -> ExfilChannel:
88
+ return ExfilChannel.DNS_TUNNELING if self.protocol == "dns" else ExfilChannel.HTTP_POST
89
+
90
+ @property
91
+ def body_b64(self) -> str | None:
92
+ if not self.body:
93
+ return None
94
+ return base64.b64encode(self.body).decode("ascii")
95
+
96
+ def as_text(self) -> str:
97
+ header_text = "\n".join(f"{key}: {value}" for key, value in sorted(self.headers.items()))
98
+ body_text = self.body.decode("utf-8", errors="replace")
99
+ return "\n".join(part for part in (self.query_or_url, header_text, body_text) if part)
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Correlation:
104
+ matched: bool
105
+ reads: tuple[CanaryRead, ...]
106
+ observation: NetObservation
107
+ marker: str | None = None
108
+ encoding: str | None = None
109
+ c2_indicators: tuple[str, ...] = ()
110
+ decoded_preview: str | None = None
111
+ confidence: float = 0.0
112
+
113
+ @property
114
+ def channel(self) -> ExfilChannel:
115
+ return self.observation.channel
116
+
117
+ @property
118
+ def targeted_assets(self) -> tuple[str, ...]:
119
+ return tuple(dict.fromkeys(read.path for read in self.reads))
120
+
121
+ def to_override(self) -> OverrideSignal | None:
122
+ if not self.matched:
123
+ return None
124
+ assets = ", ".join(self.targeted_assets) or "a seeded canary"
125
+ encoding = self.encoding or "raw"
126
+ return OverrideSignal(
127
+ verdict=VerdictTier.MALICIOUS,
128
+ confidence=1.0,
129
+ reason=(
130
+ f"Poisoned Well confirmed exfiltration: {assets} was read and its "
131
+ f"marker appeared in {self.channel.value} traffic ({encoding})."
132
+ ),
133
+ source=Stage.POISONED_WELL,
134
+ evidence={
135
+ "canaryMarker": self.marker,
136
+ "encoding": encoding,
137
+ "exfilChannel": self.channel.value,
138
+ "targetedAssets": list(self.targeted_assets),
139
+ "c2Indicators": list(self.c2_indicators),
140
+ "observation": self.observation.query_or_url,
141
+ },
142
+ )
143
+
144
+
145
+ def _decode_base64(value: str) -> bytes | None:
146
+ candidate = value.strip()
147
+ if not candidate:
148
+ return None
149
+ # URL-safe alphabet, then pad to a multiple of four.
150
+ candidate = candidate.replace("-", "+").replace("_", "/")
151
+ candidate = candidate.rstrip("=")
152
+ candidate += "=" * (-len(candidate) % 4)
153
+ try:
154
+ return base64.b64decode(candidate, validate=False)
155
+ except (binascii.Error, ValueError):
156
+ return None
157
+
158
+
159
+ def _decode_hex(value: str) -> bytes | None:
160
+ candidate = value.strip()
161
+ if len(candidate) % 2:
162
+ candidate = candidate[:-1]
163
+ if len(candidate) < 16:
164
+ return None
165
+ try:
166
+ return bytes.fromhex(candidate)
167
+ except ValueError:
168
+ return None
169
+
170
+
171
+ def _registered_domain(host: str) -> str:
172
+ host = host.strip().strip(".").lower()
173
+ if not host:
174
+ return host
175
+ if _IPV4_RE.match(host):
176
+ return host
177
+ labels = host.split(".")
178
+ if len(labels) <= 2:
179
+ return host
180
+ second_level = {"co", "com", "org", "net", "gov", "ac", "edu"}
181
+ if len(labels[-1]) == 2 and labels[-2] in second_level:
182
+ return ".".join(labels[-3:])
183
+ return ".".join(labels[-2:])
184
+
185
+
186
+ def _c2_from_observation(observation: NetObservation) -> tuple[str, ...]:
187
+ if observation.protocol == "dns":
188
+ domain = _registered_domain(observation.query_or_url)
189
+ return (domain,) if domain else ()
190
+ try:
191
+ parsed = urlsplit(observation.query_or_url)
192
+ except ValueError:
193
+ return ()
194
+ host = parsed.hostname
195
+ if not host:
196
+ return ()
197
+ return (_registered_domain(host),)
198
+
199
+
200
+ def _dns_prefixes(qname: str) -> list[str]:
201
+ labels = [label for label in qname.split(".") if label]
202
+ prefixes: list[str] = []
203
+ for index in range(1, len(labels) + 1):
204
+ prefixes.append("".join(labels[:index]))
205
+ prefixes.append(".".join(labels[:index]))
206
+ return prefixes
207
+
208
+
209
+ def _candidate_texts(observation: NetObservation) -> list[tuple[str, str]]:
210
+ """Return ``(encoding, decoded_text)`` candidates for marker matching."""
211
+
212
+ raw = observation.as_text()
213
+ candidates: list[tuple[str, str]] = [("raw", raw)]
214
+ decoded_url = unquote_plus(raw)
215
+ if decoded_url != raw:
216
+ candidates.append(("raw", decoded_url))
217
+
218
+ if observation.protocol == "dns":
219
+ for variant in _dns_prefixes(observation.query_or_url):
220
+ candidates.append(("subdomain", variant))
221
+ decoded = _decode_base64(variant)
222
+ if decoded is not None:
223
+ candidates.append(("base64", decoded.decode("utf-8", errors="replace")))
224
+ decoded_hex = _decode_hex(variant)
225
+ if decoded_hex is not None:
226
+ candidates.append(("hex", decoded_hex.decode("utf-8", errors="replace")))
227
+
228
+ tokens = [raw, decoded_url, *[value for _encoding, value in candidates]]
229
+ for value in tokens:
230
+ for match in _BASE64_RE.findall(value):
231
+ decoded = _decode_base64(match)
232
+ if decoded is not None:
233
+ candidates.append(("base64", decoded.decode("utf-8", errors="replace")))
234
+ for match in _HEX_RE.findall(value):
235
+ decoded = _decode_hex(match)
236
+ if decoded is not None:
237
+ candidates.append(("hex", decoded.decode("utf-8", errors="replace")))
238
+
239
+ # De-duplicate while preserving the first (most direct) encoding.
240
+ seen: set[tuple[str, str]] = set()
241
+ unique: list[tuple[str, str]] = []
242
+ for encoding, value in candidates:
243
+ key = (encoding, value)
244
+ if key not in seen:
245
+ seen.add(key)
246
+ unique.append(key)
247
+ return unique
248
+
249
+
250
+ def _find_marker(
251
+ observation: NetObservation, canaries: CanarySet
252
+ ) -> tuple[str | None, str | None, str | None, str | None]:
253
+ """Return ``(marker, encoding, preview, matched_text)`` for a token hit."""
254
+
255
+ tokens = canaries.search_tokens
256
+ for encoding, text in _candidate_texts(observation):
257
+ for token in tokens:
258
+ if token in text:
259
+ preview = text[:300] if encoding != "raw" else None
260
+ matched_encoding = encoding
261
+ if encoding == "subdomain" and token in observation.query_or_url:
262
+ matched_encoding = "raw"
263
+ return canaries.marker, matched_encoding, preview, text
264
+ return None, None, None, None
265
+
266
+
267
+ def _filter_reads(reads: list[CanaryRead], matched_text: str) -> list[CanaryRead]:
268
+ """Prefer the canary sources whose provider hints appear in the payload."""
269
+
270
+ matched = [
271
+ read
272
+ for read in reads
273
+ if any(hint in matched_text for hint in _ASSET_HINTS.get(read.asset, ()))
274
+ ]
275
+ return matched or reads
276
+
277
+
278
+ class ExfilTracker:
279
+ """Stateful per-scan correlator; safe to call from one scan worker."""
280
+
281
+ def __init__(
282
+ self,
283
+ canaries: CanarySet,
284
+ *,
285
+ window_s: float = 30.0,
286
+ clock_skew_s: float = 2.0,
287
+ ) -> None:
288
+ if window_s <= 0:
289
+ raise ValueError("window_s must be positive")
290
+ self.canaries = canaries
291
+ self.window_s = window_s
292
+ self.clock_skew_s = max(0.0, clock_skew_s)
293
+ self._reads: list[CanaryRead] = []
294
+ self._observations: list[NetObservation] = []
295
+ self._results: list[Correlation] = []
296
+ self._seen: set[tuple[str, str, str]] = set()
297
+
298
+ @property
299
+ def reads(self) -> tuple[CanaryRead, ...]:
300
+ return tuple(self._reads)
301
+
302
+ @property
303
+ def observations(self) -> tuple[NetObservation, ...]:
304
+ return tuple(self._observations)
305
+
306
+ @property
307
+ def results(self) -> tuple[Correlation, ...]:
308
+ return tuple(self._results)
309
+
310
+ @property
311
+ def confirmed(self) -> bool:
312
+ return bool(self._results)
313
+
314
+ def record_canary_read(self, event: MonitorEvent) -> CanaryRead | None:
315
+ if event.event_type != "canary_read" or not event.path_or_channel:
316
+ return None
317
+ path = event.path_or_channel
318
+ asset = "generic"
319
+ marker = event.marker or self.canaries.marker
320
+ if path.startswith("env:"):
321
+ key = path.removeprefix("env:")
322
+ asset = self.canaries.env_assets.get(key, "generic")
323
+ marker = self.canaries.marker
324
+ else:
325
+ file = self.canaries.file_for(path)
326
+ if file is not None:
327
+ asset = file.asset
328
+ marker = file.marker
329
+ implicit = bool(event.detail.get("implicit"))
330
+ read = CanaryRead(
331
+ ts=event.ts,
332
+ path=self.canaries.display_path(path),
333
+ asset=asset,
334
+ marker=marker,
335
+ process=event.process,
336
+ implicit=implicit,
337
+ )
338
+ self._reads.append(read)
339
+ return read
340
+
341
+ def record_implicit_env_reads(self, *, ts: datetime | None = None) -> list[CanaryRead]:
342
+ """Treat injected env canaries as exposed at process start.
343
+
344
+ Shell expansion of ``$SLACK_BOT_TOKEN`` is not observable through
345
+ ``getenv``, so the environment itself is the exposure event. The
346
+ within-window and marker-match requirements still apply to Event B.
347
+ """
348
+
349
+ moment = ts or utc_now()
350
+ reads: list[CanaryRead] = []
351
+ for key in self.canaries.env_canary_keys:
352
+ event = MonitorEvent(
353
+ ts=moment,
354
+ event_type="canary_read",
355
+ path_or_channel=f"env:{key}",
356
+ marker=self.canaries.marker,
357
+ detail={"implicit": True, "channel": "env"},
358
+ )
359
+ read = self.record_canary_read(event)
360
+ if read is not None:
361
+ reads.append(read)
362
+ return reads
363
+
364
+ def _matching_reads(self, observation: NetObservation) -> list[CanaryRead]:
365
+ lower = observation.ts - timedelta(seconds=self.window_s)
366
+ upper = observation.ts + timedelta(seconds=self.clock_skew_s)
367
+ return [read for read in self._reads if lower <= read.ts <= upper]
368
+
369
+ def observe(
370
+ self,
371
+ *,
372
+ protocol: Literal["dns", "http"],
373
+ query_or_url: str,
374
+ body: bytes = b"",
375
+ headers: dict[str, str] | None = None,
376
+ ts: datetime | None = None,
377
+ client: str | None = None,
378
+ method: str | None = None,
379
+ responder_action: str | None = None,
380
+ ) -> Correlation | None:
381
+ observation = NetObservation(
382
+ ts=ts or utc_now(),
383
+ protocol=protocol,
384
+ query_or_url=query_or_url,
385
+ body=body,
386
+ headers=headers or {},
387
+ client=client,
388
+ method=method,
389
+ responder_action=responder_action,
390
+ )
391
+ self._observations.append(observation)
392
+ reads = self._matching_reads(observation)
393
+ if not reads:
394
+ return None
395
+ marker, encoding, preview, matched_text = _find_marker(observation, self.canaries)
396
+ if marker is None or matched_text is None:
397
+ return None
398
+ targeted = tuple(_filter_reads(reads, matched_text))
399
+ c2 = _c2_from_observation(observation)
400
+ result = Correlation(
401
+ matched=True,
402
+ reads=targeted,
403
+ observation=observation,
404
+ marker=marker,
405
+ encoding=encoding,
406
+ c2_indicators=c2,
407
+ decoded_preview=preview,
408
+ confidence=1.0,
409
+ )
410
+ key = (
411
+ marker,
412
+ observation.protocol,
413
+ hashlib.sha256(observation.query_or_url.encode("utf-8")).hexdigest(),
414
+ )
415
+ if key not in self._seen:
416
+ self._seen.add(key)
417
+ self._results.append(result)
418
+ return result
419
+
420
+ def observe_dns(
421
+ self,
422
+ qname: str,
423
+ *,
424
+ ts: datetime | None = None,
425
+ client: str | None = None,
426
+ responder_action: str | None = None,
427
+ ) -> Correlation | None:
428
+ return self.observe(
429
+ protocol="dns",
430
+ query_or_url=qname,
431
+ ts=ts,
432
+ client=client,
433
+ responder_action=responder_action,
434
+ )
435
+
436
+ def observe_http(
437
+ self,
438
+ method: str,
439
+ url: str,
440
+ *,
441
+ body: bytes = b"",
442
+ headers: dict[str, str] | None = None,
443
+ ts: datetime | None = None,
444
+ client: str | None = None,
445
+ responder_action: str | None = None,
446
+ ) -> Correlation | None:
447
+ return self.observe(
448
+ protocol="http",
449
+ query_or_url=url,
450
+ body=body,
451
+ headers=headers,
452
+ ts=ts,
453
+ client=client,
454
+ method=method,
455
+ responder_action=responder_action,
456
+ )
457
+
458
+ def summary(self) -> dict[str, Any]:
459
+ result = self._results[-1] if self._results else None
460
+ return {
461
+ "confirmed": result is not None,
462
+ "marker": result.marker if result else self.canaries.marker,
463
+ "encoding": result.encoding if result else None,
464
+ "channel": result.channel.value if result else None,
465
+ "targetedAssets": list(result.targeted_assets) if result else [],
466
+ "c2Indicators": list(result.c2_indicators) if result else [],
467
+ "readCount": len(self._reads),
468
+ "observationCount": len(self._observations),
469
+ "lastObservedAt": format_utc(self._observations[-1].ts) if self._observations else None,
470
+ }
@@ -0,0 +1,114 @@
1
+ """Report rendering and on-disk persistence (I5).
2
+
3
+ Files live under ``~/.skillsentry/reports/<scan_id>/`` (docs/03 §3.1).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from skill_sentry_cli.core.config import reports_dir
14
+ from skill_sentry_cli.core.contracts import ExportFormat, StageResult, Verdict
15
+ from skill_sentry_cli.core.decisions.report import decision_summary
16
+ from skill_sentry_cli.core.verdict.markdown_report import build_markdown
17
+ from skill_sentry_cli.core.verdict.sarif_report import build_sarif
18
+ from skill_sentry_cli.core.verdict.stix_report import NoIOCsError, build_stix
19
+
20
+ MEDIA_TYPES: dict[ExportFormat, str] = {
21
+ ExportFormat.SARIF: "application/sarif+json",
22
+ ExportFormat.MARKDOWN: "text/markdown; charset=utf-8",
23
+ ExportFormat.STIX: "application/stix+json;version=2.1",
24
+ }
25
+
26
+ FILENAMES: dict[ExportFormat, str] = {
27
+ ExportFormat.SARIF: "report.sarif",
28
+ ExportFormat.MARKDOWN: "report.md",
29
+ ExportFormat.STIX: "stix-bundle.json",
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RenderedReport:
35
+ filename: str
36
+ media_type: str
37
+ content: bytes
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ReportFiles:
42
+ """On-disk report paths (never sent on the wire; use ``ReportLinks``)."""
43
+
44
+ sarif: Path
45
+ markdown: Path
46
+ stix: Path | None = None
47
+
48
+
49
+ def render_report(
50
+ verdict: Verdict,
51
+ fmt: ExportFormat,
52
+ *,
53
+ stage_results: Sequence[StageResult] | None = None,
54
+ ) -> RenderedReport:
55
+ """Render one report in memory. Raises ``NoIOCsError`` for empty STIX.
56
+
57
+ Pass ``stage_results`` to include the decision-layer summary. Without them
58
+ the report states that the decision layer was not recorded.
59
+ """
60
+ decision = decision_summary(stage_results) if stage_results is not None else None
61
+ if fmt is ExportFormat.SARIF:
62
+ content = json.dumps(build_sarif(verdict, decision=decision), indent=2) + "\n"
63
+ elif fmt is ExportFormat.MARKDOWN:
64
+ content = build_markdown(verdict, decision=decision, stage_results=stage_results)
65
+ elif fmt is ExportFormat.STIX:
66
+ content = json.dumps(build_stix(verdict), indent=2) + "\n"
67
+ else: # pragma: no cover - enum exhaustiveness
68
+ raise ValueError(f"unsupported report format: {fmt}")
69
+ return RenderedReport(
70
+ filename=FILENAMES[fmt],
71
+ media_type=MEDIA_TYPES[fmt],
72
+ content=content.encode("utf-8"),
73
+ )
74
+
75
+
76
+ def write_report_files(
77
+ verdict: Verdict,
78
+ *,
79
+ root: str | Path | None = None,
80
+ stage_results: Sequence[StageResult] | None = None,
81
+ ) -> ReportFiles:
82
+ """Write SARIF + Markdown (+ STIX when triggered) and return file paths."""
83
+ target = Path(root) if root is not None else reports_dir(str(verdict.scan_id))
84
+ target.mkdir(parents=True, exist_ok=True)
85
+
86
+ sarif = render_report(verdict, ExportFormat.SARIF, stage_results=stage_results)
87
+ markdown = render_report(verdict, ExportFormat.MARKDOWN, stage_results=stage_results)
88
+ (target / sarif.filename).write_bytes(sarif.content)
89
+ (target / markdown.filename).write_bytes(markdown.content)
90
+
91
+ stix_path: Path | None = None
92
+ try:
93
+ stix = render_report(verdict, ExportFormat.STIX)
94
+ except NoIOCsError:
95
+ stix = None
96
+ if stix is not None:
97
+ stix_path = target / stix.filename
98
+ stix_path.write_bytes(stix.content)
99
+
100
+ return ReportFiles(
101
+ sarif=target / sarif.filename,
102
+ markdown=target / markdown.filename,
103
+ stix=stix_path,
104
+ )
105
+
106
+
107
+ __all__ = [
108
+ "FILENAMES",
109
+ "MEDIA_TYPES",
110
+ "RenderedReport",
111
+ "ReportFiles",
112
+ "render_report",
113
+ "write_report_files",
114
+ ]
@@ -0,0 +1,74 @@
1
+ """Ephemeral Docker sandbox, masquerade, canary filesystem, and sinkholes.
2
+
3
+ Public objects are resolved lazily so importing a narrow helper (for example
4
+ ``skill_sentry_cli.core.sandbox.monitor``) never pulls in the Docker SDK or creates
5
+ an import cycle with the Poisoned Well package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ from typing import Any
12
+
13
+ __all__ = [
14
+ "CanarySet",
15
+ "DnsQuery",
16
+ "DnsSinkhole",
17
+ "FakeHttpResponder",
18
+ "HttpObservation",
19
+ "MasqueradeCheck",
20
+ "MasqueradeConfig",
21
+ "MasqueradeError",
22
+ "MasqueradeUnavailable",
23
+ "MonitorEvent",
24
+ "SandboxConfig",
25
+ "SandboxError",
26
+ "SandboxRequest",
27
+ "SandboxResult",
28
+ "SandboxRunner",
29
+ "SandboxUnavailable",
30
+ "SinkholeConfig",
31
+ "SinkholeObservation",
32
+ "SinkholeSidecar",
33
+ "build_canary_tar",
34
+ "build_honeypot",
35
+ "runtime_environment",
36
+ ]
37
+
38
+ _LAZY: dict[str, tuple[str, str]] = {
39
+ "CanarySet": ("skill_sentry_cli.core.poisoned_well.canary_gen", "CanarySet"),
40
+ "DnsSinkhole": ("skill_sentry_cli.core.sandbox.dns_sinkhole", "DnsSinkhole"),
41
+ "DnsQuery": ("skill_sentry_cli.core.sandbox.dns_sinkhole", "DnsQuery"),
42
+ "FakeHttpResponder": ("skill_sentry_cli.core.sandbox.fake_http", "FakeHttpResponder"),
43
+ "HttpObservation": ("skill_sentry_cli.core.sandbox.fake_http", "HttpObservation"),
44
+ "MasqueradeCheck": ("skill_sentry_cli.core.sandbox.masquerade", "MasqueradeCheck"),
45
+ "MasqueradeConfig": ("skill_sentry_cli.core.sandbox.masquerade", "MasqueradeConfig"),
46
+ "MasqueradeError": ("skill_sentry_cli.core.sandbox.masquerade", "MasqueradeError"),
47
+ "MasqueradeUnavailable": ("skill_sentry_cli.core.sandbox.masquerade", "MasqueradeUnavailable"),
48
+ "MonitorEvent": ("skill_sentry_cli.core.sandbox.monitor", "MonitorEvent"),
49
+ "SandboxConfig": ("skill_sentry_cli.core.sandbox.runner", "SandboxConfig"),
50
+ "SandboxError": ("skill_sentry_cli.core.sandbox.runner", "SandboxError"),
51
+ "SandboxRequest": ("skill_sentry_cli.core.sandbox.runner", "SandboxRequest"),
52
+ "SandboxResult": ("skill_sentry_cli.core.sandbox.runner", "SandboxResult"),
53
+ "SandboxRunner": ("skill_sentry_cli.core.sandbox.runner", "SandboxRunner"),
54
+ "SandboxUnavailable": ("skill_sentry_cli.core.sandbox.runner", "SandboxUnavailable"),
55
+ "SinkholeConfig": ("skill_sentry_cli.core.sandbox.sidecar", "SinkholeConfig"),
56
+ "SinkholeObservation": ("skill_sentry_cli.core.sandbox.sidecar", "SinkholeObservation"),
57
+ "SinkholeSidecar": ("skill_sentry_cli.core.sandbox.sidecar", "SinkholeSidecar"),
58
+ "build_canary_tar": ("skill_sentry_cli.core.sandbox.canary_fs", "build_canary_tar"),
59
+ "build_honeypot": ("skill_sentry_cli.core.sandbox.context_honeypot", "build_honeypot"),
60
+ "runtime_environment": (
61
+ "skill_sentry_cli.core.sandbox.context_honeypot",
62
+ "runtime_environment",
63
+ ),
64
+ }
65
+
66
+
67
+ def __getattr__(name: str) -> Any:
68
+ target = _LAZY.get(name)
69
+ if target is None:
70
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
71
+ module_name, attribute = target
72
+ value = getattr(importlib.import_module(module_name), attribute)
73
+ globals()[name] = value
74
+ return value
@@ -0,0 +1,39 @@
1
+ # SkillSentry sandbox image. Build from the repository root:
2
+ # docker build -f skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox \
3
+ # -t skillsentry-sandbox:local skill_sentry_cli/core/sandbox/assets
4
+ # Pinned by digest so a rebuild uses the same base image. To refresh: pull the tag,
5
+ # read its digest with `docker image inspect`, and replace the digest here.
6
+ FROM python:3.11-slim@sha256:673719e071ecf27434fd2635bbd8d00f49f4fdd81f243143438a42ced8bedf60 AS build
7
+
8
+ RUN apt-get update \
9
+ && apt-get install -y --no-install-recommends gcc libc6-dev \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ COPY masquerade.c /src/masquerade.c
13
+ RUN gcc -shared -fPIC -O2 -Wall -Wextra -o /src/masquerade.so /src/masquerade.c -ldl
14
+
15
+ FROM python:3.11-slim@sha256:673719e071ecf27434fd2635bbd8d00f49f4fdd81f243143438a42ced8bedf60
16
+
17
+ # Fixtures and real skills commonly use curl/dig for declared network access,
18
+ # git for local repository helpers, and Pillow for image tools. Installing them makes the sandbox
19
+ # representative; the internal sinkhole network still prevents any upstream
20
+ # connection.
21
+ RUN apt-get update \
22
+ && apt-get install -y --no-install-recommends curl dnsutils git ca-certificates \
23
+ && rm -rf /var/lib/apt/lists/* \
24
+ && pip install --no-cache-dir pillow==13.0.0 \
25
+ && mkdir -p /opt/skillsentry /home/sandbox \
26
+ && chown -R 65534:65534 /home/sandbox
27
+ COPY --from=build /src/masquerade.so /opt/skillsentry/masquerade.so
28
+ COPY run_skill.sh /opt/skillsentry/run_skill.sh
29
+ RUN chmod 0755 /opt/skillsentry/run_skill.sh \
30
+ && chmod 0644 /opt/skillsentry/masquerade.so
31
+
32
+ LABEL org.opencontainers.image.title="SkillSentry sandbox" \
33
+ org.opencontainers.image.description="Ephemeral skill execution sandbox with masquerade shim" \
34
+ skillsentry.role="sandbox"
35
+
36
+ ENV HOME=/home/sandbox
37
+ WORKDIR /skill
38
+ USER 65534:65534
39
+ ENTRYPOINT ["/opt/skillsentry/run_skill.sh"]