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,52 @@
|
|
|
1
|
+
"""Poisoned Well canaries, correlation, and attacker-profile construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"IOC",
|
|
10
|
+
"AttackerProfile",
|
|
11
|
+
"CanaryFile",
|
|
12
|
+
"CanarySet",
|
|
13
|
+
"Correlation",
|
|
14
|
+
"ExfilTracker",
|
|
15
|
+
"build_attacker_profile",
|
|
16
|
+
"extract_markers",
|
|
17
|
+
"generate_canary_set",
|
|
18
|
+
"new_marker",
|
|
19
|
+
"normalize_intent",
|
|
20
|
+
"validate_marker",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_LAZY: dict[str, tuple[str, str]] = {
|
|
24
|
+
"AttackerProfile": ("skill_sentry_cli.core.poisoned_well.attacker_profile", "AttackerProfile"),
|
|
25
|
+
"IOC": ("skill_sentry_cli.core.poisoned_well.attacker_profile", "IOC"),
|
|
26
|
+
"build_attacker_profile": (
|
|
27
|
+
"skill_sentry_cli.core.poisoned_well.attacker_profile",
|
|
28
|
+
"build_attacker_profile",
|
|
29
|
+
),
|
|
30
|
+
"CanaryFile": ("skill_sentry_cli.core.poisoned_well.canary_gen", "CanaryFile"),
|
|
31
|
+
"CanarySet": ("skill_sentry_cli.core.poisoned_well.canary_gen", "CanarySet"),
|
|
32
|
+
"extract_markers": ("skill_sentry_cli.core.poisoned_well.canary_gen", "extract_markers"),
|
|
33
|
+
"generate_canary_set": (
|
|
34
|
+
"skill_sentry_cli.core.poisoned_well.canary_gen",
|
|
35
|
+
"generate_canary_set",
|
|
36
|
+
),
|
|
37
|
+
"new_marker": ("skill_sentry_cli.core.poisoned_well.canary_gen", "new_marker"),
|
|
38
|
+
"normalize_intent": ("skill_sentry_cli.core.poisoned_well.canary_gen", "normalize_intent"),
|
|
39
|
+
"validate_marker": ("skill_sentry_cli.core.poisoned_well.canary_gen", "validate_marker"),
|
|
40
|
+
"Correlation": ("skill_sentry_cli.core.poisoned_well.exfil_tracker", "Correlation"),
|
|
41
|
+
"ExfilTracker": ("skill_sentry_cli.core.poisoned_well.exfil_tracker", "ExfilTracker"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def __getattr__(name: str) -> Any:
|
|
46
|
+
target = _LAZY.get(name)
|
|
47
|
+
if target is None:
|
|
48
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
49
|
+
module_name, attribute = target
|
|
50
|
+
value = getattr(importlib.import_module(module_name), attribute)
|
|
51
|
+
globals()[name] = value
|
|
52
|
+
return value
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Build the Attacker Profile / ``activeDefense`` block from confirmed evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from skill_sentry_cli.core.contracts import (
|
|
12
|
+
ActiveDefense,
|
|
13
|
+
ContextCanary,
|
|
14
|
+
ExfilChannel,
|
|
15
|
+
Finding,
|
|
16
|
+
FindingEvidence,
|
|
17
|
+
Severity,
|
|
18
|
+
Stage,
|
|
19
|
+
)
|
|
20
|
+
from skill_sentry_cli.core.evasion.detector import EvasionResult
|
|
21
|
+
from skill_sentry_cli.core.poisoned_well.canary_gen import CanarySet
|
|
22
|
+
from skill_sentry_cli.core.poisoned_well.exfil_tracker import Correlation
|
|
23
|
+
|
|
24
|
+
_IPV4_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class IOC:
|
|
29
|
+
ioc_type: str
|
|
30
|
+
value: str
|
|
31
|
+
channel: str
|
|
32
|
+
first_seen: datetime
|
|
33
|
+
confirmed: bool = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AttackerProfile:
|
|
38
|
+
triggered: bool
|
|
39
|
+
declared_intent: str
|
|
40
|
+
actual_behavior: str
|
|
41
|
+
context_aware_canary: ContextCanary
|
|
42
|
+
targeted_assets: tuple[str, ...] = ()
|
|
43
|
+
exfil_channel: ExfilChannel | None = None
|
|
44
|
+
canary_marker: str | None = None
|
|
45
|
+
secondary_canary: str | None = None
|
|
46
|
+
encoding: str | None = None
|
|
47
|
+
c2_indicators: tuple[str, ...] = ()
|
|
48
|
+
evasion_detected: bool = False
|
|
49
|
+
iocs: tuple[IOC, ...] = ()
|
|
50
|
+
ioc_export: str | None = None
|
|
51
|
+
probe_targets: tuple[str, ...] = ()
|
|
52
|
+
|
|
53
|
+
def to_active_defense(self) -> ActiveDefense:
|
|
54
|
+
return ActiveDefense(
|
|
55
|
+
triggered=self.triggered,
|
|
56
|
+
targeted_assets=list(self.targeted_assets),
|
|
57
|
+
exfil_channel=self.exfil_channel,
|
|
58
|
+
canary_marker=self.canary_marker,
|
|
59
|
+
secondary_canary=self.secondary_canary,
|
|
60
|
+
c2_indicators=list(self.c2_indicators),
|
|
61
|
+
encoding=self.encoding,
|
|
62
|
+
sandbox_evasion_detected=self.evasion_detected,
|
|
63
|
+
declared_intent=self.declared_intent or None,
|
|
64
|
+
context_aware_canary=self.context_aware_canary,
|
|
65
|
+
actual_behavior=self.actual_behavior or None,
|
|
66
|
+
ioc_export=self.ioc_export,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def to_finding(self) -> Finding | None:
|
|
70
|
+
if not self.triggered:
|
|
71
|
+
return None
|
|
72
|
+
if self.exfil_channel is not None:
|
|
73
|
+
message = (
|
|
74
|
+
f"Poisoned Well confirmed exfiltration of {', '.join(self.targeted_assets)} "
|
|
75
|
+
f"via {self.exfil_channel.value}."
|
|
76
|
+
)
|
|
77
|
+
finding_type = "canary-exfiltration"
|
|
78
|
+
else:
|
|
79
|
+
message = f"Sandbox evasion probes detected: {', '.join(self.probe_targets)}."
|
|
80
|
+
finding_type = "sandbox-evasion"
|
|
81
|
+
return Finding(
|
|
82
|
+
stage=Stage.POISONED_WELL if self.exfil_channel else Stage.EVASION,
|
|
83
|
+
severity=Severity.CRITICAL,
|
|
84
|
+
message=message,
|
|
85
|
+
finding_type=finding_type,
|
|
86
|
+
evidence=FindingEvidence(
|
|
87
|
+
marker=self.canary_marker,
|
|
88
|
+
channel=self.exfil_channel.value if self.exfil_channel else None,
|
|
89
|
+
c2=self.c2_indicators[0] if self.c2_indicators else None,
|
|
90
|
+
path=self.targeted_assets[0] if self.targeted_assets else None,
|
|
91
|
+
**{
|
|
92
|
+
"targetedAssets": list(self.targeted_assets),
|
|
93
|
+
"encoding": self.encoding,
|
|
94
|
+
"probes": list(self.probe_targets),
|
|
95
|
+
},
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def to_dict(self) -> dict[str, Any]:
|
|
100
|
+
return {
|
|
101
|
+
"triggered": self.triggered,
|
|
102
|
+
"declaredIntent": self.declared_intent,
|
|
103
|
+
"actualBehavior": self.actual_behavior,
|
|
104
|
+
"contextAwareCanary": self.context_aware_canary.value,
|
|
105
|
+
"targetedAssets": list(self.targeted_assets),
|
|
106
|
+
"exfilChannel": self.exfil_channel.value if self.exfil_channel else None,
|
|
107
|
+
"canaryMarker": self.canary_marker,
|
|
108
|
+
"secondaryCanary": self.secondary_canary,
|
|
109
|
+
"encoding": self.encoding,
|
|
110
|
+
"c2Indicators": list(self.c2_indicators),
|
|
111
|
+
"sandboxEvasionDetected": self.evasion_detected,
|
|
112
|
+
"iocExport": self.ioc_export,
|
|
113
|
+
"iocs": [
|
|
114
|
+
{
|
|
115
|
+
"type": ioc.ioc_type,
|
|
116
|
+
"value": ioc.value,
|
|
117
|
+
"channel": ioc.channel,
|
|
118
|
+
"firstSeen": ioc.first_seen.isoformat(),
|
|
119
|
+
"confirmed": ioc.confirmed,
|
|
120
|
+
}
|
|
121
|
+
for ioc in self.iocs
|
|
122
|
+
],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ioc_type(value: str) -> str:
|
|
127
|
+
if _IPV4_RE.match(value):
|
|
128
|
+
return "ipv4-addr"
|
|
129
|
+
if value.startswith(("http://", "https://")):
|
|
130
|
+
return "url"
|
|
131
|
+
return "domain-name"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _iocs_for(correlation: Correlation) -> tuple[IOC, ...]:
|
|
135
|
+
channel = correlation.observation.protocol
|
|
136
|
+
seen: set[tuple[str, str]] = set()
|
|
137
|
+
iocs: list[IOC] = []
|
|
138
|
+
for value in correlation.c2_indicators:
|
|
139
|
+
key = (_ioc_type(value), value)
|
|
140
|
+
if key in seen:
|
|
141
|
+
continue
|
|
142
|
+
seen.add(key)
|
|
143
|
+
iocs.append(
|
|
144
|
+
IOC(
|
|
145
|
+
ioc_type=key[0],
|
|
146
|
+
value=value,
|
|
147
|
+
channel=channel,
|
|
148
|
+
first_seen=correlation.observation.ts,
|
|
149
|
+
confirmed=True,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
# Capture the full URL for an HTTP exfil as well as its hostname, when it
|
|
153
|
+
# carries a path that may be useful to a responder.
|
|
154
|
+
if observation_url := correlation.observation.query_or_url:
|
|
155
|
+
try:
|
|
156
|
+
parsed = urlsplit(observation_url)
|
|
157
|
+
except ValueError:
|
|
158
|
+
parsed = None
|
|
159
|
+
if parsed is not None and parsed.netloc and parsed.path not in {"", "/"}:
|
|
160
|
+
key = ("url", observation_url)
|
|
161
|
+
if key not in seen:
|
|
162
|
+
seen.add(key)
|
|
163
|
+
iocs.append(
|
|
164
|
+
IOC(
|
|
165
|
+
ioc_type="url",
|
|
166
|
+
value=observation_url,
|
|
167
|
+
channel=channel,
|
|
168
|
+
first_seen=correlation.observation.ts,
|
|
169
|
+
confirmed=True,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return tuple(iocs)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_attacker_profile(
|
|
176
|
+
canaries: CanarySet,
|
|
177
|
+
*,
|
|
178
|
+
correlation: Correlation | None = None,
|
|
179
|
+
evasion: EvasionResult | None = None,
|
|
180
|
+
secondary_served: bool = False,
|
|
181
|
+
ioc_export: str | None = None,
|
|
182
|
+
) -> AttackerProfile:
|
|
183
|
+
"""Assemble the profile from whichever hard evidence was confirmed."""
|
|
184
|
+
|
|
185
|
+
evasion_detected = bool(evasion and evasion.detected)
|
|
186
|
+
triggered = bool(correlation and correlation.matched) or evasion_detected
|
|
187
|
+
targeted_assets: tuple[str, ...] = ()
|
|
188
|
+
exfil_channel: ExfilChannel | None = None
|
|
189
|
+
marker: str | None = None
|
|
190
|
+
encoding: str | None = None
|
|
191
|
+
c2: tuple[str, ...] = ()
|
|
192
|
+
iocs: tuple[IOC, ...] = ()
|
|
193
|
+
probe_targets = evasion.targets if evasion else ()
|
|
194
|
+
|
|
195
|
+
if correlation and correlation.matched:
|
|
196
|
+
targeted_assets = correlation.targeted_assets
|
|
197
|
+
exfil_channel = correlation.channel
|
|
198
|
+
marker = correlation.marker
|
|
199
|
+
encoding = correlation.encoding
|
|
200
|
+
c2 = correlation.c2_indicators
|
|
201
|
+
iocs = _iocs_for(correlation)
|
|
202
|
+
c2_text = ", ".join(c2) or "the sinkhole"
|
|
203
|
+
actual_behavior = (
|
|
204
|
+
f"Read {', '.join(targeted_assets) or 'a seeded canary'} and sent the "
|
|
205
|
+
f"{marker} marker to {c2_text} via {exfil_channel.value} "
|
|
206
|
+
f"({encoding or 'raw'} encoding)."
|
|
207
|
+
)
|
|
208
|
+
elif evasion_detected:
|
|
209
|
+
actual_behavior = (
|
|
210
|
+
f"Probed the environment for sandbox/VM artifacts: {', '.join(probe_targets)}."
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
actual_behavior = "No canary read, exfiltration, or sandbox probe observed."
|
|
214
|
+
|
|
215
|
+
return AttackerProfile(
|
|
216
|
+
triggered=triggered,
|
|
217
|
+
declared_intent=canaries.declared_intent,
|
|
218
|
+
actual_behavior=actual_behavior,
|
|
219
|
+
context_aware_canary=canaries.intent,
|
|
220
|
+
targeted_assets=targeted_assets,
|
|
221
|
+
exfil_channel=exfil_channel,
|
|
222
|
+
canary_marker=marker,
|
|
223
|
+
secondary_canary=canaries.secondary_marker if secondary_served else None,
|
|
224
|
+
encoding=encoding,
|
|
225
|
+
c2_indicators=c2,
|
|
226
|
+
evasion_detected=evasion_detected,
|
|
227
|
+
iocs=iocs,
|
|
228
|
+
ioc_export=ioc_export,
|
|
229
|
+
probe_targets=probe_targets,
|
|
230
|
+
)
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Intent-aware, fake-by-construction credential canaries.
|
|
2
|
+
|
|
3
|
+
All values are generated per scan and contain a unique ``SKS-<8hex>`` marker.
|
|
4
|
+
Nothing in this module reads, copies, or derives from real credentials. The
|
|
5
|
+
only inputs are the scan id, the declared intent, and (in tests) an explicit
|
|
6
|
+
marker for reproducibility.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from skill_sentry_cli.core.contracts import ContextCanary
|
|
18
|
+
|
|
19
|
+
CANARY_ROOT = "/home/sandbox"
|
|
20
|
+
MARKER_RE = re.compile(r"^SKS-[0-9a-f]{8}$")
|
|
21
|
+
_EMBEDDED_MARKER_RE = re.compile(r"SKS-?[0-9a-f]{8}", re.IGNORECASE)
|
|
22
|
+
|
|
23
|
+
_INTENT_KEYWORDS: tuple[tuple[ContextCanary, tuple[tuple[str, int], ...]], ...] = (
|
|
24
|
+
(
|
|
25
|
+
ContextCanary.GITHUB,
|
|
26
|
+
(
|
|
27
|
+
("github", 2),
|
|
28
|
+
("git ", 1),
|
|
29
|
+
("git-", 1),
|
|
30
|
+
("repository", 2),
|
|
31
|
+
("repo", 1),
|
|
32
|
+
("pull request", 2),
|
|
33
|
+
("commit", 1),
|
|
34
|
+
("gh ", 1),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
ContextCanary.SLACK,
|
|
39
|
+
(
|
|
40
|
+
("slack", 2),
|
|
41
|
+
("webhook", 2),
|
|
42
|
+
("notification", 2),
|
|
43
|
+
("chat", 1),
|
|
44
|
+
("workspace", 1),
|
|
45
|
+
("bot token", 2),
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
ContextCanary.SSH,
|
|
50
|
+
(
|
|
51
|
+
("ssh", 2),
|
|
52
|
+
("devops", 2),
|
|
53
|
+
("deploy", 1),
|
|
54
|
+
("server", 1),
|
|
55
|
+
("remote", 1),
|
|
56
|
+
("scp", 1),
|
|
57
|
+
("ansible", 2),
|
|
58
|
+
("kubernetes", 2),
|
|
59
|
+
("k8s", 2),
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
(
|
|
63
|
+
ContextCanary.AWS,
|
|
64
|
+
(
|
|
65
|
+
("aws", 2),
|
|
66
|
+
("amazon", 2),
|
|
67
|
+
("s3", 2),
|
|
68
|
+
("ec2", 2),
|
|
69
|
+
("iam", 2),
|
|
70
|
+
("bucket", 2),
|
|
71
|
+
("cloud", 1),
|
|
72
|
+
("credential", 1),
|
|
73
|
+
("sync", 1),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Common files are seeded for every non-generic intent as well: realistic
|
|
79
|
+
# developers have more than one credential, and the scenario in docs/02 §2.3
|
|
80
|
+
# explicitly seeds SLACK + generic AWS/SSH canaries.
|
|
81
|
+
_COMMON_ASSETS = ("aws", "ssh", "generic")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class CanaryFile:
|
|
86
|
+
"""One fake file seeded into the sandbox home directory."""
|
|
87
|
+
|
|
88
|
+
relative_path: str
|
|
89
|
+
display_path: str
|
|
90
|
+
content: str
|
|
91
|
+
mode: int
|
|
92
|
+
asset: str
|
|
93
|
+
marker: str
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def container_path(self) -> str:
|
|
97
|
+
return f"{CANARY_ROOT}/{self.relative_path}"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def tar_path(self) -> str:
|
|
101
|
+
return f"./{self.relative_path}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class CanarySet:
|
|
106
|
+
"""Everything seeded for one scan, plus the markers to correlate."""
|
|
107
|
+
|
|
108
|
+
scan_id: UUID
|
|
109
|
+
declared_intent: str
|
|
110
|
+
intent: ContextCanary
|
|
111
|
+
marker: str
|
|
112
|
+
secondary_marker: str
|
|
113
|
+
files: tuple[CanaryFile, ...]
|
|
114
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
115
|
+
env_assets: Mapping[str, str] = field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def search_tokens(self) -> tuple[str, ...]:
|
|
119
|
+
"""Marker spellings that may appear in raw or decoded exfil traffic."""
|
|
120
|
+
|
|
121
|
+
compact = self.marker.replace("-", "")
|
|
122
|
+
# The docs show GitHub as ``ghp_SKS<8hex>`` and Slack as
|
|
123
|
+
# ``xoxb-SKS-<8hex>``; accept both spellings.
|
|
124
|
+
tokens = {
|
|
125
|
+
self.marker,
|
|
126
|
+
compact,
|
|
127
|
+
f"SKS{compact[3:]}",
|
|
128
|
+
self.marker.upper(),
|
|
129
|
+
compact.upper(),
|
|
130
|
+
}
|
|
131
|
+
return tuple(sorted(tokens))
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def env_canary_keys(self) -> tuple[str, ...]:
|
|
135
|
+
return tuple(sorted(self.env))
|
|
136
|
+
|
|
137
|
+
def display_path(self, path: str) -> str:
|
|
138
|
+
"""Map a container path under HOME to its ``~/...`` display form."""
|
|
139
|
+
|
|
140
|
+
prefix = f"{CANARY_ROOT}/"
|
|
141
|
+
if path.startswith(prefix):
|
|
142
|
+
return f"~/{path[len(prefix) :]}"
|
|
143
|
+
return path
|
|
144
|
+
|
|
145
|
+
def file_for(self, path: str) -> CanaryFile | None:
|
|
146
|
+
normalised = path[len(CANARY_ROOT) + 1 :] if path.startswith(f"{CANARY_ROOT}/") else path
|
|
147
|
+
normalised = normalised.removeprefix("~/")
|
|
148
|
+
for canary in self.files:
|
|
149
|
+
if normalised in {canary.relative_path, canary.display_path.removeprefix("~/")}:
|
|
150
|
+
return canary
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def new_marker() -> str:
|
|
155
|
+
"""Return a fresh ``SKS-<8hex>`` marker (used once per scan)."""
|
|
156
|
+
|
|
157
|
+
return f"SKS-{secrets.token_hex(4)}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def validate_marker(marker: str) -> str:
|
|
161
|
+
if not MARKER_RE.fullmatch(marker):
|
|
162
|
+
raise ValueError(f"invalid canary marker: {marker!r}")
|
|
163
|
+
return marker
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def normalize_intent(declared_intent: str | None) -> ContextCanary:
|
|
167
|
+
"""Classify a free-text declared intent into a canary context.
|
|
168
|
+
|
|
169
|
+
The matching is deliberately simple and deterministic. Dev 2 supplies the
|
|
170
|
+
raw intent from the semantic stage; no model call is needed here.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
text = f" {(declared_intent or '').lower()} "
|
|
174
|
+
scored = [
|
|
175
|
+
(sum(weight for keyword, weight in keywords if keyword in text), context)
|
|
176
|
+
for context, keywords in _INTENT_KEYWORDS
|
|
177
|
+
]
|
|
178
|
+
best_score, best_context = max(scored, key=lambda item: item[0])
|
|
179
|
+
return best_context if best_score > 0 else ContextCanary.GENERIC
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _aws_access_key(marker: str) -> str:
|
|
183
|
+
# 4 + 11 + 5 = 20 characters, the shape of an AWS key id. Keep the whole
|
|
184
|
+
# compact marker (``SKS<8hex>``) inside the value so an exfiltrated key id is
|
|
185
|
+
# still correlatable; the ``FAKEF`` suffix makes it invalid.
|
|
186
|
+
compact = marker.replace("-", "").upper()
|
|
187
|
+
return f"AKIA{compact}FAKEF"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _aws_secret(marker: str) -> str:
|
|
191
|
+
return f"{marker}-fake-aws-secret-DO-NOT-USE-0000000000"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _github_token(marker: str) -> str:
|
|
195
|
+
return f"ghp_{marker}-fake-github-token-DO-NOT-USE"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _slack_token(marker: str) -> str:
|
|
199
|
+
return f"xoxb-{marker}-fake-slack-token"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _ssh_key(marker: str) -> str:
|
|
203
|
+
# A marker-bearing, deliberately invalid PEM-like blob. It is not a key
|
|
204
|
+
# generated from any real material and cannot authenticate anywhere.
|
|
205
|
+
body = (
|
|
206
|
+
"U0tJTExTRU5UUlktRkFLRS1QUklWQVRFLUtFWS1ETy1OT1QtVVNFLT"
|
|
207
|
+
"AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw"
|
|
208
|
+
)
|
|
209
|
+
return (
|
|
210
|
+
"# SkillSentry canary - this is NOT a real private key\n"
|
|
211
|
+
f"# {marker}\n"
|
|
212
|
+
"-----BEGIN OPENSSH PRIVATE KEY-----\n"
|
|
213
|
+
f"{body}\n"
|
|
214
|
+
f"-----END OPENSSH PRIVATE KEY-----\n"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _env_file(marker: str) -> str:
|
|
219
|
+
return (
|
|
220
|
+
"# SkillSentry canary environment - all values are fake\n"
|
|
221
|
+
f"AWS_ACCESS_KEY_ID={_aws_access_key(marker)}\n"
|
|
222
|
+
f"AWS_SECRET_ACCESS_KEY={_aws_secret(marker)}\n"
|
|
223
|
+
f"GITHUB_TOKEN={_github_token(marker)}\n"
|
|
224
|
+
f"GH_TOKEN={_github_token(marker)}\n"
|
|
225
|
+
f"SLACK_BOT_TOKEN={_slack_token(marker)}\n"
|
|
226
|
+
"SLACK_WEBHOOK_URL=https://hooks.slack.invalid/services/T000/B000/"
|
|
227
|
+
f"{marker}-fake\n"
|
|
228
|
+
f"OPENAI_API_KEY=sk-{marker}-fake\n"
|
|
229
|
+
f"DATABASE_URL=postgres://canary:{marker}@db.invalid/skillsentry\n"
|
|
230
|
+
f"NPM_TOKEN=npm_{marker}-fake\n"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _passwords_file(marker: str) -> str:
|
|
235
|
+
return (
|
|
236
|
+
"# Fake password export - generated by SkillSentry\n"
|
|
237
|
+
f"example.com | analyst | {marker}-not-a-password\n"
|
|
238
|
+
f"internal.invalid | deploy | {marker}-not-a-password\n"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _files_for(intent: ContextCanary, marker: str) -> tuple[CanaryFile, ...]:
|
|
243
|
+
files = {
|
|
244
|
+
"aws": (
|
|
245
|
+
CanaryFile(
|
|
246
|
+
".aws/credentials",
|
|
247
|
+
"~/.aws/credentials",
|
|
248
|
+
"[default]\n"
|
|
249
|
+
f"aws_access_key_id = {_aws_access_key(marker)}\n"
|
|
250
|
+
f"aws_secret_access_key = {_aws_secret(marker)}\n"
|
|
251
|
+
f"# canary {marker}\n"
|
|
252
|
+
"region = us-east-1\n",
|
|
253
|
+
0o600,
|
|
254
|
+
"aws",
|
|
255
|
+
marker,
|
|
256
|
+
),
|
|
257
|
+
CanaryFile(
|
|
258
|
+
".aws/config",
|
|
259
|
+
"~/.aws/config",
|
|
260
|
+
f"[default]\nregion = us-east-1\noutput = json\n# {marker}\n",
|
|
261
|
+
0o644,
|
|
262
|
+
"aws",
|
|
263
|
+
marker,
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
"github": (
|
|
267
|
+
CanaryFile(
|
|
268
|
+
".config/gh/hosts.yml",
|
|
269
|
+
"~/.config/gh/hosts.yml",
|
|
270
|
+
"github.com:\n"
|
|
271
|
+
" user: skillsentry-canary\n"
|
|
272
|
+
f" oauth_token: {_github_token(marker)}\n"
|
|
273
|
+
" git_protocol: https\n"
|
|
274
|
+
f"# {marker}\n",
|
|
275
|
+
0o600,
|
|
276
|
+
"github",
|
|
277
|
+
marker,
|
|
278
|
+
),
|
|
279
|
+
),
|
|
280
|
+
"slack": (
|
|
281
|
+
CanaryFile(
|
|
282
|
+
".config/slack/token",
|
|
283
|
+
"~/.config/slack/token",
|
|
284
|
+
f"{_slack_token(marker)}\n# {marker}\n",
|
|
285
|
+
0o600,
|
|
286
|
+
"slack",
|
|
287
|
+
marker,
|
|
288
|
+
),
|
|
289
|
+
),
|
|
290
|
+
"ssh": (
|
|
291
|
+
CanaryFile(
|
|
292
|
+
".ssh/id_rsa",
|
|
293
|
+
"~/.ssh/id_rsa",
|
|
294
|
+
_ssh_key(marker),
|
|
295
|
+
0o600,
|
|
296
|
+
"ssh",
|
|
297
|
+
marker,
|
|
298
|
+
),
|
|
299
|
+
CanaryFile(
|
|
300
|
+
".ssh/config",
|
|
301
|
+
"~/.ssh/config",
|
|
302
|
+
f"Host *\n IdentitiesOnly yes\n# {marker}\n",
|
|
303
|
+
0o644,
|
|
304
|
+
"ssh",
|
|
305
|
+
marker,
|
|
306
|
+
),
|
|
307
|
+
),
|
|
308
|
+
"generic": (
|
|
309
|
+
CanaryFile(".env", "~/.env", _env_file(marker), 0o600, "generic", marker),
|
|
310
|
+
CanaryFile(
|
|
311
|
+
"Documents/passwords.kdbx.txt",
|
|
312
|
+
"~/Documents/passwords.kdbx.txt",
|
|
313
|
+
_passwords_file(marker),
|
|
314
|
+
0o644,
|
|
315
|
+
"generic",
|
|
316
|
+
marker,
|
|
317
|
+
),
|
|
318
|
+
),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
selected: list[str] = []
|
|
322
|
+
if intent is ContextCanary.GENERIC:
|
|
323
|
+
selected = ["aws", "github", "slack", "ssh", "generic"]
|
|
324
|
+
else:
|
|
325
|
+
primary = {
|
|
326
|
+
ContextCanary.AWS: "aws",
|
|
327
|
+
ContextCanary.GITHUB: "github",
|
|
328
|
+
ContextCanary.SLACK: "slack",
|
|
329
|
+
ContextCanary.SSH: "ssh",
|
|
330
|
+
}[intent]
|
|
331
|
+
selected = [primary, *_COMMON_ASSETS]
|
|
332
|
+
# Generic files (.env / passwords export) are common too, but keep the
|
|
333
|
+
# list deterministic and deduplicated.
|
|
334
|
+
selected = list(dict.fromkeys([*selected, "generic"]))
|
|
335
|
+
|
|
336
|
+
result: list[CanaryFile] = []
|
|
337
|
+
for key in selected:
|
|
338
|
+
result.extend(files[key])
|
|
339
|
+
# Stable order by display path makes tar output and tests deterministic.
|
|
340
|
+
return tuple(sorted(result, key=lambda item: item.relative_path))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _env_for(intent: ContextCanary, marker: str) -> tuple[dict[str, str], dict[str, str]]:
|
|
344
|
+
values: dict[str, tuple[str, str]] = {
|
|
345
|
+
"AWS_ACCESS_KEY_ID": (_aws_access_key(marker), "aws"),
|
|
346
|
+
"AWS_SECRET_ACCESS_KEY": (_aws_secret(marker), "aws"),
|
|
347
|
+
"AWS_SESSION_TOKEN": (f"{marker}-fake-session-token", "aws"),
|
|
348
|
+
"GITHUB_TOKEN": (_github_token(marker), "github"),
|
|
349
|
+
"GH_TOKEN": (_github_token(marker), "github"),
|
|
350
|
+
"SLACK_BOT_TOKEN": (_slack_token(marker), "slack"),
|
|
351
|
+
"SLACK_WEBHOOK_URL": (
|
|
352
|
+
f"https://hooks.slack.invalid/services/T000/B000/{marker}-fake",
|
|
353
|
+
"slack",
|
|
354
|
+
),
|
|
355
|
+
"OPENAI_API_KEY": (f"sk-{marker}-fake", "generic"),
|
|
356
|
+
"DATABASE_URL": (f"postgres://canary:{marker}@db.invalid/skillsentry", "generic"),
|
|
357
|
+
"NPM_TOKEN": (f"npm_{marker}-fake", "generic"),
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if intent is ContextCanary.GENERIC:
|
|
361
|
+
selected = values
|
|
362
|
+
else:
|
|
363
|
+
primary_asset = {
|
|
364
|
+
ContextCanary.AWS: "aws",
|
|
365
|
+
ContextCanary.GITHUB: "github",
|
|
366
|
+
ContextCanary.SLACK: "slack",
|
|
367
|
+
ContextCanary.SSH: "ssh",
|
|
368
|
+
}[intent]
|
|
369
|
+
selected = {
|
|
370
|
+
key: value for key, value in values.items() if value[1] in {primary_asset, "generic"}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
env = {key: value for key, (value, _asset) in selected.items()}
|
|
374
|
+
assets = {key: asset for key, (_value, asset) in selected.items()}
|
|
375
|
+
return env, assets
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def generate_canary_set(
|
|
379
|
+
scan_id: UUID,
|
|
380
|
+
declared_intent: str | None,
|
|
381
|
+
*,
|
|
382
|
+
marker: str | None = None,
|
|
383
|
+
secondary_marker: str | None = None,
|
|
384
|
+
) -> CanarySet:
|
|
385
|
+
"""Build a complete, deterministic-by-input canary set for one scan."""
|
|
386
|
+
|
|
387
|
+
primary = validate_marker(marker) if marker else new_marker()
|
|
388
|
+
secondary = validate_marker(secondary_marker) if secondary_marker else new_marker()
|
|
389
|
+
if secondary == primary:
|
|
390
|
+
raise ValueError("primary and secondary canary markers must differ")
|
|
391
|
+
|
|
392
|
+
intent = normalize_intent(declared_intent)
|
|
393
|
+
env, env_assets = _env_for(intent, primary)
|
|
394
|
+
return CanarySet(
|
|
395
|
+
scan_id=scan_id,
|
|
396
|
+
declared_intent=(declared_intent or "").strip()[:500],
|
|
397
|
+
intent=intent,
|
|
398
|
+
marker=primary,
|
|
399
|
+
secondary_marker=secondary,
|
|
400
|
+
files=_files_for(intent, primary),
|
|
401
|
+
env=env,
|
|
402
|
+
env_assets=env_assets,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def extract_markers(text: str) -> set[str]:
|
|
407
|
+
"""Return every embedded ``SKS-<8hex>``/``SKS<8hex>`` marker in *text*."""
|
|
408
|
+
|
|
409
|
+
markers: set[str] = set()
|
|
410
|
+
for match in _EMBEDDED_MARKER_RE.findall(text):
|
|
411
|
+
compact = match.replace("-", "").lower()
|
|
412
|
+
markers.add(f"SKS-{compact[3:]}")
|
|
413
|
+
return markers
|