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,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"]
|