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,327 @@
1
+ """Per-scan sinkhole sidecar attached to the isolated Docker network.
2
+
3
+ The sidecar is the only component that receives sandbox egress. It is a
4
+ separate container on an ``internal`` bridge (no NAT), with a static address
5
+ used as the sandbox's DNS resolver. DNS A answers point back at the sidecar;
6
+ HTTP requests get an inert 200 response. Observations are emitted as JSON
7
+ lines on stdout and collected before teardown.
8
+
9
+ TLS is intentionally not intercepted in M1. A self-signed certificate would
10
+ require installing a trust anchor inside the skill image, which changes the
11
+ threat model; HTTPS fixtures must use HTTP or remain explicitly unverified.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import contextlib
18
+ import io
19
+ import json
20
+ import re
21
+ import tarfile
22
+ import threading
23
+ import time
24
+ from dataclasses import dataclass, field
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+ from typing import Any, Protocol
28
+ from uuid import UUID
29
+
30
+ MAX_SIDECAR_LOG_BYTES = 8 * 1024 * 1024
31
+ MAX_SIDECAR_EVENTS = 10_000
32
+ MAX_SIDECAR_TAIL_LINES = 10_000
33
+ _SIDECAR_SCRIPT_PATH = Path(__file__).resolve().parent / "assets" / "sinkhole_sidecar.py"
34
+ _TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T")
35
+ _READY_MARKER = b'"event":"ready"'
36
+
37
+
38
+ class SidecarError(RuntimeError):
39
+ """The sinkhole sidecar could not be started or its evidence is unusable."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SinkholeObservation:
44
+ ts: datetime
45
+ protocol: str
46
+ query_or_url: str
47
+ body: bytes = b""
48
+ headers: dict[str, str] = field(default_factory=dict)
49
+ client: str = ""
50
+ responder_action: str = "fake"
51
+
52
+ @property
53
+ def body_b64(self) -> str | None:
54
+ if not self.body:
55
+ return None
56
+ return base64.b64encode(self.body).decode("ascii")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class SinkholeConfig:
61
+ image: str = "python:3.11-slim"
62
+ ip: str = "172.30.77.2"
63
+ subnet: str = "172.30.77.0/24"
64
+ dns_port: int = 53
65
+ http_port: int = 80
66
+ timeout_s: float = 15.0
67
+
68
+
69
+ class SidecarController(Protocol):
70
+ @property
71
+ def ip(self) -> str: ...
72
+
73
+ def start(self, client: Any, network: Any, scan_id: UUID) -> SinkholeSidecar: ...
74
+
75
+
76
+ class SinkholeSidecar:
77
+ """Docker lifecycle plus observation parsing for one scan."""
78
+
79
+ def __init__(self, client: Any, config: SinkholeConfig | None = None) -> None:
80
+ self.client = client
81
+ self.config = config or SinkholeConfig()
82
+ self.container: Any | None = None
83
+ self._ip = self.config.ip
84
+ self._lock = threading.RLock()
85
+
86
+ @property
87
+ def ip(self) -> str:
88
+ return self._ip
89
+
90
+ def start(self, network: Any, scan_id: UUID, *, image: str | None = None) -> SinkholeSidecar:
91
+ # A sidecar is per-scan state. Serialise lifecycle calls so a
92
+ # concurrent teardown cannot remove a container while it is starting.
93
+ with self._lock:
94
+ if self.container is not None:
95
+ return self
96
+ network_name = getattr(network, "name", None) or str(network)
97
+ labels = {
98
+ "skillsentry": "true",
99
+ "skillsentry.role": "sinkhole-sidecar",
100
+ "skillsentry.scan_id": str(scan_id),
101
+ }
102
+ container = None
103
+ try:
104
+ container = self.client.containers.create(
105
+ image=image or self.config.image,
106
+ # Keep the script outside the /tmp tmpfs: files copied into
107
+ # a tmpfs-backed path are hidden when the container starts.
108
+ command=["python", "-u", "/opt/skillsentry_sidecar.py"],
109
+ detach=True,
110
+ # The rootfs must be writable long enough for put_archive()
111
+ # to copy the trusted sidecar script before start; Docker's
112
+ # archive endpoint rejects a read-only rootfs. /tmp is still
113
+ # a bounded tmpfs and all capabilities are dropped below.
114
+ read_only=False,
115
+ user="0:0",
116
+ working_dir="/tmp",
117
+ tmpfs={"/tmp": "rw,nosuid,nodev,noexec,size=16m,mode=1777"},
118
+ cap_drop=["ALL"],
119
+ cap_add=["NET_BIND_SERVICE"],
120
+ security_opt=["no-new-privileges"],
121
+ environment={
122
+ "SKILLSENTRY_SIDECAR_IP": self._ip,
123
+ "SKILLSENTRY_DNS_PORT": str(self.config.dns_port),
124
+ "SKILLSENTRY_HTTP_PORT": str(self.config.http_port),
125
+ },
126
+ labels=labels,
127
+ auto_remove=False,
128
+ network=network_name,
129
+ networking_config={
130
+ "EndpointsConfig": {network_name: {"IPAMConfig": {"IPv4Address": self._ip}}}
131
+ },
132
+ # The sidecar only needs enough memory for two small
133
+ # stdlib servers. Cap it independently from the skill so
134
+ # a compromised sidecar cannot exhaust the host.
135
+ mem_limit="64m",
136
+ memswap_limit="64m",
137
+ pids_limit=32,
138
+ nano_cpus=250_000_000,
139
+ )
140
+ self.container = container
141
+ script = _SIDECAR_SCRIPT_PATH.read_bytes()
142
+ buffer = io.BytesIO()
143
+ with tarfile.open(fileobj=buffer, mode="w") as archive:
144
+ info = tarfile.TarInfo("skillsentry_sidecar.py")
145
+ info.size = len(script)
146
+ info.mode = 0o755
147
+ info.uid = 0
148
+ info.gid = 0
149
+ info.mtime = 0
150
+ archive.addfile(info, io.BytesIO(script))
151
+ # Copy the trusted script into the stopped container before it
152
+ # can execute anything. No host mount is exposed.
153
+ if container.put_archive("/opt", buffer.getvalue()) is False:
154
+ raise SidecarError("could not copy sidecar script into container")
155
+ self.container = container
156
+ container.start()
157
+ self._wait_until_ready(container)
158
+ return self
159
+ except Exception as exc:
160
+ self.stop(collect=False)
161
+ if isinstance(exc, SidecarError):
162
+ raise
163
+ raise SidecarError(f"could not start sinkhole sidecar: {exc}") from exc
164
+
165
+ def _wait_until_ready(self, container: Any) -> None:
166
+ """Wait for both DNS and HTTP listeners to report readiness.
167
+
168
+ Docker marks a container ``running`` before the Python process has
169
+ bound its sockets. Starting the skill at that point produces an
170
+ evidence hole (the first exfil attempt gets connection refused). The
171
+ sidecar therefore emits one ``{"event":"ready"}`` line after binding,
172
+ and the runner waits for it with a hard deadline.
173
+ """
174
+
175
+ deadline = time.monotonic() + max(0.1, self.config.timeout_s)
176
+ while time.monotonic() < deadline:
177
+ status = ""
178
+ try:
179
+ reload = getattr(container, "reload", None)
180
+ if reload is not None:
181
+ reload()
182
+ status = str(getattr(container, "status", ""))
183
+ except Exception:
184
+ status = ""
185
+ if status in {"exited", "dead"}:
186
+ raise SidecarError(f"sinkhole sidecar exited during startup ({status})")
187
+ logs = _read_logs(container, tail=64)
188
+ if _READY_MARKER in logs:
189
+ return
190
+ time.sleep(0.05)
191
+ raise SidecarError(
192
+ f"sinkhole sidecar did not become ready within {self.config.timeout_s:.1f}s"
193
+ )
194
+
195
+ def stop(self, *, collect: bool = True) -> list[SinkholeObservation]:
196
+ observations: list[SinkholeObservation] = []
197
+ with self._lock:
198
+ container = self.container
199
+ self.container = None
200
+ if container is None:
201
+ return observations
202
+ # Stop first so a log stream terminates promptly and a hostile
203
+ # sidecar cannot keep writing while evidence is collected.
204
+ with contextlib.suppress(Exception):
205
+ container.stop(timeout=1)
206
+ if collect:
207
+ try:
208
+ raw = _read_logs(container, stream=True)
209
+ observations = parse_sidecar_log(raw)
210
+ except Exception:
211
+ observations = []
212
+ with contextlib.suppress(Exception):
213
+ container.remove(force=True, v=True)
214
+ return observations
215
+
216
+
217
+ def _read_logs(
218
+ container: Any,
219
+ *,
220
+ tail: int | None = None,
221
+ stream: bool = False,
222
+ limit: int = MAX_SIDECAR_LOG_BYTES,
223
+ ) -> bytes:
224
+ """Read Docker logs without allowing an unbounded response into memory."""
225
+
226
+ kwargs: dict[str, Any] = {"stdout": True, "stderr": False}
227
+ if tail is not None:
228
+ kwargs["tail"] = max(1, min(int(tail), MAX_SIDECAR_TAIL_LINES))
229
+ if stream:
230
+ kwargs["stream"] = True
231
+ try:
232
+ raw = container.logs(**kwargs)
233
+ except TypeError:
234
+ # A minimal fake client may not implement stream/tail kwargs.
235
+ raw = container.logs(stdout=True, stderr=False)
236
+ if isinstance(raw, (bytes, bytearray)):
237
+ return bytes(raw)[-limit:]
238
+ if isinstance(raw, str):
239
+ return raw.encode("utf-8", errors="replace")[-limit:]
240
+ chunks: list[bytes] = []
241
+ total = 0
242
+ try:
243
+ for chunk in raw:
244
+ if isinstance(chunk, str):
245
+ chunk = chunk.encode("utf-8", errors="replace")
246
+ if not isinstance(chunk, (bytes, bytearray)):
247
+ continue
248
+ chunks.append(bytes(chunk))
249
+ total += len(chunk)
250
+ if total >= limit:
251
+ break
252
+ finally:
253
+ close = getattr(raw, "close", None)
254
+ if close is not None:
255
+ with contextlib.suppress(Exception):
256
+ close()
257
+ return b"".join(chunks)[-limit:]
258
+
259
+
260
+ def parse_sidecar_log(raw: bytes | str) -> list[SinkholeObservation]:
261
+ if isinstance(raw, bytes):
262
+ raw = raw[-MAX_SIDECAR_LOG_BYTES:].decode("utf-8", errors="replace")
263
+ elif len(raw) > MAX_SIDECAR_LOG_BYTES:
264
+ raw = raw[-MAX_SIDECAR_LOG_BYTES:]
265
+ observations: list[SinkholeObservation] = []
266
+ for line in raw.splitlines():
267
+ line = line.strip()
268
+ if not line.startswith("{"):
269
+ # Docker may prefix non-TTY streams with a multiplexing header.
270
+ brace = line.find("{")
271
+ if brace == -1:
272
+ continue
273
+ line = line[brace:]
274
+ try:
275
+ payload = json.loads(line)
276
+ except (TypeError, ValueError):
277
+ continue
278
+ if not isinstance(payload, dict):
279
+ continue
280
+ protocol = payload.get("protocol")
281
+ url = payload.get("query_or_url")
282
+ if protocol not in {"dns", "http"} or not isinstance(url, str) or not url:
283
+ continue
284
+ ts_raw = payload.get("ts")
285
+ try:
286
+ ts = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
287
+ if ts.tzinfo is None:
288
+ ts = ts.replace(tzinfo=UTC)
289
+ except (TypeError, ValueError):
290
+ continue
291
+ body_b64 = payload.get("body_b64")
292
+ body = b""
293
+ if isinstance(body_b64, str):
294
+ try:
295
+ body = base64.b64decode(body_b64, validate=False)[:2_000_000]
296
+ except (ValueError, TypeError):
297
+ body = b""
298
+ headers = payload.get("headers")
299
+ observations.append(
300
+ SinkholeObservation(
301
+ ts=ts,
302
+ protocol=protocol,
303
+ query_or_url=url[:4096],
304
+ body=body,
305
+ headers=(
306
+ {str(k)[:100]: str(v)[:1000] for k, v in headers.items()}
307
+ if isinstance(headers, dict)
308
+ else {}
309
+ ),
310
+ client=str(payload.get("client") or "")[:200],
311
+ responder_action=str(payload.get("responder_action") or "fake")[:200],
312
+ )
313
+ )
314
+ if len(observations) >= MAX_SIDECAR_EVENTS:
315
+ break
316
+ return observations
317
+
318
+
319
+ __all__ = [
320
+ "MAX_SIDECAR_EVENTS",
321
+ "SidecarController",
322
+ "SidecarError",
323
+ "SinkholeConfig",
324
+ "SinkholeObservation",
325
+ "SinkholeSidecar",
326
+ "parse_sidecar_log",
327
+ ]
@@ -0,0 +1,9 @@
1
+ """Deterministic anchors and semantic analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from skill_sentry_cli.core.semantic.analyzer import analyze_semantics
6
+ from skill_sentry_cli.core.semantic.ast_anchor import AnchorRule, scan_anchors
7
+ from skill_sentry_cli.core.semantic.prompts import SEMANTIC_SCHEMA, SYSTEM_PROMPT
8
+
9
+ __all__ = ["SEMANTIC_SCHEMA", "SYSTEM_PROMPT", "AnchorRule", "analyze_semantics", "scan_anchors"]