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,108 @@
|
|
|
1
|
+
"""Materialise canary files into a tar stream or a local test directory.
|
|
2
|
+
|
|
3
|
+
The production path never writes fake credentials to the host: the tar stream is
|
|
4
|
+
uploaded into the container with ``put_archive`` after its tmpfs HOME exists.
|
|
5
|
+
``materialise_directory`` exists for unit tests and local debugging only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import io
|
|
12
|
+
import tarfile
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
|
|
15
|
+
from skill_sentry_cli.core.poisoned_well.canary_gen import CANARY_ROOT, CanaryFile, CanarySet
|
|
16
|
+
|
|
17
|
+
_NOBODY = 65534
|
|
18
|
+
_NOGROUP = 65534
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_relative_path(relative_path: str) -> str:
|
|
22
|
+
path = PurePosixPath(relative_path)
|
|
23
|
+
if path.is_absolute() or not path.parts:
|
|
24
|
+
raise ValueError(f"canary path must be relative: {relative_path!r}")
|
|
25
|
+
if any(part in {"", ".", ".."} for part in path.parts):
|
|
26
|
+
raise ValueError(f"unsafe canary path: {relative_path!r}")
|
|
27
|
+
if "\\" in relative_path:
|
|
28
|
+
raise ValueError(f"canary path must use forward slashes: {relative_path!r}")
|
|
29
|
+
return path.as_posix()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tarinfo(name: str, *, mode: int, is_dir: bool) -> tarfile.TarInfo:
|
|
33
|
+
info = tarfile.TarInfo(name=name)
|
|
34
|
+
info.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE
|
|
35
|
+
info.mode = mode
|
|
36
|
+
info.uid = _NOBODY
|
|
37
|
+
info.gid = _NOGROUP
|
|
38
|
+
info.uname = "nobody"
|
|
39
|
+
info.gname = "nogroup"
|
|
40
|
+
info.mtime = 0
|
|
41
|
+
return info
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_canary_tar(canary_set: CanarySet) -> bytes:
|
|
45
|
+
"""Return a deterministic tar archive rooted at ``CANARY_ROOT``.
|
|
46
|
+
|
|
47
|
+
Directory entries are created explicitly so extraction does not depend on
|
|
48
|
+
the destination's umask. Files are regular files only; no symlinks or
|
|
49
|
+
device nodes are ever emitted.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
buffer = io.BytesIO()
|
|
53
|
+
directories: set[str] = set()
|
|
54
|
+
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
|
55
|
+
for canary in canary_set.files:
|
|
56
|
+
rel = validate_relative_path(canary.relative_path)
|
|
57
|
+
parent = PurePosixPath(rel).parent
|
|
58
|
+
if parent != PurePosixPath("."):
|
|
59
|
+
parts = parent.parts
|
|
60
|
+
for index in range(1, len(parts) + 1):
|
|
61
|
+
directory = PurePosixPath(*parts[:index]).as_posix()
|
|
62
|
+
if directory in directories:
|
|
63
|
+
continue
|
|
64
|
+
directories.add(directory)
|
|
65
|
+
info = _tarinfo(f"./{directory}", mode=0o700, is_dir=True)
|
|
66
|
+
archive.addfile(info)
|
|
67
|
+
|
|
68
|
+
payload = canary.content.encode("utf-8")
|
|
69
|
+
info = _tarinfo(f"./{rel}", mode=canary.mode, is_dir=False)
|
|
70
|
+
info.size = len(payload)
|
|
71
|
+
archive.addfile(info, io.BytesIO(payload))
|
|
72
|
+
|
|
73
|
+
return buffer.getvalue()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def materialise_directory(root: str | Path, canary_set: CanarySet) -> list[Path]:
|
|
77
|
+
"""Write canary files under *root* (tests/local debugging only).
|
|
78
|
+
|
|
79
|
+
The caller must pass an explicit scratch directory; this function never
|
|
80
|
+
touches a user's real home directory.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
base = Path(root)
|
|
84
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
written: list[Path] = []
|
|
86
|
+
for canary in canary_set.files:
|
|
87
|
+
rel = validate_relative_path(canary.relative_path)
|
|
88
|
+
target = base / rel
|
|
89
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
target.write_text(canary.content, encoding="utf-8")
|
|
91
|
+
# Windows test hosts may not implement POSIX modes; content and layout
|
|
92
|
+
# are still verified. The container path uses tar metadata.
|
|
93
|
+
with contextlib.suppress(OSError):
|
|
94
|
+
target.chmod(canary.mode)
|
|
95
|
+
written.append(target)
|
|
96
|
+
return written
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def canary_paths(canary_set: CanarySet) -> list[str]:
|
|
100
|
+
"""Container-absolute paths for monitor event mapping."""
|
|
101
|
+
|
|
102
|
+
return [
|
|
103
|
+
f"{CANARY_ROOT}/{validate_relative_path(item.relative_path)}" for item in canary_set.files
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def canary_for_path(canary_set: CanarySet, path: str) -> CanaryFile | None:
|
|
108
|
+
return canary_set.file_for(path)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Intent-aware context honeypot.
|
|
2
|
+
|
|
3
|
+
The honeypot answers one question: *what would this skill plausibly be able to
|
|
4
|
+
steal?* A Slack skill gets a Slack token and generic AWS/SSH material; an
|
|
5
|
+
unknown skill gets the full set. Seeding is delegated to
|
|
6
|
+
:mod:`skill_sentry_cli.core.poisoned_well.canary_gen` so the marker lifecycle has a
|
|
7
|
+
single owner.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
from skill_sentry_cli.core.contracts import ContextCanary
|
|
17
|
+
from skill_sentry_cli.core.poisoned_well.canary_gen import (
|
|
18
|
+
CANARY_ROOT,
|
|
19
|
+
CanarySet,
|
|
20
|
+
generate_canary_set,
|
|
21
|
+
normalize_intent,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
INTENT_KEYWORDS: Mapping[ContextCanary, tuple[str, ...]] = {
|
|
25
|
+
ContextCanary.AWS: ("aws", "amazon", "s3", "ec2", "iam", "cloud", "credential"),
|
|
26
|
+
ContextCanary.GITHUB: ("github", "git", "repository", "repo", "pull request"),
|
|
27
|
+
ContextCanary.SLACK: ("slack", "webhook", "notification", "chat", "workspace"),
|
|
28
|
+
ContextCanary.SSH: ("ssh", "devops", "deploy", "server", "remote", "ansible"),
|
|
29
|
+
ContextCanary.GENERIC: (),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class HoneypotPlan:
|
|
35
|
+
"""Small, serialisable summary of what the honeypot will seed."""
|
|
36
|
+
|
|
37
|
+
intent: ContextCanary
|
|
38
|
+
declared_intent: str
|
|
39
|
+
paths: tuple[str, ...]
|
|
40
|
+
env_keys: tuple[str, ...]
|
|
41
|
+
marker: str
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_canaries(cls, canaries: CanarySet) -> HoneypotPlan:
|
|
45
|
+
return cls(
|
|
46
|
+
intent=canaries.intent,
|
|
47
|
+
declared_intent=canaries.declared_intent,
|
|
48
|
+
paths=tuple(item.display_path for item in canaries.files),
|
|
49
|
+
env_keys=tuple(sorted(canaries.env)),
|
|
50
|
+
marker=canaries.marker,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def classify(declared_intent: str | None) -> ContextCanary:
|
|
55
|
+
return normalize_intent(declared_intent)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_honeypot(
|
|
59
|
+
scan_id: UUID,
|
|
60
|
+
declared_intent: str | None,
|
|
61
|
+
*,
|
|
62
|
+
marker: str | None = None,
|
|
63
|
+
secondary_marker: str | None = None,
|
|
64
|
+
) -> CanarySet:
|
|
65
|
+
return generate_canary_set(
|
|
66
|
+
scan_id,
|
|
67
|
+
declared_intent,
|
|
68
|
+
marker=marker,
|
|
69
|
+
secondary_marker=secondary_marker,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def runtime_environment(canaries: CanarySet) -> dict[str, str]:
|
|
74
|
+
"""Environment for the skill process (canaries + realistic HOME)."""
|
|
75
|
+
|
|
76
|
+
env = {
|
|
77
|
+
"HOME": CANARY_ROOT,
|
|
78
|
+
"USER": "nobody",
|
|
79
|
+
"LOGNAME": "nobody",
|
|
80
|
+
"SHELL": "/bin/sh",
|
|
81
|
+
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
|
82
|
+
}
|
|
83
|
+
env.update(canaries.env)
|
|
84
|
+
return env
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def plan(declared_intent: str | None) -> HoneypotPlan:
|
|
88
|
+
"""Preview the honeypot for a declared intent without consuming a marker."""
|
|
89
|
+
|
|
90
|
+
# A preview marker is deterministic and not used for correlation.
|
|
91
|
+
preview = generate_canary_set(
|
|
92
|
+
UUID(int=0),
|
|
93
|
+
declared_intent,
|
|
94
|
+
marker="SKS-00000000",
|
|
95
|
+
secondary_marker="SKS-ffffffff",
|
|
96
|
+
)
|
|
97
|
+
return HoneypotPlan.from_canaries(preview)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Minimal, dependency-free DNS sinkhole and query codec.
|
|
2
|
+
|
|
3
|
+
The sinkhole answers A queries with a loopback address and returns an empty
|
|
4
|
+
NOERROR response for other types. It never forwards a query upstream. The
|
|
5
|
+
default bind address is loopback; the Docker integration binds the sidecar on
|
|
6
|
+
the internal sinkhole network only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import contextlib
|
|
13
|
+
import ipaddress
|
|
14
|
+
import socket
|
|
15
|
+
import struct
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
from skill_sentry_cli.core.contracts import utc_now
|
|
22
|
+
|
|
23
|
+
_QTYPE_NAMES = {
|
|
24
|
+
1: "A",
|
|
25
|
+
2: "NS",
|
|
26
|
+
5: "CNAME",
|
|
27
|
+
6: "SOA",
|
|
28
|
+
12: "PTR",
|
|
29
|
+
15: "MX",
|
|
30
|
+
16: "TXT",
|
|
31
|
+
28: "AAAA",
|
|
32
|
+
33: "SRV",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class DnsQuery:
|
|
38
|
+
ts: datetime
|
|
39
|
+
qname: str
|
|
40
|
+
qtype: int
|
|
41
|
+
client: str
|
|
42
|
+
raw_b64: str
|
|
43
|
+
qclass: int = 1
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def qtype_name(self) -> str:
|
|
47
|
+
return _QTYPE_NAMES.get(self.qtype, f"TYPE{self.qtype}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_dns_query(
|
|
51
|
+
data: bytes, *, ts: datetime | None = None, client: str = ""
|
|
52
|
+
) -> DnsQuery | None:
|
|
53
|
+
if len(data) < 12:
|
|
54
|
+
return None
|
|
55
|
+
_ident, flags, qdcount, _ancount, _nscount, _arcount = struct.unpack("!HHHHHH", data[:12])
|
|
56
|
+
if flags & 0x8000 or qdcount < 1:
|
|
57
|
+
return None
|
|
58
|
+
offset = 12
|
|
59
|
+
labels: list[str] = []
|
|
60
|
+
while True:
|
|
61
|
+
if offset >= len(data):
|
|
62
|
+
return None
|
|
63
|
+
length = data[offset]
|
|
64
|
+
offset += 1
|
|
65
|
+
if length == 0:
|
|
66
|
+
break
|
|
67
|
+
if length & 0xC0:
|
|
68
|
+
# Compression pointers are legal in messages but not in the
|
|
69
|
+
# question section of a query; reject rather than risk a loop.
|
|
70
|
+
return None
|
|
71
|
+
if length > 63 or offset + length > len(data):
|
|
72
|
+
return None
|
|
73
|
+
labels.append(data[offset : offset + length].decode("ascii", errors="replace"))
|
|
74
|
+
offset += length
|
|
75
|
+
if offset + 4 > len(data):
|
|
76
|
+
return None
|
|
77
|
+
qtype, qclass = struct.unpack("!HH", data[offset : offset + 4])
|
|
78
|
+
qname = ".".join(labels) or "."
|
|
79
|
+
return DnsQuery(
|
|
80
|
+
ts=ts or utc_now(),
|
|
81
|
+
qname=qname,
|
|
82
|
+
qtype=qtype,
|
|
83
|
+
qclass=qclass,
|
|
84
|
+
client=client,
|
|
85
|
+
raw_b64=base64.b64encode(data).decode("ascii"),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _question_end(data: bytes) -> int | None:
|
|
90
|
+
if len(data) < 12:
|
|
91
|
+
return None
|
|
92
|
+
offset = 12
|
|
93
|
+
while offset < len(data):
|
|
94
|
+
length = data[offset]
|
|
95
|
+
offset += 1
|
|
96
|
+
if length == 0:
|
|
97
|
+
return offset + 4 if offset + 4 <= len(data) else None
|
|
98
|
+
if length & 0xC0 or offset + length > len(data):
|
|
99
|
+
return None
|
|
100
|
+
offset += length
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_dns_response(
|
|
105
|
+
query: bytes,
|
|
106
|
+
*,
|
|
107
|
+
answer_ip: str = "127.0.0.1",
|
|
108
|
+
rcode: int = 0,
|
|
109
|
+
) -> bytes:
|
|
110
|
+
if len(query) < 12:
|
|
111
|
+
raise ValueError("DNS query is shorter than its header")
|
|
112
|
+
ident = query[:2]
|
|
113
|
+
question_end = _question_end(query)
|
|
114
|
+
if question_end is None:
|
|
115
|
+
raise ValueError("DNS query has no complete question section")
|
|
116
|
+
qtype = struct.unpack("!H", query[question_end - 4 : question_end - 2])[0]
|
|
117
|
+
answer = b""
|
|
118
|
+
ancount = 0
|
|
119
|
+
if rcode == 0 and qtype == 1:
|
|
120
|
+
try:
|
|
121
|
+
packed_ip = ipaddress.IPv4Address(answer_ip).packed
|
|
122
|
+
except ipaddress.AddressValueError as exc:
|
|
123
|
+
raise ValueError(f"answer_ip must be an IPv4 address: {answer_ip!r}") from exc
|
|
124
|
+
answer = b"\xc0\x0c" + struct.pack("!HHIH", 1, 1, 60, 4) + packed_ip
|
|
125
|
+
ancount = 1
|
|
126
|
+
flags = 0x8000 | 0x0100 | 0x0080 | (rcode & 0xF) # QR, RD, RA
|
|
127
|
+
header = ident + struct.pack("!HHHHH", flags, 1, ancount, 0, 0)
|
|
128
|
+
return header + query[12:question_end] + answer
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DnsSinkhole:
|
|
132
|
+
"""Threaded UDP DNS responder that records every query it receives."""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
host: str = "127.0.0.1",
|
|
138
|
+
port: int = 0,
|
|
139
|
+
answer_ip: str = "127.0.0.1",
|
|
140
|
+
on_query: Callable[[DnsQuery], None] | None = None,
|
|
141
|
+
max_queries: int = 5000,
|
|
142
|
+
) -> None:
|
|
143
|
+
self.host = host
|
|
144
|
+
self.port = port
|
|
145
|
+
self.answer_ip = answer_ip
|
|
146
|
+
self.on_query = on_query
|
|
147
|
+
self.max_queries = max_queries
|
|
148
|
+
self._sock: socket.socket | None = None
|
|
149
|
+
self._thread: threading.Thread | None = None
|
|
150
|
+
self._stop = threading.Event()
|
|
151
|
+
self._lock = threading.Lock()
|
|
152
|
+
self._queries: list[DnsQuery] = []
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def address(self) -> tuple[str, int] | None:
|
|
156
|
+
if self._sock is None:
|
|
157
|
+
return None
|
|
158
|
+
host, port = self._sock.getsockname()[:2]
|
|
159
|
+
return str(host), int(port)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def queries(self) -> tuple[DnsQuery, ...]:
|
|
163
|
+
with self._lock:
|
|
164
|
+
return tuple(self._queries)
|
|
165
|
+
|
|
166
|
+
def start(self) -> DnsSinkhole:
|
|
167
|
+
if self._sock is not None:
|
|
168
|
+
return self
|
|
169
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
170
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
171
|
+
sock.bind((self.host, self.port))
|
|
172
|
+
sock.settimeout(0.2)
|
|
173
|
+
self._sock = sock
|
|
174
|
+
self._stop.clear()
|
|
175
|
+
self._thread = threading.Thread(target=self._serve, name="skillsentry-dns", daemon=True)
|
|
176
|
+
self._thread.start()
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
def stop(self) -> None:
|
|
180
|
+
self._stop.set()
|
|
181
|
+
if self._sock is not None:
|
|
182
|
+
with contextlib.suppress(OSError):
|
|
183
|
+
self._sock.close()
|
|
184
|
+
self._sock = None
|
|
185
|
+
if self._thread is not None:
|
|
186
|
+
self._thread.join(timeout=1.0)
|
|
187
|
+
self._thread = None
|
|
188
|
+
|
|
189
|
+
def __enter__(self) -> DnsSinkhole:
|
|
190
|
+
return self.start()
|
|
191
|
+
|
|
192
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
193
|
+
self.stop()
|
|
194
|
+
|
|
195
|
+
def _serve(self) -> None:
|
|
196
|
+
sock = self._sock
|
|
197
|
+
if sock is None:
|
|
198
|
+
return
|
|
199
|
+
while not self._stop.is_set():
|
|
200
|
+
try:
|
|
201
|
+
data, peer = sock.recvfrom(4096)
|
|
202
|
+
except TimeoutError:
|
|
203
|
+
continue
|
|
204
|
+
except OSError:
|
|
205
|
+
break
|
|
206
|
+
query = parse_dns_query(data, client=f"{peer[0]}:{peer[1]}")
|
|
207
|
+
if query is None:
|
|
208
|
+
continue
|
|
209
|
+
with self._lock:
|
|
210
|
+
if len(self._queries) >= self.max_queries:
|
|
211
|
+
continue
|
|
212
|
+
self._queries.append(query)
|
|
213
|
+
if self.on_query is not None:
|
|
214
|
+
# A telemetry callback must never kill the responder.
|
|
215
|
+
with contextlib.suppress(Exception):
|
|
216
|
+
self.on_query(query)
|
|
217
|
+
with contextlib.suppress(OSError, ValueError):
|
|
218
|
+
sock.sendto(build_dns_response(data, answer_ip=self.answer_ip), peer)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Inert fake HTTP responder for the Poisoned Well sinkhole.
|
|
2
|
+
|
|
3
|
+
Every request is recorded (method, URL, headers, body) and answered with a
|
|
4
|
+
fixed ``200 {"status":"ok","data":"<secondary canary>"}``. It never proxies,
|
|
5
|
+
resolves, or serves attacker-controlled content. Bind it to loopback for tests
|
|
6
|
+
or to the internal sinkhole network in the Docker integration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
import threading
|
|
15
|
+
from collections import defaultdict, deque
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
|
+
from time import monotonic
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from skill_sentry_cli.core.contracts import utc_now
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class HttpObservation:
|
|
28
|
+
ts: datetime
|
|
29
|
+
method: str
|
|
30
|
+
url: str
|
|
31
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
32
|
+
body: bytes = b""
|
|
33
|
+
client: str = ""
|
|
34
|
+
responder_action: str = "200+secondary"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def body_b64(self) -> str | None:
|
|
38
|
+
if not self.body:
|
|
39
|
+
return None
|
|
40
|
+
return base64.b64encode(self.body).decode("ascii")
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def query_or_url(self) -> str:
|
|
44
|
+
return f"{self.method} {self.url}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _FakeHttpServer(ThreadingHTTPServer):
|
|
48
|
+
daemon_threads = True
|
|
49
|
+
allow_reuse_address = True
|
|
50
|
+
|
|
51
|
+
def __init__(self, address: tuple[str, int], app: FakeHttpResponder) -> None:
|
|
52
|
+
self.app = app
|
|
53
|
+
super().__init__(address, _FakeHttpHandler)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _FakeHttpHandler(BaseHTTPRequestHandler):
|
|
57
|
+
server: _FakeHttpServer
|
|
58
|
+
|
|
59
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
60
|
+
# Keep test output quiet; the responder has its own structured log.
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
def _read_body(self) -> bytes:
|
|
64
|
+
raw_length = self.headers.get("Content-Length", "0")
|
|
65
|
+
try:
|
|
66
|
+
length = max(0, min(int(raw_length), 1_048_576))
|
|
67
|
+
except ValueError:
|
|
68
|
+
length = 0
|
|
69
|
+
return self.rfile.read(length) if length else b""
|
|
70
|
+
|
|
71
|
+
def _handle(self) -> None:
|
|
72
|
+
body = self._read_body()
|
|
73
|
+
observation = HttpObservation(
|
|
74
|
+
ts=utc_now(),
|
|
75
|
+
method=self.command,
|
|
76
|
+
url=self.path,
|
|
77
|
+
headers={key: value for key, value in self.headers.items()},
|
|
78
|
+
body=body,
|
|
79
|
+
client=f"{self.client_address[0]}:{self.client_address[1]}",
|
|
80
|
+
)
|
|
81
|
+
app = self.server.app
|
|
82
|
+
action = app._record(observation)
|
|
83
|
+
status = 429 if action == "429-rate-limited" else 200
|
|
84
|
+
payload = (
|
|
85
|
+
{"error": "rate limited"}
|
|
86
|
+
if status == 429
|
|
87
|
+
else {"status": "ok", "data": app.secondary_canary}
|
|
88
|
+
)
|
|
89
|
+
encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
90
|
+
self.send_response(status)
|
|
91
|
+
self.send_header("Content-Type", "application/json")
|
|
92
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
93
|
+
self.send_header("Connection", "close")
|
|
94
|
+
self.end_headers()
|
|
95
|
+
self.wfile.write(encoded)
|
|
96
|
+
|
|
97
|
+
do_GET = _handle
|
|
98
|
+
do_POST = _handle
|
|
99
|
+
do_PUT = _handle
|
|
100
|
+
do_PATCH = _handle
|
|
101
|
+
do_DELETE = _handle
|
|
102
|
+
do_HEAD = _handle
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class FakeHttpResponder:
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
host: str = "127.0.0.1",
|
|
110
|
+
port: int = 0,
|
|
111
|
+
secondary_canary: str = "SKS-00000000",
|
|
112
|
+
rate_limit_per_minute: int = 120,
|
|
113
|
+
on_request: Callable[[HttpObservation], None] | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
if rate_limit_per_minute <= 0:
|
|
116
|
+
raise ValueError("rate_limit_per_minute must be positive")
|
|
117
|
+
self.host = host
|
|
118
|
+
self.port = port
|
|
119
|
+
self.secondary_canary = secondary_canary
|
|
120
|
+
self.rate_limit_per_minute = rate_limit_per_minute
|
|
121
|
+
self.on_request = on_request
|
|
122
|
+
self._server: _FakeHttpServer | None = None
|
|
123
|
+
self._thread: threading.Thread | None = None
|
|
124
|
+
self._lock = threading.Lock()
|
|
125
|
+
self._requests: list[HttpObservation] = []
|
|
126
|
+
self._client_windows: dict[str, deque[float]] = defaultdict(deque)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def address(self) -> tuple[str, int] | None:
|
|
130
|
+
if self._server is None:
|
|
131
|
+
return None
|
|
132
|
+
host, port = self._server.server_address[:2]
|
|
133
|
+
return str(host), int(port)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def requests(self) -> tuple[HttpObservation, ...]:
|
|
137
|
+
with self._lock:
|
|
138
|
+
return tuple(self._requests)
|
|
139
|
+
|
|
140
|
+
def start(self) -> FakeHttpResponder:
|
|
141
|
+
if self._server is not None:
|
|
142
|
+
return self
|
|
143
|
+
self._server = _FakeHttpServer((self.host, self.port), self)
|
|
144
|
+
self._thread = threading.Thread(
|
|
145
|
+
target=self._server.serve_forever,
|
|
146
|
+
name="skillsentry-http",
|
|
147
|
+
daemon=True,
|
|
148
|
+
)
|
|
149
|
+
self._thread.start()
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def stop(self) -> None:
|
|
153
|
+
if self._server is not None:
|
|
154
|
+
self._server.shutdown()
|
|
155
|
+
self._server.server_close()
|
|
156
|
+
self._server = None
|
|
157
|
+
if self._thread is not None:
|
|
158
|
+
self._thread.join(timeout=1.0)
|
|
159
|
+
self._thread = None
|
|
160
|
+
|
|
161
|
+
def __enter__(self) -> FakeHttpResponder:
|
|
162
|
+
return self.start()
|
|
163
|
+
|
|
164
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
165
|
+
self.stop()
|
|
166
|
+
|
|
167
|
+
def _record(self, observation: HttpObservation) -> str:
|
|
168
|
+
with self._lock:
|
|
169
|
+
# The ephemeral source port changes for every connection, so limit
|
|
170
|
+
# by host address rather than by "host:port".
|
|
171
|
+
client_key = observation.client.rsplit(":", 1)[0] if observation.client else "unknown"
|
|
172
|
+
window = self._client_windows[client_key]
|
|
173
|
+
now = monotonic()
|
|
174
|
+
while window and now - window[0] >= 60.0:
|
|
175
|
+
window.popleft()
|
|
176
|
+
if len(window) >= self.rate_limit_per_minute:
|
|
177
|
+
action = "429-rate-limited"
|
|
178
|
+
else:
|
|
179
|
+
window.append(now)
|
|
180
|
+
action = "200+secondary"
|
|
181
|
+
recorded = HttpObservation(
|
|
182
|
+
ts=observation.ts,
|
|
183
|
+
method=observation.method,
|
|
184
|
+
url=observation.url,
|
|
185
|
+
headers=observation.headers,
|
|
186
|
+
body=observation.body,
|
|
187
|
+
client=observation.client,
|
|
188
|
+
responder_action=action,
|
|
189
|
+
)
|
|
190
|
+
self._requests.append(recorded)
|
|
191
|
+
if self.on_request is not None:
|
|
192
|
+
with contextlib.suppress(Exception):
|
|
193
|
+
self.on_request(recorded)
|
|
194
|
+
return action
|