agentscanner 0.1.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.
- agentscanner/__init__.py +3 -0
- agentscanner/checks/__init__.py +13 -0
- agentscanner/checks/agentic_skills_ast.py +487 -0
- agentscanner/checks/agents_skills.py +73 -0
- agentscanner/checks/base.py +63 -0
- agentscanner/checks/env_secrets.py +115 -0
- agentscanner/checks/hooks.py +150 -0
- agentscanner/checks/mcp.py +148 -0
- agentscanner/checks/permissions.py +139 -0
- agentscanner/checks/prompts.py +59 -0
- agentscanner/cli.py +115 -0
- agentscanner/data.py +82 -0
- agentscanner/discovery.py +143 -0
- agentscanner/engine.py +54 -0
- agentscanner/models.py +112 -0
- agentscanner/parsers/__init__.py +1 -0
- agentscanner/parsers/json_parser.py +37 -0
- agentscanner/parsers/markdown_parser.py +37 -0
- agentscanner/reporters/__init__.py +17 -0
- agentscanner/reporters/cli.py +67 -0
- agentscanner/reporters/json.py +22 -0
- agentscanner/reporters/sarif.py +71 -0
- agentscanner-0.1.0.dist-info/METADATA +127 -0
- agentscanner-0.1.0.dist-info/RECORD +27 -0
- agentscanner-0.1.0.dist-info/WHEEL +4 -0
- agentscanner-0.1.0.dist-info/entry_points.txt +2 -0
- agentscanner-0.1.0.dist-info/licenses/LICENSE +201 -0
agentscanner/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Check registry. Importing this package registers all built-in checks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .base import CHECK_REGISTRY, Check, get_checks # noqa: F401
|
|
5
|
+
|
|
6
|
+
# Import modules for their @register side effects.
|
|
7
|
+
from . import permissions # noqa: F401,E402
|
|
8
|
+
from . import hooks # noqa: F401,E402
|
|
9
|
+
from . import mcp # noqa: F401,E402
|
|
10
|
+
from . import env_secrets # noqa: F401,E402
|
|
11
|
+
from . import agents_skills # noqa: F401,E402
|
|
12
|
+
from . import prompts # noqa: F401,E402
|
|
13
|
+
from . import agentic_skills_ast # noqa: F401,E402
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""OWASP Agentic Skills Top 10 checks (AS-SKILL-*).
|
|
2
|
+
|
|
3
|
+
All ten AST risks are covered with deterministic, zero-FP detections:
|
|
4
|
+
|
|
5
|
+
AS-SKILL-001 AST01 – Identity-file write access (Malicious Skills)
|
|
6
|
+
AS-SKILL-002 AST01 – Social-engineering Prerequisites section (Malicious Skills)
|
|
7
|
+
AS-SKILL-003 AST02 – Missing signature on Universal-Format skill (Supply Chain)
|
|
8
|
+
AS-SKILL-004 AST03 – network: true boolean over-grant (Over-Privileged Skills)
|
|
9
|
+
AS-SKILL-005 AST03 – shell: true explicit access (Over-Privileged Skills)
|
|
10
|
+
AS-SKILL-006 AST04 – risk_tier/permissions contradiction (Insecure Metadata)
|
|
11
|
+
AS-SKILL-007 AST05 – YAML unsafe execution tags in raw file (Unsafe Deserialization)
|
|
12
|
+
AS-SKILL-008 AST06 – sandboxed_execution: false (Weak Isolation)
|
|
13
|
+
AS-SKILL-009 AST07 – Missing version on Universal-Format skill (Update Drift)
|
|
14
|
+
AS-SKILL-010 AST08 – Standalone base64 block in prose (Poor Scanning evasion)
|
|
15
|
+
AS-SKILL-011 AST09 – Missing publisher on Universal-Format skill (No Governance)
|
|
16
|
+
AS-SKILL-012 AST10 – Multi-platform skill missing signature (Cross-Platform Reuse)
|
|
17
|
+
|
|
18
|
+
Absence checks (AS-SKILL-003, 009, 011, 012) are gated: they only fire when the skill
|
|
19
|
+
already declares at least one Universal Agentic Skill Format field (risk_tier, platforms,
|
|
20
|
+
signature, publisher, content_hash, scan_status, signing_key). This avoids drowning every
|
|
21
|
+
legacy Claude Code skill in provenance findings for fields the format never required.
|
|
22
|
+
|
|
23
|
+
Reference: OWASP Agentic Skills Top 10 v0.5, June 2026
|
|
24
|
+
https://owasp.org/www-project-agentic-skills-top-10/
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Iterable, List
|
|
31
|
+
|
|
32
|
+
from ..models import ArtifactType, Finding, Severity
|
|
33
|
+
from .base import Check, register
|
|
34
|
+
|
|
35
|
+
# ── Universal Agentic Skill Format opt-in detection ─────────────────────────
|
|
36
|
+
# Keys proposed by the OWASP AST doc's Universal Skill Format (v0.5 pp.39-40).
|
|
37
|
+
# Presence of ANY of these means the author adopted the format; absence checks
|
|
38
|
+
# are only surfaced in that case.
|
|
39
|
+
_UNIVERSAL_FORMAT_KEYS = frozenset({
|
|
40
|
+
"signature", "content_hash", "risk_tier", "publisher",
|
|
41
|
+
"scan_status", "platforms", "signing_key",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _uses_universal_format(fm: dict) -> bool:
|
|
46
|
+
return bool(_UNIVERSAL_FORMAT_KEYS & set(fm.keys()))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Identity files a skill must never be granted write access to ─────────────
|
|
50
|
+
_IDENTITY_FILES = frozenset({
|
|
51
|
+
"soul.md", "memory.md", "agents.md",
|
|
52
|
+
".soul.md", ".memory.md", ".agents.md",
|
|
53
|
+
"claude.md",
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _names_identity_file(path_str: str) -> bool:
|
|
58
|
+
return Path(path_str).name.lower() in _IDENTITY_FILES
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _collect_write_paths(fm: dict) -> List[str]:
|
|
62
|
+
"""Return all write-path strings from a skill's permission manifest."""
|
|
63
|
+
perms = fm.get("permissions", {})
|
|
64
|
+
if not isinstance(perms, dict):
|
|
65
|
+
return []
|
|
66
|
+
write: List[str] = []
|
|
67
|
+
files = perms.get("files", {})
|
|
68
|
+
if isinstance(files, dict):
|
|
69
|
+
w = files.get("write", [])
|
|
70
|
+
if isinstance(w, list):
|
|
71
|
+
write.extend(str(p) for p in w)
|
|
72
|
+
elif isinstance(w, str):
|
|
73
|
+
write.append(w)
|
|
74
|
+
flat_w = perms.get("write", [])
|
|
75
|
+
if isinstance(flat_w, list):
|
|
76
|
+
write.extend(str(p) for p in flat_w)
|
|
77
|
+
elif isinstance(flat_w, str):
|
|
78
|
+
write.append(flat_w)
|
|
79
|
+
return write
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── Social-engineering Prerequisites pattern ─────────────────────────────────
|
|
83
|
+
_PREREQS_HEADING = re.compile(r"(?im)^#+\s*prerequisites\s*$")
|
|
84
|
+
_PIPE_TO_SHELL = re.compile(
|
|
85
|
+
r"(?i)\b(curl|wget)\b[^|\n]{0,120}\|\s*(?:(?:ba|da)?sh|python\d?|pwsh)\b"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ── YAML unsafe execution tags ───────────────────────────────────────────────
|
|
89
|
+
_YAML_EXEC_TAGS = re.compile(
|
|
90
|
+
r"!!\s*(python/object|python/apply|python/name|python/module|python/reduce)\b",
|
|
91
|
+
re.IGNORECASE,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# ── Standalone base64 block (obfuscated payload) ─────────────────────────────
|
|
95
|
+
# A line consisting only of base64 characters, ≥ 60 chars — too long to be an
|
|
96
|
+
# inline value and too clean to be prose; used to hide NL payloads from scanners.
|
|
97
|
+
_BASE64_LINE = re.compile(r"(?m)^[A-Za-z0-9+/]{60,}={0,2}$")
|
|
98
|
+
|
|
99
|
+
# ── Safe risk tiers (L0 / L1 / equivalent) ───────────────────────────────────
|
|
100
|
+
_SAFE_TIERS = frozenset({"l0", "l1", "low", "0", "1", "safe"})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
# AS-SKILL-001 AST01 – Identity-file write access
|
|
105
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
@register
|
|
108
|
+
class SkillIdentityFileWrite(Check):
|
|
109
|
+
id = "AS-SKILL-001"
|
|
110
|
+
severity = Severity.CRITICAL
|
|
111
|
+
title = "Skill requests write access to agent identity files"
|
|
112
|
+
applies_to = {ArtifactType.SKILL}
|
|
113
|
+
framework = "OWASP Agentic Skills AST01 – Malicious Skills"
|
|
114
|
+
remediation = (
|
|
115
|
+
"Remove SOUL.md, MEMORY.md, AGENTS.md, and CLAUDE.md from permissions.files.write. "
|
|
116
|
+
"Skills that write identity files persist backdoor instructions beyond uninstall."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
120
|
+
fm = resource.frontmatter or {}
|
|
121
|
+
if not isinstance(fm, dict):
|
|
122
|
+
return
|
|
123
|
+
for path_str in _collect_write_paths(fm):
|
|
124
|
+
if _names_identity_file(path_str):
|
|
125
|
+
yield self.finding(
|
|
126
|
+
resource,
|
|
127
|
+
f"Skill declares write access to identity file {path_str!r} — "
|
|
128
|
+
"enables persistent backdoor instructions that survive skill uninstall.",
|
|
129
|
+
line=resource.line_of(path_str),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
# AS-SKILL-002 AST01 – Social-engineering Prerequisites section
|
|
135
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
@register
|
|
138
|
+
class SkillSocialEngineeringPrereqs(Check):
|
|
139
|
+
id = "AS-SKILL-002"
|
|
140
|
+
severity = Severity.HIGH
|
|
141
|
+
title = "Skill has social-engineering Prerequisites section with pipe-to-shell"
|
|
142
|
+
applies_to = {ArtifactType.SKILL}
|
|
143
|
+
framework = "OWASP Agentic Skills AST01 – Malicious Skills"
|
|
144
|
+
remediation = (
|
|
145
|
+
"Remove Prerequisites sections that instruct users to run curl|sh or wget|sh. "
|
|
146
|
+
"Vendor dependencies locally and invoke them from an absolute, trusted path."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
150
|
+
body = resource.body or ""
|
|
151
|
+
m = _PREREQS_HEADING.search(body)
|
|
152
|
+
if not m:
|
|
153
|
+
return
|
|
154
|
+
window = body[m.end(): m.end() + 600]
|
|
155
|
+
pipe_m = _PIPE_TO_SHELL.search(window)
|
|
156
|
+
if pipe_m:
|
|
157
|
+
snippet = pipe_m.group(0).strip()
|
|
158
|
+
yield self.finding(
|
|
159
|
+
resource,
|
|
160
|
+
f"Skill 'Prerequisites' section instructs users to pipe a remote download "
|
|
161
|
+
f"into a shell: {snippet!r}",
|
|
162
|
+
line=resource.line_of("Prerequisites"),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
167
|
+
# AS-SKILL-003 AST02 – Missing signature (Universal-Format opt-in)
|
|
168
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
@register
|
|
171
|
+
class SkillMissingSignature(Check):
|
|
172
|
+
id = "AS-SKILL-003"
|
|
173
|
+
severity = Severity.HIGH
|
|
174
|
+
title = "Universal-Format skill missing cryptographic signature"
|
|
175
|
+
applies_to = {ArtifactType.SKILL}
|
|
176
|
+
framework = "OWASP Agentic Skills AST02 – Supply Chain Compromise"
|
|
177
|
+
remediation = (
|
|
178
|
+
"Add a 'signature' field (ed25519 over the canonical content hash). "
|
|
179
|
+
"Without it, any compromise of the distribution channel is undetectable."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
183
|
+
fm = resource.frontmatter or {}
|
|
184
|
+
if not isinstance(fm, dict) or not _uses_universal_format(fm):
|
|
185
|
+
return
|
|
186
|
+
if not fm.get("signature"):
|
|
187
|
+
yield self.finding(
|
|
188
|
+
resource,
|
|
189
|
+
"Skill uses Universal Agentic Skill Format fields but has no 'signature' — "
|
|
190
|
+
"integrity cannot be verified across distribution channels.",
|
|
191
|
+
line=1,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
196
|
+
# AS-SKILL-004 AST03 – network: true boolean over-grant
|
|
197
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
@register
|
|
200
|
+
class SkillNetworkBooleanOverGrant(Check):
|
|
201
|
+
id = "AS-SKILL-004"
|
|
202
|
+
severity = Severity.HIGH
|
|
203
|
+
title = "Skill sets permissions.network: true (binary boolean, not domain allowlist)"
|
|
204
|
+
applies_to = {ArtifactType.SKILL}
|
|
205
|
+
framework = "OWASP Agentic Skills AST03 – Over-Privileged Skills"
|
|
206
|
+
remediation = (
|
|
207
|
+
"Replace 'network: true' with a domain allowlist: "
|
|
208
|
+
"network: {allow: [\"api.example.com\"], deny: \"*\"}. "
|
|
209
|
+
"A boolean true grants unrestricted egress to any host."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
213
|
+
fm = resource.frontmatter or {}
|
|
214
|
+
if not isinstance(fm, dict):
|
|
215
|
+
return
|
|
216
|
+
perms = fm.get("permissions", {})
|
|
217
|
+
if isinstance(perms, dict) and perms.get("network") is True:
|
|
218
|
+
yield self.finding(
|
|
219
|
+
resource,
|
|
220
|
+
"permissions.network is 'true' — grants unrestricted outbound network "
|
|
221
|
+
"access instead of a scoped domain allowlist.",
|
|
222
|
+
line=resource.line_of("network"),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
227
|
+
# AS-SKILL-005 AST03 – shell: true explicit access
|
|
228
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
@register
|
|
231
|
+
class SkillShellAccess(Check):
|
|
232
|
+
id = "AS-SKILL-005"
|
|
233
|
+
severity = Severity.HIGH
|
|
234
|
+
title = "Skill declares explicit shell access"
|
|
235
|
+
applies_to = {ArtifactType.SKILL}
|
|
236
|
+
framework = "OWASP Agentic Skills AST03 – Over-Privileged Skills"
|
|
237
|
+
remediation = (
|
|
238
|
+
"Remove 'shell: true'. Use parameterized tool calls scoped to the specific "
|
|
239
|
+
"operations the skill requires. Shell access grants arbitrary code execution."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
243
|
+
fm = resource.frontmatter or {}
|
|
244
|
+
if not isinstance(fm, dict):
|
|
245
|
+
return
|
|
246
|
+
perms = fm.get("permissions", {})
|
|
247
|
+
if isinstance(perms, dict) and perms.get("shell") is True:
|
|
248
|
+
yield self.finding(
|
|
249
|
+
resource,
|
|
250
|
+
"permissions.shell is 'true' — grants arbitrary shell command execution "
|
|
251
|
+
"beyond the skill's stated function.",
|
|
252
|
+
line=resource.line_of("shell"),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
257
|
+
# AS-SKILL-006 AST04 – risk_tier / permissions contradiction
|
|
258
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
@register
|
|
261
|
+
class SkillRiskTierContradiction(Check):
|
|
262
|
+
id = "AS-SKILL-006"
|
|
263
|
+
severity = Severity.HIGH
|
|
264
|
+
title = "Skill risk_tier contradicts declared permissions (risk tier spoofing)"
|
|
265
|
+
applies_to = {ArtifactType.SKILL}
|
|
266
|
+
framework = "OWASP Agentic Skills AST04 – Insecure Metadata"
|
|
267
|
+
remediation = (
|
|
268
|
+
"Align risk_tier with actual permissions. L0/L1 may not be combined with "
|
|
269
|
+
"shell: true, sandboxed_execution: false, or identity-file write access."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
273
|
+
fm = resource.frontmatter or {}
|
|
274
|
+
if not isinstance(fm, dict):
|
|
275
|
+
return
|
|
276
|
+
tier = str(fm.get("risk_tier", "")).strip().lower()
|
|
277
|
+
if not tier or tier not in _SAFE_TIERS:
|
|
278
|
+
return # not claiming to be safe/low-risk
|
|
279
|
+
|
|
280
|
+
perms = fm.get("permissions", {}) if isinstance(fm.get("permissions"), dict) else {}
|
|
281
|
+
dangerous = []
|
|
282
|
+
if perms.get("shell") is True:
|
|
283
|
+
dangerous.append("permissions.shell: true")
|
|
284
|
+
if fm.get("sandboxed_execution") is False:
|
|
285
|
+
dangerous.append("sandboxed_execution: false")
|
|
286
|
+
for p in _collect_write_paths(fm):
|
|
287
|
+
if _names_identity_file(p):
|
|
288
|
+
dangerous.append(f"write to {p!r}")
|
|
289
|
+
|
|
290
|
+
if dangerous:
|
|
291
|
+
yield self.finding(
|
|
292
|
+
resource,
|
|
293
|
+
f"Skill declares risk_tier={tier!r} (safe) but also has: "
|
|
294
|
+
f"{', '.join(dangerous)} — contradicts the declared safety level.",
|
|
295
|
+
line=resource.line_of("risk_tier"),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
300
|
+
# AS-SKILL-007 AST05 – YAML unsafe execution tags
|
|
301
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
@register
|
|
304
|
+
class SkillYamlExecTags(Check):
|
|
305
|
+
id = "AS-SKILL-007"
|
|
306
|
+
severity = Severity.CRITICAL
|
|
307
|
+
title = "Skill file contains YAML unsafe execution tags"
|
|
308
|
+
applies_to = {ArtifactType.SKILL}
|
|
309
|
+
framework = "OWASP Agentic Skills AST05 – Unsafe Deserialization"
|
|
310
|
+
remediation = (
|
|
311
|
+
"Remove !!python/object, !!python/apply, and related YAML constructor tags. "
|
|
312
|
+
"These execute arbitrary code when parsed by an unsafe YAML loader."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
316
|
+
m = _YAML_EXEC_TAGS.search(resource.raw_text)
|
|
317
|
+
if m:
|
|
318
|
+
snippet = m.group(0)
|
|
319
|
+
line = resource.raw_text.count("\n", 0, m.start()) + 1
|
|
320
|
+
yield self.finding(
|
|
321
|
+
resource,
|
|
322
|
+
f"File contains YAML unsafe execution tag {snippet!r} — triggers "
|
|
323
|
+
"arbitrary code execution when parsed by an unsafe YAML loader.",
|
|
324
|
+
line=line,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
329
|
+
# AS-SKILL-008 AST06 – sandboxed_execution: false
|
|
330
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
@register
|
|
333
|
+
class SkillSandboxDisabled(Check):
|
|
334
|
+
id = "AS-SKILL-008"
|
|
335
|
+
severity = Severity.HIGH
|
|
336
|
+
title = "Skill explicitly disables sandboxed execution"
|
|
337
|
+
applies_to = {ArtifactType.SKILL}
|
|
338
|
+
framework = "OWASP Agentic Skills AST06 – Weak Isolation"
|
|
339
|
+
remediation = (
|
|
340
|
+
"Remove 'sandboxed_execution: false'. Skills should run isolated in a container "
|
|
341
|
+
"or sandbox; opting out removes all containment guarantees."
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
345
|
+
fm = resource.frontmatter or {}
|
|
346
|
+
if not isinstance(fm, dict):
|
|
347
|
+
return
|
|
348
|
+
if fm.get("sandboxed_execution") is False:
|
|
349
|
+
yield self.finding(
|
|
350
|
+
resource,
|
|
351
|
+
"sandboxed_execution: false — skill opts out of all isolation, "
|
|
352
|
+
"enabling direct access to the host environment.",
|
|
353
|
+
line=resource.line_of("sandboxed_execution"),
|
|
354
|
+
)
|
|
355
|
+
return
|
|
356
|
+
security = fm.get("security", {})
|
|
357
|
+
if isinstance(security, dict) and (
|
|
358
|
+
security.get("sandboxed_execution") is False
|
|
359
|
+
or security.get("sandbox") is False
|
|
360
|
+
):
|
|
361
|
+
key = "sandboxed_execution" if security.get("sandboxed_execution") is False else "sandbox"
|
|
362
|
+
yield self.finding(
|
|
363
|
+
resource,
|
|
364
|
+
f"security.{key}: false — skill opts out of all isolation.",
|
|
365
|
+
line=resource.line_of(key),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
370
|
+
# AS-SKILL-009 AST07 – Missing version (Universal-Format opt-in)
|
|
371
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
@register
|
|
374
|
+
class SkillMissingVersion(Check):
|
|
375
|
+
id = "AS-SKILL-009"
|
|
376
|
+
severity = Severity.MEDIUM
|
|
377
|
+
title = "Universal-Format skill missing version field (update drift risk)"
|
|
378
|
+
applies_to = {ArtifactType.SKILL}
|
|
379
|
+
framework = "OWASP Agentic Skills AST07 – Update Drift"
|
|
380
|
+
remediation = (
|
|
381
|
+
"Add a 'version' field (semver). Without it, agents cannot detect whether an "
|
|
382
|
+
"installed skill has drifted from a known-good pinned version."
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
386
|
+
fm = resource.frontmatter or {}
|
|
387
|
+
if not isinstance(fm, dict) or not _uses_universal_format(fm):
|
|
388
|
+
return
|
|
389
|
+
if not fm.get("version"):
|
|
390
|
+
yield self.finding(
|
|
391
|
+
resource,
|
|
392
|
+
"Skill uses Universal Agentic Skill Format fields but has no 'version' — "
|
|
393
|
+
"update drift and malicious patch substitution cannot be detected.",
|
|
394
|
+
line=1,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
399
|
+
# AS-SKILL-010 AST08 – Standalone base64 block (Poor Scanning evasion)
|
|
400
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
@register
|
|
403
|
+
class SkillStandaloneBase64(Check):
|
|
404
|
+
id = "AS-SKILL-010"
|
|
405
|
+
severity = Severity.MEDIUM
|
|
406
|
+
title = "Skill body contains standalone base64-encoded block (obfuscated payload)"
|
|
407
|
+
applies_to = {ArtifactType.SKILL}
|
|
408
|
+
framework = "OWASP Agentic Skills AST08 – Poor Scanning"
|
|
409
|
+
remediation = (
|
|
410
|
+
"Remove base64-encoded content from skill instructions. Encoded blobs hide "
|
|
411
|
+
"malicious payloads from human reviewers and pattern-matching scanners while "
|
|
412
|
+
"remaining decodable and executable by the agent at runtime."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
416
|
+
body = resource.body or ""
|
|
417
|
+
m = _BASE64_LINE.search(body)
|
|
418
|
+
if m:
|
|
419
|
+
snippet = m.group(0)[:20] + "…"
|
|
420
|
+
yield self.finding(
|
|
421
|
+
resource,
|
|
422
|
+
f"Skill body contains a standalone base64-encoded line starting with "
|
|
423
|
+
f"{snippet!r} — potential obfuscated payload invisible to pattern-matching scanners.",
|
|
424
|
+
line=resource.line_of(m.group(0)[:30]),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
429
|
+
# AS-SKILL-011 AST09 – Missing publisher (Universal-Format opt-in)
|
|
430
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
@register
|
|
433
|
+
class SkillMissingPublisher(Check):
|
|
434
|
+
id = "AS-SKILL-011"
|
|
435
|
+
severity = Severity.MEDIUM
|
|
436
|
+
title = "Universal-Format skill missing publisher field (governance gap)"
|
|
437
|
+
applies_to = {ArtifactType.SKILL}
|
|
438
|
+
framework = "OWASP Agentic Skills AST09 – No Governance"
|
|
439
|
+
remediation = (
|
|
440
|
+
"Add a 'publisher' field with a verified identity. Without it the skill cannot "
|
|
441
|
+
"be tied to an accountable party, inventoried, or revoked by a governance process."
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
445
|
+
fm = resource.frontmatter or {}
|
|
446
|
+
if not isinstance(fm, dict) or not _uses_universal_format(fm):
|
|
447
|
+
return
|
|
448
|
+
if not fm.get("publisher"):
|
|
449
|
+
yield self.finding(
|
|
450
|
+
resource,
|
|
451
|
+
"Skill uses Universal Agentic Skill Format fields but has no 'publisher' — "
|
|
452
|
+
"cannot be inventoried, audited, or revoked by a governance process.",
|
|
453
|
+
line=1,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
458
|
+
# AS-SKILL-012 AST10 – Multi-platform skill missing signature
|
|
459
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
@register
|
|
462
|
+
class SkillMultiPlatformMissingSignature(Check):
|
|
463
|
+
id = "AS-SKILL-012"
|
|
464
|
+
severity = Severity.MEDIUM
|
|
465
|
+
title = "Multi-platform skill missing signature (security metadata lost in translation)"
|
|
466
|
+
applies_to = {ArtifactType.SKILL}
|
|
467
|
+
framework = "OWASP Agentic Skills AST10 – Cross-Platform Reuse"
|
|
468
|
+
remediation = (
|
|
469
|
+
"Add a 'signature' field when declaring multiple platforms. Security metadata "
|
|
470
|
+
"(risk_tier, permissions) is stripped when skills are ported between formats; "
|
|
471
|
+
"a cryptographic signature is the only cross-platform integrity anchor."
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
475
|
+
fm = resource.frontmatter or {}
|
|
476
|
+
if not isinstance(fm, dict):
|
|
477
|
+
return
|
|
478
|
+
platforms = fm.get("platforms", [])
|
|
479
|
+
if not isinstance(platforms, list) or len(platforms) <= 1:
|
|
480
|
+
return # single-platform or unspecified — not a cross-platform reuse scenario
|
|
481
|
+
if not fm.get("signature"):
|
|
482
|
+
yield self.finding(
|
|
483
|
+
resource,
|
|
484
|
+
f"Skill targets {len(platforms)} platforms {platforms!r} but has no "
|
|
485
|
+
"'signature' — security properties are unverifiable after cross-platform porting.",
|
|
486
|
+
line=resource.line_of("platforms"),
|
|
487
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Agent & skill privilege checks (AS-AGENT-*)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Iterable, List
|
|
5
|
+
|
|
6
|
+
from ..models import ArtifactType, Finding, Severity
|
|
7
|
+
from .base import Check, register
|
|
8
|
+
|
|
9
|
+
_BROAD = {"*", "all", "all tools"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _tokens(value) -> List[str]:
|
|
13
|
+
if isinstance(value, str):
|
|
14
|
+
return [t.strip().lower() for t in value.split(",") if t.strip()]
|
|
15
|
+
if isinstance(value, list):
|
|
16
|
+
return [str(t).strip().lower() for t in value]
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_broad(value) -> bool:
|
|
21
|
+
toks = _tokens(value)
|
|
22
|
+
return any(t in _BROAD for t in toks)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@register
|
|
26
|
+
class OverPrivilegedAgent(Check):
|
|
27
|
+
id = "AS-AGENT-001"
|
|
28
|
+
severity = Severity.HIGH
|
|
29
|
+
title = "Over-privileged agent or skill"
|
|
30
|
+
applies_to = {ArtifactType.AGENT, ArtifactType.SKILL, ArtifactType.COMMAND}
|
|
31
|
+
framework = "OWASP LLM06 Excessive Agency"
|
|
32
|
+
remediation = (
|
|
33
|
+
"Avoid permissionMode 'bypassPermissions'/'acceptEdits' in agents and "
|
|
34
|
+
"restrict 'tools'/'allowed-tools' to the minimum the agent needs."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def analyze(self, resource) -> Iterable[Finding]:
|
|
38
|
+
fm = resource.frontmatter or {}
|
|
39
|
+
if not isinstance(fm, dict):
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
mode = fm.get("permissionMode")
|
|
43
|
+
tools_field = fm.get("tools", fm.get("allowed-tools"))
|
|
44
|
+
broad = _is_broad(tools_field)
|
|
45
|
+
|
|
46
|
+
if mode == "bypassPermissions":
|
|
47
|
+
yield self.finding(
|
|
48
|
+
resource,
|
|
49
|
+
"Agent sets permissionMode 'bypassPermissions' — it acts without "
|
|
50
|
+
"approval prompts" + (" with access to all tools." if broad or tools_field is None else "."),
|
|
51
|
+
line=resource.line_of("permissionMode"),
|
|
52
|
+
)
|
|
53
|
+
elif mode == "acceptEdits":
|
|
54
|
+
f = self.finding(
|
|
55
|
+
resource,
|
|
56
|
+
"Agent sets permissionMode 'acceptEdits' — file edits are "
|
|
57
|
+
"auto-accepted." + (" Combined with broad tool access." if broad else ""),
|
|
58
|
+
line=resource.line_of("permissionMode"),
|
|
59
|
+
)
|
|
60
|
+
f.severity = Severity.HIGH if broad else Severity.MEDIUM
|
|
61
|
+
yield f
|
|
62
|
+
elif broad and resource.type == ArtifactType.SKILL:
|
|
63
|
+
# an auto-invocable skill granting all tools
|
|
64
|
+
auto = not fm.get("disable-model-invocation", False)
|
|
65
|
+
if auto:
|
|
66
|
+
f = self.finding(
|
|
67
|
+
resource,
|
|
68
|
+
"Auto-invocable skill grants all tools (allowed-tools: *). "
|
|
69
|
+
"Scope tools or set disable-model-invocation.",
|
|
70
|
+
line=resource.line_of("allowed-tools"),
|
|
71
|
+
)
|
|
72
|
+
f.severity = Severity.MEDIUM
|
|
73
|
+
yield f
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Check abstract base class + registry.
|
|
2
|
+
|
|
3
|
+
Each check declares the artifact types it applies to and yields Findings. The
|
|
4
|
+
engine instantiates every registered check once and dispatches resources to it.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Iterable, List, Set, Type
|
|
9
|
+
|
|
10
|
+
from ..models import ArtifactType, Finding, Resource, Severity
|
|
11
|
+
|
|
12
|
+
CHECK_REGISTRY: Dict[str, "Check"] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Check:
|
|
16
|
+
id: str = ""
|
|
17
|
+
severity: Severity = Severity.MEDIUM
|
|
18
|
+
title: str = ""
|
|
19
|
+
applies_to: Set[ArtifactType] = set()
|
|
20
|
+
remediation: str = ""
|
|
21
|
+
framework: str = ""
|
|
22
|
+
|
|
23
|
+
def analyze(self, resource: Resource) -> Iterable[Finding]: # pragma: no cover
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
# convenience to build a Finding with the check's metadata
|
|
27
|
+
def finding(self, resource: Resource, message: str, line: int = 1) -> Finding:
|
|
28
|
+
return Finding(
|
|
29
|
+
check_id=self.id,
|
|
30
|
+
severity=self.severity,
|
|
31
|
+
title=self.title,
|
|
32
|
+
message=message,
|
|
33
|
+
resource=resource,
|
|
34
|
+
line=line,
|
|
35
|
+
remediation=self.remediation,
|
|
36
|
+
framework=self.framework,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register(cls: Type[Check]) -> Type[Check]:
|
|
41
|
+
inst = cls()
|
|
42
|
+
if not inst.id:
|
|
43
|
+
raise ValueError(f"{cls.__name__} missing id")
|
|
44
|
+
if inst.id in CHECK_REGISTRY:
|
|
45
|
+
raise ValueError(f"duplicate check id {inst.id}")
|
|
46
|
+
CHECK_REGISTRY[inst.id] = inst
|
|
47
|
+
return cls
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_checks(
|
|
51
|
+
only: Iterable[str] = (),
|
|
52
|
+
skip: Iterable[str] = (),
|
|
53
|
+
) -> List[Check]:
|
|
54
|
+
only_set = {c.strip() for c in only if c.strip()}
|
|
55
|
+
skip_set = {c.strip() for c in skip if c.strip()}
|
|
56
|
+
checks = []
|
|
57
|
+
for cid, chk in sorted(CHECK_REGISTRY.items()):
|
|
58
|
+
if only_set and cid not in only_set:
|
|
59
|
+
continue
|
|
60
|
+
if cid in skip_set:
|
|
61
|
+
continue
|
|
62
|
+
checks.append(chk)
|
|
63
|
+
return checks
|