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