skill-sentry-cli 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. skill_sentry_cli/__init__.py +5 -0
  2. skill_sentry_cli/__main__.py +4 -0
  3. skill_sentry_cli/cli.py +918 -0
  4. skill_sentry_cli/core/__init__.py +1 -0
  5. skill_sentry_cli/core/anchors/__init__.py +5 -0
  6. skill_sentry_cli/core/anchors/credential_flow.py +440 -0
  7. skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
  8. skill_sentry_cli/core/cache/__init__.py +35 -0
  9. skill_sentry_cli/core/cache/hash_utils.py +206 -0
  10. skill_sentry_cli/core/cache/sqlite_store.py +925 -0
  11. skill_sentry_cli/core/config.py +299 -0
  12. skill_sentry_cli/core/contracts.py +551 -0
  13. skill_sentry_cli/core/decisions/__init__.py +44 -0
  14. skill_sentry_cli/core/decisions/base.py +174 -0
  15. skill_sentry_cli/core/decisions/calibration.py +325 -0
  16. skill_sentry_cli/core/decisions/laya.py +439 -0
  17. skill_sentry_cli/core/decisions/mapping.py +218 -0
  18. skill_sentry_cli/core/decisions/mock.py +118 -0
  19. skill_sentry_cli/core/decisions/questions.py +118 -0
  20. skill_sentry_cli/core/decisions/report.py +65 -0
  21. skill_sentry_cli/core/decisions/selector.py +151 -0
  22. skill_sentry_cli/core/engine.py +1412 -0
  23. skill_sentry_cli/core/evasion/__init__.py +27 -0
  24. skill_sentry_cli/core/evasion/detector.py +210 -0
  25. skill_sentry_cli/core/exit_codes.py +13 -0
  26. skill_sentry_cli/core/models/__init__.py +20 -0
  27. skill_sentry_cli/core/models/_json.py +147 -0
  28. skill_sentry_cli/core/models/base.py +50 -0
  29. skill_sentry_cli/core/models/gemma_cloud.py +240 -0
  30. skill_sentry_cli/core/models/gemma_local.py +273 -0
  31. skill_sentry_cli/core/models/image_utils.py +112 -0
  32. skill_sentry_cli/core/models/mock.py +152 -0
  33. skill_sentry_cli/core/models/selector.py +123 -0
  34. skill_sentry_cli/core/parser/__init__.py +29 -0
  35. skill_sentry_cli/core/parser/skill_parser.py +520 -0
  36. skill_sentry_cli/core/parser/typosquat.py +167 -0
  37. skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
  38. skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
  39. skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
  40. skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
  41. skill_sentry_cli/core/reports.py +114 -0
  42. skill_sentry_cli/core/sandbox/__init__.py +74 -0
  43. skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
  44. skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
  45. skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
  46. skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
  47. skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
  48. skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
  49. skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
  50. skill_sentry_cli/core/sandbox/fake_http.py +194 -0
  51. skill_sentry_cli/core/sandbox/instructions.py +170 -0
  52. skill_sentry_cli/core/sandbox/masquerade.py +265 -0
  53. skill_sentry_cli/core/sandbox/monitor.py +278 -0
  54. skill_sentry_cli/core/sandbox/runner.py +776 -0
  55. skill_sentry_cli/core/sandbox/sidecar.py +327 -0
  56. skill_sentry_cli/core/semantic/__init__.py +9 -0
  57. skill_sentry_cli/core/semantic/analyzer.py +443 -0
  58. skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
  59. skill_sentry_cli/core/semantic/ast_python.py +156 -0
  60. skill_sentry_cli/core/semantic/candidates.py +78 -0
  61. skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
  62. skill_sentry_cli/core/semantic/prompts.py +61 -0
  63. skill_sentry_cli/core/verdict/__init__.py +24 -0
  64. skill_sentry_cli/core/verdict/fusion.py +392 -0
  65. skill_sentry_cli/core/verdict/markdown_report.py +211 -0
  66. skill_sentry_cli/core/verdict/sarif_report.py +139 -0
  67. skill_sentry_cli/core/verdict/status.py +35 -0
  68. skill_sentry_cli/core/verdict/stix_report.py +132 -0
  69. skill_sentry_cli/core/version.py +38 -0
  70. skill_sentry_cli/core/vision/__init__.py +8 -0
  71. skill_sentry_cli/core/vision/image_extractor.py +91 -0
  72. skill_sentry_cli/core/vision/vision_scanner.py +274 -0
  73. skill_sentry_cli/data/__init__.py +1 -0
  74. skill_sentry_cli/data/known_skills_v1.json +276 -0
  75. skill_sentry_cli/data/sandbox_image.json +4 -0
  76. skill_sentry_cli/rules/credential_rules.toml +94 -0
  77. skill_sentry_cli/server/__init__.py +5 -0
  78. skill_sentry_cli/server/app.py +129 -0
  79. skill_sentry_cli/server/backend.py +63 -0
  80. skill_sentry_cli/server/demo_assets.py +62 -0
  81. skill_sentry_cli/server/demo_backend.py +475 -0
  82. skill_sentry_cli/server/demo_data.py +814 -0
  83. skill_sentry_cli/server/demo_mode.py +40 -0
  84. skill_sentry_cli/server/errors.py +48 -0
  85. skill_sentry_cli/server/live_backend.py +576 -0
  86. skill_sentry_cli/server/routes.py +191 -0
  87. skill_sentry_cli/skill/SKILL.md +67 -0
  88. skill_sentry_cli/skill/scripts/audit.sh +48 -0
  89. skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
  90. skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
  91. skill_sentry_cli/ui/dist/index.html +14 -0
  92. skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
  93. skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
  94. skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
  95. skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
  96. skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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