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,371 @@
1
+ """Deterministic AST/regex anchors (stage 2).
2
+
3
+ These facts are computed *before* any model runs and are injected into the
4
+ semantic prompt as ground truth. The rules are intentionally high-signal:
5
+ a match is evidence, never merely a suspicion. Every finding carries the
6
+ relative file, a 1-based line, and the exact source line.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+
14
+ from skill_sentry_cli.core.anchors import analyze_credential_flows
15
+ from skill_sentry_cli.core.anchors.credential_flow import RULES_VERSION
16
+ from skill_sentry_cli.core.contracts import (
17
+ Finding,
18
+ FindingEvidence,
19
+ Severity,
20
+ Stage,
21
+ StageResult,
22
+ )
23
+ from skill_sentry_cli.core.parser import SkillPackage, TextFile
24
+ from skill_sentry_cli.core.semantic.ast_python import scan_python_ast
25
+
26
+ MAX_FINDINGS = 80
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AnchorRule:
31
+ type: str
32
+ severity: Severity
33
+ pattern: re.Pattern[str]
34
+ message: str
35
+
36
+
37
+ _RULES: tuple[AnchorRule, ...] = (
38
+ AnchorRule(
39
+ "dns-lookup",
40
+ Severity.HIGH,
41
+ # Only a command in command position: prose such as "external host" is not one.
42
+ re.compile(
43
+ r"(?:^|[;&|`(]|\$\()\s*(?:sudo\s+)?(?:nslookup|dig|host)\s+[\w\"'$@.-]", re.IGNORECASE
44
+ ),
45
+ "DNS lookup command detected - potential DNS tunnelling",
46
+ ),
47
+ AnchorRule(
48
+ "credential-path",
49
+ Severity.CRITICAL,
50
+ re.compile(
51
+ r"(?:~|/home/[^/\s]+|/root)/\.aws/credentials|"
52
+ r"AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|"
53
+ r"\.aws[/\\]credentials",
54
+ re.IGNORECASE,
55
+ ),
56
+ "AWS credential file or environment variable referenced",
57
+ ),
58
+ AnchorRule(
59
+ "credential-path",
60
+ Severity.CRITICAL,
61
+ re.compile(
62
+ r"(?:~|/home/[^/\s]+|/root)/\.ssh/(?:id_[a-z0-9]+|authorized_keys)|"
63
+ r"\.ssh[/\\]id_",
64
+ re.IGNORECASE,
65
+ ),
66
+ "SSH private key referenced",
67
+ ),
68
+ AnchorRule(
69
+ "secret-access",
70
+ Severity.CRITICAL,
71
+ re.compile(
72
+ r"\$\{?(?:SLACK_(?:BOT|USER)_TOKEN|GITHUB_TOKEN|GH_TOKEN|"
73
+ r"AWS_SECRET_ACCESS_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY)\}?",
74
+ re.IGNORECASE,
75
+ ),
76
+ "High-value secret environment variable referenced",
77
+ ),
78
+ AnchorRule(
79
+ "obfuscation",
80
+ Severity.HIGH,
81
+ re.compile(r"\bbase64\b(?:\s+-[a-zA-Z]*d|\s+--decode|\s+-w0)", re.IGNORECASE),
82
+ "base64 encoding/decoding detected - possible obfuscation",
83
+ ),
84
+ AnchorRule(
85
+ "exfil-http-post",
86
+ Severity.HIGH,
87
+ re.compile(
88
+ r"\b(?:curl|wget)\b[^\n]*(?:-X\s*POST|--data|-d\s|--upload-file)|"
89
+ r"\brequests\.post\s*\(|\bhttpx\.post\s*\(|"
90
+ r"\burllib\.request\.(?:urlopen|Request)\s*\([^\n]*(?:data\s*=|method\s*=\s*['\"]POST)",
91
+ re.IGNORECASE,
92
+ ),
93
+ "Outbound HTTP POST/upload capability detected",
94
+ ),
95
+ AnchorRule(
96
+ "sandbox-artifact",
97
+ Severity.HIGH,
98
+ re.compile(
99
+ r"/\.dockerenv|/proc/(?:1|self)/(?:cgroup|mountinfo)|"
100
+ r"/sys/(?:class/dmi|hypervisor)|/dev/kmsg",
101
+ re.IGNORECASE,
102
+ ),
103
+ "Sandbox/VM artifact probe path detected",
104
+ ),
105
+ AnchorRule(
106
+ "raw-socket",
107
+ Severity.MEDIUM,
108
+ # Ordinary network code uses sockets; the reverse-shell primitives are below.
109
+ re.compile(r"\bsocket\.socket\s*\(|SOCK_STREAM", re.IGNORECASE),
110
+ "Raw socket use detected",
111
+ ),
112
+ AnchorRule(
113
+ "reverse-shell",
114
+ Severity.CRITICAL,
115
+ re.compile(r"/dev/tcp/|\bpty\.spawn\s*\(|\bos\.dup2\s*\(", re.IGNORECASE),
116
+ "Reverse-shell primitive detected (shell redirected to a network connection)",
117
+ ),
118
+ AnchorRule(
119
+ "dynamic-code",
120
+ Severity.MEDIUM,
121
+ re.compile(r"\b(?:eval|exec)\s*\(|\b__import__\s*\(", re.IGNORECASE),
122
+ "Dynamic code execution primitive detected",
123
+ ),
124
+ AnchorRule(
125
+ "shell-exec",
126
+ Severity.HIGH,
127
+ re.compile(
128
+ r"\bos\.system\s*\(|\bsubprocess\.(?:run|call|Popen|check_output)\s*\([^\n]*"
129
+ r"shell\s*=\s*True",
130
+ re.IGNORECASE,
131
+ ),
132
+ "Shell command execution detected",
133
+ ),
134
+ AnchorRule(
135
+ "unsafe-deserialization",
136
+ Severity.HIGH,
137
+ re.compile(r"\b(?:pickle|dill|marshal)\.loads?\s*\(", re.IGNORECASE),
138
+ "Unsafe deserialization primitive detected",
139
+ ),
140
+ AnchorRule(
141
+ "encoded-powershell",
142
+ Severity.HIGH,
143
+ re.compile(r"powershell(?:\.exe)?[^\n]*-e(?:nc(?:odedcommand)?)?\b", re.IGNORECASE),
144
+ "Encoded PowerShell command detected",
145
+ ),
146
+ AnchorRule(
147
+ "permission-change",
148
+ Severity.MEDIUM,
149
+ re.compile(r"\bchmod\s+(?:777|a\+rwx)\b", re.IGNORECASE),
150
+ "World-writable permission change detected",
151
+ ),
152
+ AnchorRule(
153
+ "destructive-command",
154
+ Severity.MEDIUM,
155
+ re.compile(r"\brm\s+-rf\s+(?:/|~|\$HOME|\*)", re.IGNORECASE),
156
+ "Destructive filesystem command detected",
157
+ ),
158
+ )
159
+
160
+ _INSTRUCTION_RULES: tuple[AnchorRule, ...] = (
161
+ AnchorRule(
162
+ "instruction-injection",
163
+ Severity.CRITICAL,
164
+ re.compile(
165
+ r"(?:ignore|disregard|forget)\s+(?:all\s+)?(?:previous|prior|above)\s+"
166
+ r"(?:instructions?|rules?|prompts?)",
167
+ re.IGNORECASE,
168
+ ),
169
+ "Prompt-injection phrase detected",
170
+ ),
171
+ AnchorRule(
172
+ "instruction-injection",
173
+ Severity.CRITICAL,
174
+ re.compile(
175
+ r"(?:read|open|include|send|upload|exfiltrate)[^\n]{0,80}"
176
+ r"(?:~|/home/[^/\s]+)/\.ssh/|"
177
+ r"include\s+its\s+contents[^\n]{0,80}(?:http|request|api)",
178
+ re.IGNORECASE,
179
+ ),
180
+ "Instruction asks the agent to read private keys and transmit them",
181
+ ),
182
+ AnchorRule(
183
+ "instruction-injection",
184
+ Severity.HIGH,
185
+ re.compile(
186
+ r"(?:do\s+not|don't|never)\s+(?:tell|inform|reveal|mention)[^\n]{0,80}"
187
+ r"(?:user|human|operator)",
188
+ re.IGNORECASE,
189
+ ),
190
+ "Instruction asks the agent to conceal behaviour from the user",
191
+ ),
192
+ AnchorRule(
193
+ "hidden-instruction",
194
+ Severity.HIGH,
195
+ re.compile(
196
+ r"(?:white[-\s]on[-\s]white|hidden\s+(?:text|instruction)|"
197
+ r"invisible\s+(?:text|instruction)|zero[-\s]width)",
198
+ re.IGNORECASE,
199
+ ),
200
+ "Hidden or concealed instruction pattern detected",
201
+ ),
202
+ )
203
+
204
+ _SEVERITY_SCORE = {
205
+ Severity.CRITICAL: 95,
206
+ Severity.HIGH: 72,
207
+ Severity.MEDIUM: 42,
208
+ Severity.LOW: 18,
209
+ Severity.NOTE: 0,
210
+ }
211
+
212
+
213
+ def _scan_file(
214
+ text_file: TextFile,
215
+ rules: tuple[AnchorRule, ...],
216
+ *,
217
+ seen: set[tuple[str, str, int]],
218
+ findings: list[Finding],
219
+ ) -> None:
220
+ for line_number, line in enumerate(text_file.text.splitlines(), start=1):
221
+ if len(line) > 4000:
222
+ line = line[:4000]
223
+ for rule in rules:
224
+ if not rule.pattern.search(line):
225
+ continue
226
+ key = (rule.type, text_file.relative, line_number)
227
+ if key in seen:
228
+ continue
229
+ seen.add(key)
230
+ findings.append(
231
+ Finding(
232
+ stage=Stage.DETERMINISTIC_ANCHOR,
233
+ severity=rule.severity,
234
+ message=rule.message,
235
+ type=rule.type,
236
+ evidence=FindingEvidence(
237
+ file=text_file.relative,
238
+ line=line_number,
239
+ code=line.strip()[:500],
240
+ ),
241
+ )
242
+ )
243
+ if len(findings) >= MAX_FINDINGS:
244
+ return
245
+
246
+
247
+ _SHELL_AND_PERSISTENCE_RULES: tuple[AnchorRule, ...] = (
248
+ AnchorRule(
249
+ "shell-exec",
250
+ Severity.HIGH,
251
+ # No leading \b: inside code the command often follows a literal "\n" escape.
252
+ re.compile(r"(?:curl|wget)\s[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b"),
253
+ "download piped straight into a shell interpreter",
254
+ ),
255
+ AnchorRule(
256
+ "shell-exec",
257
+ Severity.HIGH,
258
+ re.compile(
259
+ r"(?:Invoke-WebRequest|Invoke-RestMethod|\biwr\b|\birm\b|\bcurl\b|\bwget\b)"
260
+ r"[^\n|]*\|\s*(?:Invoke-Expression|\biex\b|powershell|pwsh)\b",
261
+ re.IGNORECASE,
262
+ ),
263
+ "download piped straight into PowerShell execution",
264
+ ),
265
+ AnchorRule(
266
+ "shell-exec",
267
+ Severity.HIGH,
268
+ re.compile(
269
+ r"\b(?:Invoke-Expression|iex)\b[^\n]*"
270
+ r"(?:DownloadString|DownloadFile|Invoke-WebRequest|\biwr\b|\birm\b)",
271
+ re.IGNORECASE,
272
+ ),
273
+ "download passed to PowerShell Invoke-Expression",
274
+ ),
275
+ AnchorRule(
276
+ "log-tampering",
277
+ Severity.HIGH,
278
+ re.compile(
279
+ r"\bhistory\s+-c\b|>\s*~?/?[^\s;]*\.(?:bash_|zsh_)?history\b|"
280
+ r"\brm\s+-[a-z]*f[a-z]*\s+[^\n;]*\.log\b"
281
+ ),
282
+ "shell history or log tampering",
283
+ ),
284
+ AnchorRule(
285
+ "persistence",
286
+ Severity.MEDIUM,
287
+ re.compile(
288
+ r"\.config/autostart/|\.(?:bash|zsh)rc\b|\.profile\b|"
289
+ r"\bcrontab\s+-|/etc/cron\.|\bsystemctl\s+enable\b"
290
+ ),
291
+ "writes to a startup, shell-profile or scheduled-task location",
292
+ ),
293
+ )
294
+
295
+ _SEVERITY_RANK = {
296
+ Severity.NOTE: 0,
297
+ Severity.LOW: 1,
298
+ Severity.MEDIUM: 2,
299
+ Severity.HIGH: 3,
300
+ Severity.CRITICAL: 4,
301
+ }
302
+
303
+
304
+ def _merge(findings: list[Finding], item: Finding) -> None:
305
+ """Add ``item``, or keep the stronger of two findings for the same file, line and type."""
306
+
307
+ for index, existing in enumerate(findings):
308
+ same_site = (
309
+ existing.evidence.file == item.evidence.file
310
+ and existing.evidence.line == item.evidence.line
311
+ and existing.finding_type == item.finding_type
312
+ )
313
+ if same_site:
314
+ if _SEVERITY_RANK[item.severity] > _SEVERITY_RANK[existing.severity]:
315
+ findings[index] = item
316
+ return
317
+ findings.append(item)
318
+
319
+
320
+ def scan_anchors(package: SkillPackage) -> StageResult:
321
+ """Run all deterministic rules and return a normalised stage result."""
322
+
323
+ findings: list[Finding] = []
324
+ seen: set[tuple[str, str, int]] = set()
325
+ # SKILL.md first so prompt-injection evidence is prominent in the UI.
326
+ ordered = sorted(
327
+ package.text_files, key=lambda item: (item.relative != "SKILL.md", item.relative)
328
+ )
329
+ for text_file in ordered:
330
+ _scan_file(
331
+ text_file,
332
+ _RULES + _SHELL_AND_PERSISTENCE_RULES + _INSTRUCTION_RULES,
333
+ seen=seen,
334
+ findings=findings,
335
+ )
336
+ if len(findings) >= MAX_FINDINGS:
337
+ break
338
+
339
+ parse_errors: list[str] = []
340
+ for text_file in ordered:
341
+ if not text_file.relative.lower().endswith(".py"):
342
+ continue
343
+ ast_findings, error = scan_python_ast(text_file.relative, text_file.text)
344
+ if error:
345
+ parse_errors.append(error)
346
+ for item in ast_findings:
347
+ _merge(findings, item)
348
+
349
+ findings.extend(analyze_credential_flows(package))
350
+
351
+ score = 0.0
352
+ if findings:
353
+ highest = max(_SEVERITY_SCORE[item.severity] for item in findings)
354
+ score = min(100.0, highest + min(12, (len(findings) - 1) * 3))
355
+ return StageResult(
356
+ stage=Stage.DETERMINISTIC_ANCHOR,
357
+ findings=findings,
358
+ signals={"anchors": score},
359
+ degraded=bool(parse_errors),
360
+ error="; ".join(parse_errors)[:1000] if parse_errors else None,
361
+ telemetry={
362
+ "files_scanned": len(package.text_files),
363
+ "findings": len(findings),
364
+ "prompt_injection_rules": len(_INSTRUCTION_RULES),
365
+ "credential_flow_rules_version": RULES_VERSION,
366
+ "python_parse_errors": len(parse_errors),
367
+ },
368
+ )
369
+
370
+
371
+ __all__ = ["AnchorRule", "scan_anchors"]
@@ -0,0 +1,156 @@
1
+ """AST-based detection for Python scripts.
2
+
3
+ Regex anchors miss aliased calls such as ``from subprocess import Popen as P``.
4
+ This visitor records import aliases and resolves each call to its real dotted
5
+ name before matching it against the rule table below. Findings use the same
6
+ finding types as the regex anchors, so fusion and reports treat them alike.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ from dataclasses import dataclass
13
+
14
+ from skill_sentry_cli.core.contracts import Finding, FindingEvidence, Severity, Stage
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class _Rule:
19
+ finding_type: str
20
+ severity: Severity
21
+ message: str
22
+
23
+
24
+ _CALL_RULES: dict[str, _Rule] = {
25
+ "os.system": _Rule("shell-exec", Severity.HIGH, "Shell command execution via os.system"),
26
+ "os.popen": _Rule("shell-exec", Severity.HIGH, "Shell command execution via os.popen"),
27
+ "pty.spawn": _Rule("shell-exec", Severity.HIGH, "Interactive shell spawn via pty.spawn"),
28
+ "eval": _Rule("dynamic-code", Severity.HIGH, "Dynamic code evaluation via eval"),
29
+ "exec": _Rule("dynamic-code", Severity.HIGH, "Dynamic code execution via exec"),
30
+ "compile": _Rule("dynamic-code", Severity.MEDIUM, "Runtime code compilation via compile"),
31
+ "pickle.loads": _Rule(
32
+ "unsafe-deserialization", Severity.HIGH, "Unsafe deserialization via pickle.loads"
33
+ ),
34
+ "pickle.load": _Rule(
35
+ "unsafe-deserialization", Severity.HIGH, "Unsafe deserialization via pickle.load"
36
+ ),
37
+ "marshal.loads": _Rule(
38
+ "unsafe-deserialization", Severity.HIGH, "Unsafe deserialization via marshal.loads"
39
+ ),
40
+ "socket.socket": _Rule("raw-socket", Severity.MEDIUM, "Raw socket creation"),
41
+ "socket.create_connection": _Rule("raw-socket", Severity.MEDIUM, "Outbound socket connection"),
42
+ "requests.post": _Rule("exfil-http-post", Severity.HIGH, "HTTP POST to a remote host"),
43
+ "requests.put": _Rule("exfil-http-post", Severity.HIGH, "HTTP PUT to a remote host"),
44
+ "requests.patch": _Rule("exfil-http-post", Severity.HIGH, "HTTP PATCH to a remote host"),
45
+ "httpx.post": _Rule("exfil-http-post", Severity.HIGH, "HTTP POST to a remote host"),
46
+ "urllib.request.urlopen": _Rule("exfil-http-post", Severity.MEDIUM, "URL fetch via urlopen"),
47
+ "dict": _Rule("secret-access", Severity.MEDIUM, "Whole-environment read"),
48
+ }
49
+
50
+ _ENV_DUMP_CALLS = frozenset({"os.environ.copy", "os.environ.items", "os.environ.keys"})
51
+
52
+
53
+ class _Visitor(ast.NodeVisitor):
54
+ def __init__(self, filename: str) -> None:
55
+ self.filename = filename
56
+ self.aliases: dict[str, str] = {}
57
+ self.findings: list[Finding] = []
58
+
59
+ def visit_Import(self, node: ast.Import) -> None:
60
+ for alias in node.names:
61
+ self.aliases[alias.asname or alias.name.split(".")[0]] = (
62
+ alias.name if alias.asname else alias.name.split(".")[0]
63
+ )
64
+ self.generic_visit(node)
65
+
66
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
67
+ if node.module and node.level == 0:
68
+ for alias in node.names:
69
+ self.aliases[alias.asname or alias.name] = f"{node.module}.{alias.name}"
70
+ self.generic_visit(node)
71
+
72
+ def _resolve(self, node: ast.expr) -> str | None:
73
+ if isinstance(node, ast.Name):
74
+ return self.aliases.get(node.id, node.id)
75
+ if isinstance(node, ast.Attribute):
76
+ base = self._resolve(node.value)
77
+ return f"{base}.{node.attr}" if base else None
78
+ return None
79
+
80
+ def visit_Call(self, node: ast.Call) -> None:
81
+ name = self._resolve(node.func)
82
+ if name is not None:
83
+ self._check(node, name)
84
+ self.generic_visit(node)
85
+
86
+ def _check(self, node: ast.Call, name: str) -> None:
87
+ if name in (
88
+ "subprocess.run",
89
+ "subprocess.call",
90
+ "subprocess.Popen",
91
+ "subprocess.check_output",
92
+ "subprocess.check_call",
93
+ "subprocess.getoutput",
94
+ "subprocess.getstatusoutput",
95
+ ):
96
+ if any(
97
+ keyword.arg == "shell"
98
+ and isinstance(keyword.value, ast.Constant)
99
+ and keyword.value.value is True
100
+ for keyword in node.keywords
101
+ ):
102
+ self._add(
103
+ node, "shell-exec", Severity.HIGH, f"Shell execution with shell=True via {name}"
104
+ )
105
+ return
106
+ if name in _ENV_DUMP_CALLS:
107
+ self._add(node, "secret-access", Severity.MEDIUM, f"Whole-environment read via {name}")
108
+ return
109
+ if name == "yaml.load" and not any(keyword.arg == "Loader" for keyword in node.keywords):
110
+ self._add(
111
+ node, "unsafe-deserialization", Severity.HIGH, "Unsafe YAML load without SafeLoader"
112
+ )
113
+ return
114
+ rule = _CALL_RULES.get(name)
115
+ if rule is None:
116
+ return
117
+ if name == "dict" and not (node.args and self._is_environ(node.args[0])):
118
+ return
119
+ if name == "urllib.request.urlopen" and not any(
120
+ keyword.arg == "data" for keyword in node.keywords
121
+ ):
122
+ return
123
+ self._add(node, rule.finding_type, rule.severity, rule.message)
124
+
125
+ def _is_environ(self, node: ast.expr) -> bool:
126
+ return self._resolve(node) == "os.environ"
127
+
128
+ def _add(self, node: ast.Call, kind: str, severity: Severity, message: str) -> None:
129
+ line = getattr(node, "lineno", 1)
130
+ self.findings.append(
131
+ Finding(
132
+ stage=Stage.DETERMINISTIC_ANCHOR,
133
+ severity=severity,
134
+ message=message,
135
+ type=kind,
136
+ evidence=FindingEvidence(file=self.filename, line=line, code=f"line {line}"),
137
+ )
138
+ )
139
+
140
+
141
+ def scan_python_ast(filename: str, text: str) -> tuple[list[Finding], str | None]:
142
+ """Return findings for one Python file, and an error message when it does not parse.
143
+
144
+ An unparseable file is never skipped silently: the caller marks the stage degraded.
145
+ """
146
+
147
+ try:
148
+ tree = ast.parse(text, filename=filename)
149
+ except (SyntaxError, ValueError, RecursionError) as exc:
150
+ return [], f"could not parse {filename}: {type(exc).__name__}"
151
+ visitor = _Visitor(filename)
152
+ visitor.visit(tree)
153
+ return visitor.findings, None
154
+
155
+
156
+ __all__ = ["scan_python_ast"]
@@ -0,0 +1,78 @@
1
+ """Deterministic evidence candidates for typed decision backends.
2
+
3
+ Candidates come only from deterministic anchor findings. A backend may
4
+ classify a candidate, but it never supplies or invents the file, line, or code;
5
+ those stay code-owned and are copied into any finding that cites them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from skill_sentry_cli.core.contracts import Severity, StageResult
11
+ from skill_sentry_cli.core.decisions.base import EvidenceCandidate
12
+
13
+ MAX_CANDIDATES = 20
14
+ MAX_CODE_CHARS = 400
15
+
16
+
17
+ def build_candidates(
18
+ anchors: StageResult,
19
+ *,
20
+ limit: int = MAX_CANDIDATES,
21
+ ) -> tuple[list[EvidenceCandidate], int]:
22
+ """Return ``(candidates, omitted)`` in anchor order.
23
+
24
+ Notes, locationless findings, and duplicate ``(file, line)`` pairs are not
25
+ candidates. ``omitted`` counts anchors dropped by ``limit`` so callers can
26
+ report the truncation instead of hiding it.
27
+ """
28
+
29
+ seen: set[tuple[str, int]] = set()
30
+ candidates: list[EvidenceCandidate] = []
31
+ omitted = 0
32
+ for finding in anchors.findings:
33
+ if finding.severity is Severity.NOTE:
34
+ continue
35
+ file = finding.evidence.file
36
+ line = finding.evidence.line
37
+ code = (finding.evidence.code or "").strip()
38
+ if not file or not line or not code:
39
+ continue
40
+ key = (file, line)
41
+ if key in seen:
42
+ continue
43
+ seen.add(key)
44
+ if len(candidates) >= limit:
45
+ omitted += 1
46
+ continue
47
+ try:
48
+ candidates.append(
49
+ EvidenceCandidate(
50
+ candidate_id=f"anchor-{len(candidates) + 1}",
51
+ file=file,
52
+ line=line,
53
+ code=code[:MAX_CODE_CHARS],
54
+ anchor_type=finding.finding_type or "anchor",
55
+ )
56
+ )
57
+ except ValueError:
58
+ # A file path the contract rejects is not evidence we can cite.
59
+ continue
60
+ return candidates, omitted
61
+
62
+
63
+ def candidate_state(candidates: list[EvidenceCandidate]) -> list[dict[str, object]]:
64
+ """Serialise candidates for the decision state; no new locations are added."""
65
+
66
+ return [
67
+ {
68
+ "candidate_id": candidate.candidate_id,
69
+ "type": candidate.anchor_type,
70
+ "file": candidate.file,
71
+ "line": candidate.line,
72
+ "code": candidate.code,
73
+ }
74
+ for candidate in candidates
75
+ ]
76
+
77
+
78
+ __all__ = ["MAX_CANDIDATES", "build_candidates", "candidate_state"]