The seventeen remaining literals are container-side paths, AF_UNIX socket-path-limit candidates on darwin, detection needles, guard regexes and guidance text that tells the model to avoid /tmp. Each carries an inline `no-tmp: ok — <why>` so the reason lives next to the line; the baseline keeps only a fenced tree listing where a marker would render.
812 lines
53 KiB
Python
812 lines
53 KiB
Python
#!/usr/bin/env python3
|
|
"""Skills Guard — regex static scan of externally-sourced skills plus a trust-aware install policy.
|
|
|
|
Trust: builtin (never scanned), trusted (openai/anthropics/... repos: caution allowed), community (any
|
|
findings block unless --force). ``scan_skill`` -> ``should_allow_install`` -> ``format_scan_report``.
|
|
Known gap: language write APIs (open(..., 'w'), Path.write_text, shutil.copy*, fs.writeFileSync) aimed at
|
|
agent-config files surface only the low *_ref finding — static regexes cannot tie the call to a dynamic
|
|
destination; future coverage belongs as a fourth "mechanical" tier next to agent_config_mod_shell."""
|
|
|
|
import re
|
|
import fnmatch
|
|
import hashlib
|
|
import json
|
|
from contextlib import suppress
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import List, Tuple
|
|
|
|
|
|
SCANNER_VERSION = "skills-guard-v5"
|
|
|
|
# NVIDIA-verified skills each ship a signed `skill.oms.sig` + governance `skill-card.md`.
|
|
TRUSTED_REPOS = {"openai/skills", "anthropics/skills", "huggingface/skills", "NVIDIA/skills"}
|
|
|
|
INSTALL_POLICY = {
|
|
# safe caution dangerous
|
|
"builtin": ("allow", "allow", "allow"),
|
|
"trusted": ("allow", "allow", "block"),
|
|
"community": ("allow", "block", "block"),
|
|
# "ask" = error to the agent (retry without the flagged content); only when skills.guard_agent_created is on.
|
|
"agent-created": ("allow", "allow", "ask"),
|
|
}
|
|
|
|
VERDICT_INDEX = {"safe": 0, "caution": 1, "dangerous": 2}
|
|
|
|
|
|
@dataclass
|
|
class Finding:
|
|
pattern_id: str
|
|
severity: str # "critical" | "high" | "medium" | "low"
|
|
category: str # "exfiltration" | "injection" | "destructive" | "persistence" | "network" | ...
|
|
file: str
|
|
line: int
|
|
match: str
|
|
description: str
|
|
|
|
|
|
@dataclass
|
|
class ScanResult:
|
|
skill_name: str
|
|
source: str
|
|
trust_level: str # "builtin" | "trusted" | "community" | "agent-created"
|
|
verdict: str # "safe" | "caution" | "dangerous"
|
|
findings: List[Finding] = field(default_factory=list)
|
|
scanned_at: str = ""
|
|
summary: str = ""
|
|
scan_provenance: dict = field(default_factory=dict)
|
|
|
|
|
|
# --- Threat patterns — (regex, pattern_id, severity, category, description) --
|
|
# File-modification verbs for the agent-config persistence tiers: a verb shortly before a config
|
|
# filename on the same line is scored as modification; a bare mention is not.
|
|
MODIFY_VERB_RE = (
|
|
r'(?:\bwrit(?:e|es|ing)\b|\bwritten\b|\bedit(?:s|ed|ing)?\b'
|
|
r'|\bmodif(?:y|ies|ied|ying|ication)s?\b|\bupdat(?:e|es|ed|ing)\b'
|
|
r'|\bappend(?:s|ed|ing)?\b|\bprepend(?:s|ed|ing)?\b'
|
|
r'|\binject(?:s|ed|ing)?\b|\boverwrit(?:e|es|ing)\b|\boverwritten\b'
|
|
r'|\breplac(?:e|es|ed|ing)\b|\balter(?:s|ed|ing)?\b|\badd(?:s|ed|ing)\b)')
|
|
|
|
_AGENT_CONFIG_FILES = r'(?:AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules)'
|
|
_HERMES_CONFIG_FILES = r'\.hermes/(?:config\.yaml|SOUL\.md)'
|
|
# Path prefixes (real files are e.g. .claude/settings.json): consume trailing filename chars.
|
|
_OTHER_AGENT_CONFIG_FILES = r'\.(?:claude/settings|codex/config)[\w.]*'
|
|
|
|
|
|
def _shell_write_re(file_alt: str) -> str:
|
|
"""Mechanical shell write into *file_alt*: ``>``/``>>``, ``sed -i``, ``tee`` (target as immediate argument, so
|
|
``| tee output | AGENTS.md |`` cells miss), ``cp``/``mv`` with the file as destination (source arg required, so
|
|
``cp AGENTS.md backup/`` misses; ``AGENTS.md.bak`` is not the file). A single ``>`` needs a preceding word/quote/
|
|
paren char so blockquotes (``> text``) and arrows (``-> file``) miss."""
|
|
return (
|
|
rf'(?:>>|[\w"\'`)\]]\s*>)\s*[~\w./-]*{file_alt}(?!\.?\w)'
|
|
rf'|\bsed\b[^\n]*\s(?:-[A-Za-z]*i[A-Za-z]*|--in-place)\b[^\n]*{file_alt}(?!\.?\w)'
|
|
rf'|\btee\s+(?:-a\s+)?[~\w./"\'-]*{file_alt}(?!\.?\w)'
|
|
rf'|\b(?:cp|mv)\s+[^\s|;&]+\s+[^\n|;&]{{0,40}}?{file_alt}(?!\.?\w)')
|
|
|
|
|
|
def _prose_modify_re(file_alt: str) -> str:
|
|
"""Prose instructing modification of *file_alt*: an imperative-position verb (line start / bullet), or a mid-line
|
|
verb with a directive marker ("you must", "please", "make sure to"). Descriptive prose ("skills that edit
|
|
AGENTS.md") misses; the verb→file gap forbids commas so enumerations ("Write skills, AGENTS.md, CLAUDE.md") miss."""
|
|
return (
|
|
rf'^\s*(?:[-*+]\s+|\d+[.)]\s+)?{MODIFY_VERB_RE}[^\n,]{{0,80}}?{file_alt}\b'
|
|
rf'|(?:\byou\s+(?:must|should|need\s+to)\s+|\bplease\s+'
|
|
rf'|\bmake\s+sure\s+(?:to\s+|you\s+)|\bbe\s+sure\s+to\s+)'
|
|
rf'{MODIFY_VERB_RE}[^\n,]{{0,80}}?{file_alt}\b')
|
|
|
|
|
|
def _content_contract_re(file_alt: str) -> str:
|
|
""""<file> should contain/include ..." prose. Authoring guides and attacks share this shape and are not
|
|
separable statically, so the tier is scored high (caution → confirmation), never critical."""
|
|
return rf'{file_alt}\b[^\n]{{0,40}}?\b(?:should|must|needs?\s+to)\s+(?:contain|say|include|have|list)\b'
|
|
|
|
|
|
# ── context_exfil helpers ──
|
|
# Negation guard: never/not/doesn't ... right after the verb marks descriptive prose (subagent
|
|
# isolation notes, release notes) — the opposite of a transfer directive.
|
|
_NO_TRANSFER = (r'(?!(?:\w+\s+){0,4}?(?:never|not|doesn\'?t|didn\'?t|won\'?t|isn\'?t|aren\'?t|can\'?t|cannot|mustn\'?t|shouldn\'?t)\b)')
|
|
# Real directives are short; unbounded filler let prose (output never enters your own context)
|
|
# and feature descriptions match.
|
|
_SHORT_FILLER = r'(?:\w+\s+){0,3}?'
|
|
|
|
THREAT_PATTERNS = [
|
|
# ── Exfiltration: shell commands leaking secrets ──
|
|
# env_exfil_* share a loopback exemption: a same-line literal scheme-anchored loopback destination
|
|
# (http(s)://localhost, 127.0.0.1, [::1]) cannot move data off the machine, so a secret-shaped query
|
|
# param there is a local session token. The scheme must immediately precede the host —
|
|
# `evil.com/?u=localhost` does not qualify.
|
|
(r'curl\s+(?![^\n]*https?://(?:localhost|127\.0\.0\.1|\[::1\]))[^\n]*\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)S?\b',
|
|
"env_exfil_curl", "critical", "exfiltration", "curl command interpolating secret environment variable"),
|
|
(r'wget\s+(?![^\n]*https?://(?:localhost|127\.0\.0\.1|\[::1\]))[^\n]*\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)S?\b',
|
|
"env_exfil_wget", "critical", "exfiltration", "wget command interpolating secret environment variable"),
|
|
(r'fetch\s*\((?![^\n]*https?://(?:localhost|127\.0\.0\.1|\[::1\]))[^\n]*\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD)S?\b',
|
|
"env_exfil_fetch", "critical", "exfiltration", "fetch() call interpolating secret environment variable"),
|
|
(r'httpx?\.(get|post|put|patch)\s*\((?![^\n]*https?://(?:localhost|127\.0\.0\.1|\[::1\]))[^\n]*(KEY|TOKEN|SECRET|PASSWORD)',
|
|
"env_exfil_httpx", "critical", "exfiltration", "HTTP library call with secret variable"),
|
|
(r'requests\.(get|post|put|patch)\s*\((?![^\n]*https?://(?:localhost|127\.0\.0\.1|\[::1\]))[^\n]*(KEY|TOKEN|SECRET|PASSWORD)',
|
|
"env_exfil_requests", "critical", "exfiltration", "requests library call with secret variable"),
|
|
# ── Exfiltration: reading credential stores ──
|
|
(r'base64[^\n]*env', "encoded_exfil", "high", "exfiltration", "base64 encoding combined with environment access"),
|
|
(r'\$HOME/\.ssh|\~/\.ssh', "ssh_dir_access", "high", "exfiltration", "references user SSH directory"),
|
|
(r'\$HOME/\.aws|\~/\.aws', "aws_dir_access", "high", "exfiltration", "references user AWS credentials directory"),
|
|
(r'\$HOME/\.gnupg|\~/\.gnupg', "gpg_dir_access", "high", "exfiltration", "references user GPG keyring"),
|
|
(r'\$HOME/\.kube|\~/\.kube', "kube_dir_access", "high", "exfiltration", "references Kubernetes config directory"),
|
|
(r'\$HOME/\.docker|\~/\.docker',
|
|
"docker_dir_access", "high", "exfiltration", "references Docker config (may contain registry creds)"),
|
|
(r'\$HOME/\.hermes/\.env|\~/\.hermes/\.env',
|
|
"hermes_env_access", "critical", "exfiltration", "directly references Hermes secrets file"),
|
|
# `cat <secrets-file>` reads credentials; `cat >`/`cat >>` WRITES one (setup heredocs) — not exfil.
|
|
(r'cat\s+(?!>)[^\n]*(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)',
|
|
"read_secrets_file", "critical", "exfiltration", "reads known secrets file"),
|
|
(r'\b(?:readFile(?:Sync)?|readTextFile)\s*\(\s*["\'][^"\'\n]*(?:\.ssh[/\\]id_(?:rsa|ed25519|ecdsa|dsa)(?!\.pub)|\.env\b|credentials\b|\.netrc\b|\.pgpass\b|\.npmrc\b|\.pypirc\b)[^"\'\n]*["\']',
|
|
"js_read_secrets_file", "critical", "exfiltration", "JavaScript reads a known credential file"),
|
|
# ── Exfiltration: programmatic env access ──
|
|
(r'printenv|env\s*\|', "dump_all_env", "high", "exfiltration", "dumps all environment variables"),
|
|
# Bare `os.environ` (dump/iteration) is suspicious; ANY `.get("<name>")` form is exempt — plain config
|
|
# reads, with secret-shaped names scored medium by python_environ_get_secret below (a blanket high here
|
|
# would swamp that). `^[^#\n]*` skips lines with a '#' anywhere before it (full-line or inline comment);
|
|
# scan_file()'s docstring pre-filter skips triple-quoted prose.
|
|
(r'^[^#\n]*os\.environ\b(?!\s*\.get\s*\()',
|
|
"python_os_environ", "high", "exfiltration", "accesses os.environ outside comments/docstrings (potential env dump)"),
|
|
(r'os\.environ\s*\.get\s*\(\s*["\'][^"\']*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)',
|
|
"python_environ_get_secret", "medium", "exfiltration", "reads secret via os.environ.get() (normal API-key access; informational)"),
|
|
(r'os\.getenv\s*\(\s*[^\)]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)',
|
|
"python_getenv_secret", "medium", "exfiltration", "reads secret via os.getenv() (normal API-key access; informational)"),
|
|
(r'process\.env\[', "node_process_env", "high", "exfiltration", "accesses process.env (Node.js environment)"),
|
|
# Case-sensitive Ruby ENV: (?-i:) keeps Python `env[...]` dict access from matching under IGNORECASE.
|
|
(r'(?-i:ENV)\[.*(?:KEY|TOKEN|SECRET|PASSWORD)', "ruby_env_secret", "critical", "exfiltration", "reads secret via Ruby ENV[]"),
|
|
# ── Exfiltration: DNS and staging ──
|
|
# Exfil puts the data in the queried NAME: the first positional argument (after
|
|
# optional -flags with values, +opts, @server) carries the interpolation. Anything
|
|
# looser fires on the English noun in prose ("set the host value and run
|
|
# `${SKILL_DIR}/x`") and on flag names such as llama.cpp `--host 127.0.0.1 --port $PORT`.
|
|
(r'(?<![-/])\b(dig|nslookup|host)\s+(?:[-+@]\S*(?:\s+[^\s$"\'-][^\s$]*)?\s+)*["\']?[^\s"\'$]*\$',
|
|
"dns_exfil", "critical", "exfiltration", "DNS lookup with variable interpolation (possible DNS exfiltration)"),
|
|
(r'>\s*/tmp/[^\s]*\s*&&\s*(curl|wget|nc|python)', # no-tmp: ok — malicious-pattern regex
|
|
"tmp_staging", "critical", "exfiltration", "writes to /tmp then exfiltrates"), # no-tmp: ok — malicious-pattern label
|
|
# ── Exfiltration: markdown/link based ──
|
|
(r'!\[.*\]\(https?://[^\)]*\$\{?',
|
|
"md_image_exfil", "high", "exfiltration", "markdown image URL with variable interpolation (image-based exfil)"),
|
|
(r'\[.*\]\(https?://[^\)]*\$\{?', "md_link_exfil", "high", "exfiltration", "markdown link with variable interpolation"),
|
|
# ── Prompt injection ──
|
|
(r'ignore\s+(?:\w+\s+)*(previous|all|above|prior)\s+instructions',
|
|
"prompt_injection_ignore", "critical", "injection", "prompt injection: ignore previous instructions"),
|
|
(r'you\s+are\s+(?:\w+\s+)*now\s+', "role_hijack", "high", "injection", "attempts to override the agent's role"),
|
|
# Concealment only — the lookahead exempts UX guidance ("don't tell the user X unless Y confirms").
|
|
(r'do\s+not\s+(?:\w+\s+)*tell\s+(?:\w+\s+)*the\s+user(?!.*\b(?:unless|except|until|confirm|diagnose|verify|check)\b)',
|
|
"deception_hide", "high", "injection", "instructs agent to hide information from user"),
|
|
(r'system\s+(?:\w+\s+)*prompt\s+(?:\w+\s+)*override',
|
|
"sys_prompt_override", "critical", "injection", "attempts to override the system prompt"),
|
|
(r'pretend\s+(?:\w+\s+)*(you\s+are|to\s+be)\s+',
|
|
"role_pretend", "high", "injection", "attempts to make the agent assume a different identity"),
|
|
(r'disregard\s+(?:\w+\s+)*(your|all|any)\s+(?:\w+\s+)*(instructions|rules|guidelines)',
|
|
"disregard_rules", "critical", "injection", "instructs agent to disregard its rules"),
|
|
(r'output\s+(?:\w+\s+)*(system|initial)\s+prompt',
|
|
"leak_system_prompt", "high", "injection", "attempts to extract the system prompt"),
|
|
(r'(when|if)\s+no\s*one\s+is\s+(watching|looking)',
|
|
"conditional_deception", "high", "injection", "conditional instruction to behave differently when unobserved"),
|
|
(r'act\s+as\s+(if|though)\s+(?:\w+\s+)*you\s+(?:\w+\s+)*(have\s+no|don\'t\s+have)\s+(?:\w+\s+)*(restrictions|limits|rules)',
|
|
"bypass_restrictions", "critical", "injection", "instructs agent to act without restrictions"),
|
|
(r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)',
|
|
"translate_execute", "critical", "injection", "translate-then-execute evasion technique"),
|
|
(r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->',
|
|
"html_comment_injection", "high", "injection", "hidden instructions in HTML comments"),
|
|
(r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none',
|
|
"hidden_div", "high", "injection", "hidden HTML div (invisible instructions)"),
|
|
# ── Destructive operations ──
|
|
# Cleanup under the standard temp roots (/tmp, /var/tmp, /dev/shm, /run) is routine in
|
|
# test/smoke scripts and CI. A parent segment inside an exempted root can escape it,
|
|
# so it remains destructive along with every other path rooted at "/".
|
|
(r'rm\s+-rf\s+/(?:'
|
|
r'(?!tmp(?:\b|/)|var/tmp(?:\b|/)|dev/shm(?:\b|/)|run(?:\b|/))'
|
|
r'|(?:tmp|var/tmp|dev/shm|run)/(?:[^/\s]*/)*\.\.(?=/|[\s;&|]|$))',
|
|
"destructive_root_rm", "critical", "destructive", "recursive delete from root"),
|
|
(r'rm\s+(-[^\s]*)?r.*\$HOME|\brmdir\s+.*\$HOME',
|
|
"destructive_home_rm", "critical", "destructive", "recursive delete targeting home directory"),
|
|
(r'chmod\s+777', "insecure_perms", "medium", "destructive", "sets world-writable permissions"),
|
|
(r'>\s*/etc/', "system_overwrite", "critical", "destructive", "overwrites system configuration file"),
|
|
(r'\bmkfs\b', "format_filesystem", "critical", "destructive", "formats a filesystem"),
|
|
(r'\bdd\s+.*if=.*of=/dev/', "disk_overwrite", "critical", "destructive", "raw disk write operation"),
|
|
(r'shutil\.rmtree\s*\(\s*[\"\'/]', "python_rmtree", "high", "destructive", "Python rmtree on absolute or root-relative path"),
|
|
(r'truncate\s+-s\s*0\s+/', "truncate_system", "critical", "destructive", "truncates system file to zero bytes"),
|
|
# ── Persistence ──
|
|
(r'\bcrontab\b', "persistence_cron", "medium", "persistence", "modifies cron jobs"),
|
|
# ``profile`` is split out and anchored: ``.zshrc`` after a dot is always the file, but
|
|
# ``.profile`` is also how every language spells attribute access (``self.profile``,
|
|
# ``data?.profile``, ``func().profile``), which flooded scans of ordinary code. Requiring
|
|
# a non-identifier, non-call/index/optional-chain character before the dot keeps real paths
|
|
# (``~/.profile``, ``"$HOME/.profile"``, ``./.profile``) and drops attribute reads.
|
|
(r'\.(bashrc|zshrc|bash_profile|bash_login|zprofile|zlogin)\b',
|
|
"shell_rc_mod", "medium", "persistence", "references shell startup file"),
|
|
(r'(?<![\w)\]?])\.profile\b',
|
|
"shell_rc_mod", "medium", "persistence", "references shell startup file"),
|
|
(r'authorized_keys', "ssh_backdoor", "critical", "persistence", "modifies SSH authorized keys"),
|
|
(r'ssh-keygen', "ssh_keygen", "medium", "persistence", "generates SSH keys"),
|
|
(r'systemd.*\.service|systemctl\s+(enable|start)',
|
|
"systemd_service", "medium", "persistence", "references or enables systemd service"),
|
|
(r'/etc/init\.d/', "init_script", "medium", "persistence", "references init.d startup script"),
|
|
(r'launchctl\s+load|LaunchAgents|LaunchDaemons',
|
|
"macos_launchd", "medium", "persistence", "macOS launch agent/daemon persistence"),
|
|
(r'/etc/sudoers|visudo', "sudoers_mod", "critical", "persistence", "modifies sudoers (privilege escalation)"),
|
|
(r'git\s+config\s+--global\s+', "git_config_global", "medium", "persistence", "modifies global git configuration"),
|
|
# ── Network: reverse shells and tunnels ──
|
|
# socat needs an address spec (TCP:/EXEC:/…): a bare word match hit "SOCAT", the
|
|
# oceanographic CO2 atlas, across dozens of science skills (all patterns are IGNORECASE).
|
|
(r'\bnc\s+-[lp]|ncat\s+-[lp]|\bsocat\b[^\n]*\b(?:tcp|udp|openssl|ssl|exec|system|pty|unix)[\w-]*:',
|
|
"reverse_shell", "critical", "network", "potential reverse shell listener"),
|
|
(r'\bngrok\b|\blocaltunnel\b|\bserveo\b|\bcloudflared\b',
|
|
"tunnel_service", "high", "network", "uses tunneling service for external access"),
|
|
(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{2,5}', "hardcoded_ip_port", "medium", "network", "hardcoded IP address with port"),
|
|
(r'0\.0\.0\.0:\d+|INADDR_ANY', "bind_all_interfaces", "high", "network", "binds to all network interfaces"),
|
|
(r'/bin/(ba)?sh\s+-i\s+.*>/dev/tcp/',
|
|
"bash_reverse_shell", "critical", "network", "bash interactive reverse shell via /dev/tcp"),
|
|
(r'python[23]?\s+-c\s+["\']import\s+socket',
|
|
"python_socket_oneliner", "critical", "network", "Python one-liner socket connection (likely reverse shell)"),
|
|
(r'socket\.connect\s*\(\s*\(', "python_socket_connect", "high", "network", "Python socket connect to arbitrary host"),
|
|
(r'webhook\.site|requestbin\.com|pipedream\.net|hookbin\.com',
|
|
"exfil_service", "high", "network", "references known data exfiltration/webhook testing service"),
|
|
(r'pastebin\.com|hastebin\.com|ghostbin\.',
|
|
"paste_service", "medium", "network", "references paste service (possible data staging)"),
|
|
# ── Obfuscation: encoding and eval ──
|
|
(r'base64\s+(-d|--decode)\s*\|', "base64_decode_pipe", "high", "obfuscation", "base64 decodes and pipes to execution"),
|
|
(r'\\x[0-9a-fA-F]{2}.*\\x[0-9a-fA-F]{2}.*\\x[0-9a-fA-F]{2}',
|
|
"hex_encoded_string", "medium", "obfuscation", "hex-encoded string (possible obfuscation)"),
|
|
(r'\beval\s*\(\s*["\']', "eval_string", "high", "obfuscation", "eval() with string argument"),
|
|
(r'\bexec\s*\(\s*["\']', "exec_string", "high", "obfuscation", "exec() with string argument"),
|
|
(r'echo\s+[^\n]*\|\s*(bash|sh|python|perl|ruby|node)',
|
|
"echo_pipe_exec", "critical", "obfuscation", "echo piped to interpreter for execution"),
|
|
(r'compile\s*\(\s*[^\)]+,\s*["\'].*["\']\s*,\s*["\']exec["\']\s*\)',
|
|
"python_compile_exec", "high", "obfuscation", "Python compile() with exec mode"),
|
|
(r'getattr\s*\(\s*__builtins__',
|
|
"python_getattr_builtins", "high", "obfuscation", "dynamic access to Python builtins (evasion technique)"),
|
|
(r'__import__\s*\(\s*["\']os["\']\s*\)', "python_import_os", "high", "obfuscation", "dynamic import of os module"),
|
|
(r'codecs\.decode\s*\(\s*["\']',
|
|
"python_codecs_decode", "medium", "obfuscation", "codecs.decode (possible ROT13 or encoding obfuscation)"),
|
|
(r'String\.fromCharCode|charCodeAt',
|
|
"js_char_code", "medium", "obfuscation", "JavaScript character code construction (possible obfuscation)"),
|
|
(r'atob\s*\(|btoa\s*\(', "js_base64", "medium", "obfuscation", "JavaScript base64 encode/decode"),
|
|
(r'\[::-1\]', "string_reversal", "low", "obfuscation", "string reversal (possible obfuscated payload)"),
|
|
(r'chr\s*\(\s*\d+\s*\)\s*\+\s*chr\s*\(\s*\d+',
|
|
"chr_building", "high", "obfuscation", "building string from chr() calls (obfuscation)"),
|
|
(r'\\u[0-9a-fA-F]{4}.*\\u[0-9a-fA-F]{4}.*\\u[0-9a-fA-F]{4}',
|
|
"unicode_escape_chain", "medium", "obfuscation", "chain of unicode escapes (possible obfuscation)"),
|
|
# ── Process execution in scripts ──
|
|
(r'subprocess\.(run|call|Popen|check_output)\s*\(',
|
|
"python_subprocess", "medium", "execution", "Python subprocess execution"),
|
|
(r'os\.system\s*\(', "python_os_system", "high", "execution", "os.system() — unguarded shell execution"),
|
|
(r'os\.popen\s*\(', "python_os_popen", "high", "execution", "os.popen() — shell pipe execution"),
|
|
(r'child_process\.(exec|spawn|fork)\s*\(', "node_child_process", "high", "execution", "Node.js child_process execution"),
|
|
(r'Runtime\.getRuntime\(\)\.exec\(', "java_runtime_exec", "high", "execution", "Java Runtime.exec() — shell execution"),
|
|
(r'`[^`]*\$\([^)]+\)[^`]*`', "backtick_subshell", "medium", "execution", "backtick string with command substitution"),
|
|
# ── Path traversal ──
|
|
(r'\.\./\.\./\.\.', "path_traversal_deep", "high", "traversal", "deep relative path traversal (3+ levels up)"),
|
|
(r'\.\./\.\.', "path_traversal", "medium", "traversal", "relative path traversal (2+ levels up)"),
|
|
(r'/etc/passwd|/etc/shadow', "system_passwd_access", "critical", "traversal", "references system password files"),
|
|
(r'/proc/self|/proc/\d+/', "proc_access", "high", "traversal", "references /proc filesystem (process introspection)"),
|
|
(r'/dev/shm/', "dev_shm", "medium", "traversal", "references shared memory (common staging area)"),
|
|
# ── Crypto mining ──
|
|
(r'xmrig|stratum\+tcp|monero|coinhive|cryptonight', "crypto_mining", "critical", "mining", "cryptocurrency mining reference"),
|
|
(r'hashrate|nonce.*difficulty', "mining_indicators", "medium", "mining", "possible cryptocurrency mining indicators"),
|
|
# ── Supply chain: curl/wget pipe to shell ──
|
|
(r'curl\s+[^\n]*\|\s*(ba)?sh', "curl_pipe_shell", "critical", "supply_chain", "curl piped to shell (download-and-execute)"),
|
|
(r'wget\s+[^\n]*-O\s*-\s*\|\s*(ba)?sh',
|
|
"wget_pipe_shell", "critical", "supply_chain", "wget piped to shell (download-and-execute)"),
|
|
(r'curl\s+[^\n]*\|\s*python', "curl_pipe_python", "critical", "supply_chain", "curl piped to Python interpreter"),
|
|
# ── Supply chain: unpinned/deferred dependencies ──
|
|
(r'#\s*///\s*script.*dependencies',
|
|
"pep723_inline_deps", "medium", "supply_chain", "PEP 723 inline script metadata with dependencies (verify pinning)"),
|
|
(r'pip\s+install\s+(?!-r\s)(?!.*==)',
|
|
"unpinned_pip_install", "medium", "supply_chain", "pip install without version pinning"),
|
|
(r'npm\s+install\s+(?!.*@\d)', "unpinned_npm_install", "medium", "supply_chain", "npm install without version pinning"),
|
|
(r'uv\s+run\s+', "uv_run", "medium", "supply_chain", "uv run (may auto-install unpinned dependencies)"),
|
|
# ── Supply chain: remote resource fetching ──
|
|
(r'(curl|wget|httpx?\.get|requests\.get|fetch)\s*[\(]?\s*["\']https?://',
|
|
"remote_fetch", "medium", "supply_chain", "fetches remote resource at runtime"),
|
|
(r'git\s+clone\s+', "git_clone", "medium", "supply_chain", "clones a git repository at runtime"),
|
|
(r'docker\s+pull\s+', "docker_pull", "medium", "supply_chain", "pulls a Docker image at runtime"),
|
|
# ── Privilege escalation ──
|
|
# `allowed-tools:` is REQUIRED frontmatter per the agent-skill spec — informational (low) only.
|
|
(r'^allowed-tools\s*:',
|
|
"allowed_tools_field", "low", "privilege_escalation", "skill declares allowed-tools (standard frontmatter; informational)"),
|
|
# `sudo.request` / `sudo.respond` are gateway wire events (the masked sudo-password prompt), not an
|
|
# invocation: any client plugin that relays Hermes' secure prompts has to name them, and a bare
|
|
# `\bsudo\b` made every such plugin `caution`. A dotted event name is never a shell `sudo`.
|
|
(r'\bsudo\b(?!\.(?:request|respond)\b)',
|
|
"sudo_usage", "high", "privilege_escalation", "uses sudo (privilege escalation)"),
|
|
(r'setuid|setgid|cap_setuid',
|
|
"setuid_setgid", "critical", "privilege_escalation", "setuid/setgid (privilege escalation mechanism)"),
|
|
(r'NOPASSWD',
|
|
"nopasswd_sudo", "critical", "privilege_escalation", "NOPASSWD sudoers entry (passwordless privilege escalation)"),
|
|
(r'chmod\s+[u+]?s', "suid_bit", "critical", "privilege_escalation", "sets SUID/SGID bit on a file"),
|
|
# ── Agent config persistence ──
|
|
# Bare mentions of config files are not threats (authoring guides, setup docs) — flagging them blocked
|
|
# popular community skills. Tiers: mechanical shell writes = critical; prose modification intent =
|
|
# critical for AGENT config files (exactly how persistence attacks instruct the agent; project-skill
|
|
# quarantine only acts on "dangerous") but high for Hermes/other config (setup docs routinely say
|
|
# "edit config.yaml"); bare references = low.
|
|
# Flagging any mention as critical produced permanent false-positive blocks for popular community skills
|
|
# (#92021). * Mechanical persistence (shell redirection, sed -i, tee, cp/mv into the file) is critical —
|
|
# an unambiguous write path. * Prose modification intent — an imperative-position verb or an explicit
|
|
# directive ("you must edit ...") aimed at the file.
|
|
(_prose_modify_re(_AGENT_CONFIG_FILES),
|
|
"agent_config_mod", "critical", "persistence", "instructs modification of agent config files (could persist instructions across sessions)"),
|
|
(_shell_write_re(_AGENT_CONFIG_FILES),
|
|
"agent_config_mod_shell", "critical", "persistence", "shell write (redirect/sed -i/tee/cp/mv) targeting agent config files (persistence mechanism)"),
|
|
(_content_contract_re(_AGENT_CONFIG_FILES),
|
|
"agent_config_contract", "high", "persistence", "dictates agent config file contents (verify intent — authoring guides use this shape too)"),
|
|
(r'AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules',
|
|
"agent_config_ref", "low", "persistence", "references agent config files (informational; only modification intent is scored)"),
|
|
(_prose_modify_re(_HERMES_CONFIG_FILES),
|
|
"hermes_config_mod", "high", "persistence", "modification language aimed at Hermes configuration files (verify intent)"),
|
|
(_shell_write_re(_HERMES_CONFIG_FILES),
|
|
"hermes_config_mod_shell", "critical", "persistence", "shell write (redirect/sed -i/tee/cp/mv) targeting Hermes configuration files"),
|
|
(r'\.hermes/config\.yaml|\.hermes/SOUL\.md',
|
|
"hermes_config_ref", "low", "persistence", "references Hermes configuration files (informational; only modification intent is scored)"),
|
|
(_prose_modify_re(_OTHER_AGENT_CONFIG_FILES),
|
|
"other_agent_config_mod", "high", "persistence", "modifies other agents' configuration files"),
|
|
(_shell_write_re(_OTHER_AGENT_CONFIG_FILES),
|
|
"other_agent_config_mod_shell", "critical", "persistence", "shell write (redirect/sed -i/tee/cp/mv) targeting other agents' configuration files"),
|
|
(r'\.claude/settings|\.codex/config',
|
|
"other_agent_config_ref", "low", "persistence", "references other agent configuration files (informational; only modification intent is scored)"),
|
|
# ── Hardcoded secrets (credentials embedded in the skill itself) ──
|
|
(r'(?:api[_-]?key|token|secret|password)\s*[=:]\s*["\'][A-Za-z0-9+/=_-]{20,}',
|
|
"hardcoded_secret", "critical", "credential_exposure", "possible hardcoded API key, token, or secret"),
|
|
(r'-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----',
|
|
"embedded_private_key", "critical", "credential_exposure", "embedded private key"),
|
|
(r'ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{80,}',
|
|
"github_token_leaked", "critical", "credential_exposure", "GitHub personal access token in skill content"),
|
|
(r'sk-[A-Za-z0-9]{20,}', "openai_key_leaked", "critical", "credential_exposure", "possible OpenAI API key in skill content"),
|
|
(r'sk-ant-[A-Za-z0-9_-]{90,}',
|
|
"anthropic_key_leaked", "critical", "credential_exposure", "possible Anthropic API key in skill content"),
|
|
(r'AKIA[0-9A-Z]{16}', "aws_access_key_leaked", "critical", "credential_exposure", "AWS access key ID in skill content"),
|
|
(r'glpat-[A-Za-z0-9_\-]{20,}',
|
|
"gitlab_token_leaked", "critical", "credential_exposure", "GitLab personal access token in skill content"),
|
|
# ── Additional prompt injection: jailbreak patterns ──
|
|
(r'\bDAN\s+mode\b|Do\s+Anything\s+Now', "jailbreak_dan", "critical", "injection", "DAN (Do Anything Now) jailbreak attempt"),
|
|
(r'\bdeveloper\s+mode\b.*\benabled?\b', "jailbreak_dev_mode", "critical", "injection", "developer mode jailbreak attempt"),
|
|
(r'hypothetical\s+scenario.*(?:ignore|bypass|override)',
|
|
"hypothetical_bypass", "high", "injection", "hypothetical scenario used to bypass restrictions"),
|
|
(r'for\s+educational\s+purposes?\s+only',
|
|
"educational_pretext", "medium", "injection", "educational pretext often used to justify harmful content"),
|
|
(r'(respond|answer|reply)\s+without\s+(?:\w+\s+)*(restrictions|limitations|filters|safety)',
|
|
"remove_filters", "critical", "injection", "instructs agent to respond without safety filters"),
|
|
(r'you\s+have\s+been\s+(?:\w+\s+)*(updated|upgraded|patched)\s+to',
|
|
"fake_update", "high", "injection", "fake update/patch announcement (social engineering)"),
|
|
(r'new\s+(?:\w+\s+)*policy|updated\s+(?:\w+\s+)*guidelines|revised\s+(?:\w+\s+)*instructions',
|
|
"fake_policy", "medium", "injection", "claims new policy/guidelines (may be social engineering)"),
|
|
# ── Context window exfiltration ──
|
|
# Instruction shapes only. Descriptive prose about context handling ("The output never enters
|
|
# your own context", "**Include context:** cwd, env vars", "save tokens (no need to include code
|
|
# in context)") describes the OPPOSITE of exfiltration and must not match: the verb→target gap is
|
|
# bounded, a negation right after the verb voids the match, and a bare ``context`` target counts
|
|
# only under transfer verbs (print/send/share) — "include context" is window/information talk.
|
|
(rf'\b(?:include|output|print|send|share)\s+{_NO_TRANSFER}{_SHORT_FILLER}(?:conversation|chat\s+history|previous\s+messages)\b'
|
|
rf'|\b(?:print|send|share)\s+{_NO_TRANSFER}{_SHORT_FILLER}context\b',
|
|
"context_exfil", "high", "exfiltration", "instructs agent to output/share conversation history"),
|
|
(r'(send|post|upload|transmit)\s+.*\s+(to|at)\s+https?://',
|
|
"send_to_url", "high", "exfiltration", "instructs agent to send data to a URL"),
|
|
]
|
|
|
|
_COMPILED_THREAT_PATTERNS = [(re.compile(pattern, re.IGNORECASE), *rest) for pattern, *rest in THREAT_PATTERNS]
|
|
|
|
# ── Inert path references (#92478) ──
|
|
# The bare path-token patterns (`authorized_keys`, `~/.aws`, ...) fire on the token alone, so a skill that spells
|
|
# a path in order to REFUSE it scores like one that appends to it, and one critical is an unoverridable dangerous
|
|
# verdict on a community source. Two contexts DEMOTE the finding (it stays in the report with file and line, like
|
|
# `allowed_tools_field`): a whole-line comment in a language that has comments drops to low — Markdown is
|
|
# deliberately absent, `#` opens a heading there and the prose is the injection surface; a line owned by a
|
|
# denylist-named assignment, with no verb on the line that could touch the path, drops to high, so the verdict
|
|
# is a confirmable caution rather than a hard block. The name is attacker-chosen, hence the verb guard.
|
|
_PATH_REFERENCE_PATTERN_IDS = frozenset({"ssh_dir_access", "aws_dir_access", "gpg_dir_access", "kube_dir_access",
|
|
"docker_dir_access", "ssh_backdoor", "system_passwd_access"})
|
|
_COMMENT_PREFIX = {'.py': '#', '.sh': '#', '.bash': '#', '.rb': '#', '.pl': '#', '.r': '#', '.jl': '#', '.yaml': '#',
|
|
'.yml': '#', '.toml': '#', '.conf': '#', '.cfg': ('#', ';'), '.ini': ('#', ';'), '.js': '//',
|
|
'.ts': '//', '.php': ('//', '#')}
|
|
# `NAME = ...`, `NAME: Type = ...`, `const NAME = ...` or a mapping key `name:` whose name says "not these".
|
|
_DENYLIST_OWNER_RE = re.compile(
|
|
r'^\s*(?:(?:const|let|var|export)\s+)?[\w.\-]*(?:deny|black|block|skip|exclu|ignor|forbid|refus|reject|never'
|
|
r'|unsafe|sensitive|secret_?file|redact)[\w.\-]*\s*(?::[^=]*?)?(?:=|:\s*(?:$|[\[({]))', re.IGNORECASE)
|
|
# Anything on the line that could act on the path: shell verb, file API, spawn, append redirection. Bare `|` and
|
|
# `>` are excluded on purpose — both are regex metacharacters and the reported fragment is `r"|authorized_keys"`.
|
|
_ACTION_ON_LINE_RE = re.compile(
|
|
r'\b(?:cat|less|more|head|tail|cp|mv|rm|scp|rsync|curl|wget|tee|chmod|chown|ssh|sudo|install|source|eval|exec'
|
|
r'|system|popen|run|check_output|copyfile|copy2|sendfile)\b|\.(?:read|write|open|unlink|copy|append)\w*\s*\('
|
|
r'|\bopen\s*\(|readFileSync|writeFileSync|appendFileSync|>>', re.IGNORECASE)
|
|
_QUOTED_SPAN_RE = re.compile(r"""'[^'\n]*'|"[^"\n]*\"""")
|
|
|
|
|
|
def _statement_owners(lines: list) -> list:
|
|
"""0-indexed line that opened each line's statement (an unclosed bracket or trailing backslash continues it),
|
|
so a match on a continuation line of a multi-line regex can be traced to the assignment target above it.
|
|
Quoted spans are blanked before counting brackets; a miscount costs a missed demotion, never a missed finding."""
|
|
owners, depth, owner, continued = [], 0, 0, False
|
|
for i, line in enumerate(lines):
|
|
if depth <= 0 and not continued:
|
|
owner = i
|
|
owners.append(owner)
|
|
bare = _QUOTED_SPAN_RE.sub("", line)
|
|
depth = max(0, depth + sum(bare.count(c) for c in "([{") - sum(bare.count(c) for c in ")]}"))
|
|
continued = line.rstrip().endswith("\\")
|
|
return owners
|
|
|
|
|
|
def _demote_inert_path_reference(pid: str, severity: str, description: str, line: str, owner_line: str,
|
|
suffix: str) -> Tuple[str, str]:
|
|
"""``(severity, description)`` for a path-token match, lowered when the line cannot act where it sits."""
|
|
if pid not in _PATH_REFERENCE_PATTERN_IDS:
|
|
return severity, description
|
|
if line.lstrip().startswith(_COMMENT_PREFIX.get(suffix, ())):
|
|
return "low", f"{description} (in a comment; informational)"
|
|
if _DENYLIST_OWNER_RE.match(owner_line) and not _ACTION_ON_LINE_RE.search(line):
|
|
return "high", f"{description} (in a denylist literal; confirm before installing)"
|
|
return severity, description
|
|
|
|
|
|
# Structural limits: file count; total KB (5MB, informational only — large skills don't block); single-file KB.
|
|
MAX_FILE_COUNT, MAX_TOTAL_SIZE_KB, MAX_SINGLE_FILE_KB = 50, 5120, 256
|
|
|
|
# Text extensions to scan; known binary extensions that should NOT be in a skill; script types allowed +x.
|
|
SCANNABLE_EXTENSIONS = {
|
|
'.md', '.txt', '.py', '.sh', '.bash', '.js', '.ts', '.rb', '.yaml', '.yml', '.json', '.toml',
|
|
'.cfg', '.ini', '.conf', '.html', '.css', '.xml', '.tex', '.r', '.jl', '.pl', '.php'}
|
|
SUSPICIOUS_BINARY_EXTENSIONS = {
|
|
'.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.com', '.msi', '.dmg', '.app', '.deb', '.rpm'}
|
|
_SCRIPT_EXTENSIONS = {'.sh', '.bash', '.py', '.rb', '.pl'}
|
|
|
|
# Zero-width / directional unicode used for text hiding, with the readable name reported in the finding.
|
|
_INVISIBLE_CHAR_NAMES = {
|
|
'\u200b': "zero-width space", '\u200c': "zero-width non-joiner", '\u200d': "zero-width joiner",
|
|
'\u2060': "word joiner", '\u2062': "invisible times", '\u2063': "invisible separator",
|
|
'\u2064': "invisible plus", '\ufeff': "BOM/zero-width no-break space",
|
|
'\u202a': "LTR embedding", '\u202b': "RTL embedding", '\u202c': "pop directional",
|
|
'\u202d': "LTR override", '\u202e': "RTL override", '\u2066': "LTR isolate", '\u2067': "RTL isolate",
|
|
'\u2068': "first strong isolate", '\u2069': "pop directional isolate"}
|
|
INVISIBLE_CHARS = set(_INVISIBLE_CHAR_NAMES)
|
|
_PATH_TRAVERSAL_PATTERN_IDS = {"path_traversal", "path_traversal_deep"}
|
|
|
|
|
|
def _unicode_char_name(char: str) -> str:
|
|
return _INVISIBLE_CHAR_NAMES.get(char, f"U+{ord(char):04X}")
|
|
|
|
|
|
def _compute_docstring_lines(lines: list) -> set:
|
|
"""1-indexed lines inside or on the boundary of triple-quoted strings (opening, interior, closing, and
|
|
one-line docstrings), so ``os.environ`` in prose is not scored. Heuristic: a triple quote inside a string
|
|
literal is miscounted, but the common false-positive shapes are covered."""
|
|
doc_lines: set = set()
|
|
inside = False
|
|
for i, line in enumerate(lines, start=1):
|
|
was_in, counts = inside, [line.count(marker) for marker in ('"""', "'''")]
|
|
inside ^= sum(counts) % 2 == 1 # each odd marker count toggles; two odd counts cancel
|
|
if was_in or inside or any(counts):
|
|
doc_lines.add(i)
|
|
return doc_lines
|
|
|
|
|
|
def _mask_markdown_link_destinations(line: str) -> str:
|
|
"""Blank balanced inline-link destinations while preserving line offsets.
|
|
|
|
A relative Markdown destination describes documentation structure; it does
|
|
not cause filesystem access. Nested parentheses and escaped characters are
|
|
handled so a later, non-link traversal on the same line remains scannable.
|
|
"""
|
|
masked = list(line)
|
|
search_from = 0
|
|
while (start := line.find("](", search_from)) != -1:
|
|
depth = 1
|
|
escaped = False
|
|
cursor = start + 2
|
|
while cursor < len(line):
|
|
char = line[cursor]
|
|
if escaped:
|
|
escaped = False
|
|
elif char == "\\":
|
|
escaped = True
|
|
elif char == "(":
|
|
depth += 1
|
|
elif char == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
masked[start + 2:cursor] = " " * (cursor - start - 2)
|
|
search_from = cursor + 1
|
|
break
|
|
cursor += 1
|
|
else:
|
|
break
|
|
return "".join(masked)
|
|
|
|
|
|
# A fenced code block opens with 3+ backticks or 3+ tildes indented at most 3 spaces (CommonMark
|
|
# §4.5); a backtick fence's info string may not contain a backtick. It closes only on a line whose
|
|
# fence uses the same marker, is at least as long, and carries nothing else — so a ``~~~`` line
|
|
# inside a backtick fence, a shorter fence, or a fence line with an info string is all content.
|
|
_FENCE_LINE = re.compile(r"^ {0,3}(?P<marker>`{3,}|~{3,})(?P<info>.*)$")
|
|
# A fence may open (and close) inside a container: a bullet ``- ```sh``, an ordered item ``1. ```sh``,
|
|
# a blockquote ``> ```sh``, or a nest of them (§5.1/§5.2). Strip those prefixes before fence matching.
|
|
_CONTAINER_PREFIX = re.compile(r"^(?: {0,3}(?:>|(?:[-*+]|\d{1,9}[.)]) {1,4}))+")
|
|
|
|
|
|
def _mask_prose_link_destinations(lines: List[str]) -> List[str]:
|
|
"""Mask link destinations only in Markdown prose. Inside a fenced or indented code block a
|
|
``[x](../..)`` is an argument to whatever command surrounds it, not a hyperlink, so those lines
|
|
scan verbatim. Fence state is ``(marker_char, opener_length)`` rather than a bool so a
|
|
mismatched fence line cannot drop the scanner back into prose mode; an unclosed fence stays
|
|
code to EOF (fail-safe)."""
|
|
out: List[str] = []
|
|
fence = None # (marker char, opener length) while a fenced block is open
|
|
for line in lines:
|
|
match = _FENCE_LINE.match(_CONTAINER_PREFIX.sub("", line))
|
|
if fence is not None:
|
|
if (match and match["marker"][0] == fence[0] and len(match["marker"]) >= fence[1]
|
|
and not match["info"].strip()):
|
|
fence = None
|
|
code = True
|
|
else:
|
|
if match and not (match["marker"][0] == "`" and "`" in match["info"]):
|
|
fence = (match["marker"][0], len(match["marker"]))
|
|
code = fence is not None or line.startswith(("\t", " ")) # indented code block (§4.4)
|
|
out.append(line if code else _mask_markdown_link_destinations(line))
|
|
return out
|
|
|
|
|
|
def scan_file(file_path: Path, rel_path: str = "") -> List[Finding]:
|
|
"""Threat-pattern + invisible-unicode scan of one file; *rel_path* is the display path (default: file
|
|
name). Regex findings dedupe per pattern per line; invisible chars yield one per line."""
|
|
rel_path = rel_path or file_path.name
|
|
if file_path.suffix.lower() not in SCANNABLE_EXTENSIONS and file_path.name != "SKILL.md":
|
|
return []
|
|
try:
|
|
lines = file_path.read_text(encoding='utf-8').split('\n')
|
|
except (UnicodeDecodeError, OSError):
|
|
return []
|
|
findings = []
|
|
docstring_lines = _compute_docstring_lines(lines) # so code patterns don't fire on prose
|
|
traversal_lines = _mask_prose_link_destinations(lines) if file_path.suffix.lower() == ".md" else lines
|
|
suffix, owners = file_path.suffix.lower(), _statement_owners(lines) # per-file context for the demotion
|
|
for pattern, pid, severity, category, description in _COMPILED_THREAT_PATTERNS:
|
|
for i, line in enumerate(lines, start=1):
|
|
scan_line = traversal_lines[i - 1] if pid in _PATH_TRAVERSAL_PATTERN_IDS else line
|
|
if i not in docstring_lines and pattern.search(scan_line):
|
|
text = line.strip()
|
|
line_severity, line_description = _demote_inert_path_reference(
|
|
pid, severity, description, line, lines[owners[i - 1]], suffix)
|
|
findings.append(Finding(pid, line_severity, category, rel_path, i,
|
|
text if len(text) <= 120 else text[:117] + "...", line_description))
|
|
for i, line in enumerate(lines, start=1):
|
|
if (char := next((c for c in INVISIBLE_CHARS if c in line), None)) is not None:
|
|
name = _unicode_char_name(char)
|
|
findings.append(Finding("invisible_unicode", "high", "injection", rel_path, i,
|
|
f"U+{ord(char):04X} ({name})",
|
|
f"invisible unicode character {name} (possible text hiding/injection)"))
|
|
return findings
|
|
|
|
|
|
def scan_skill(skill_path: Path, source: str = "community") -> ScanResult:
|
|
"""Structural checks + pattern scan of every text file in a skill dir (or a single file). A gitignore-style
|
|
`.skillignore` / `.clawhubignore` excludes dev/docs artifacts from BOTH passes; the ignore file itself is
|
|
always excluded and `SKILL.md` can never be un-ignored. *source* (e.g. "openai/skills") sets the trust level."""
|
|
name, trust = skill_path.name, _resolve_trust_level(source)
|
|
findings: List[Finding] = []
|
|
if skill_path.is_dir():
|
|
ignore = _load_skill_ignore(skill_path)
|
|
findings.extend(_check_structure(skill_path, ignore=ignore))
|
|
for f in skill_path.rglob("*"):
|
|
if f.is_file() and not ignore(rel := str(f.relative_to(skill_path))):
|
|
findings.extend(scan_file(f, rel))
|
|
elif skill_path.is_file():
|
|
findings.extend(scan_file(skill_path, skill_path.name))
|
|
verdict = _determine_verdict(findings)
|
|
return ScanResult(name, source, trust, verdict, findings, datetime.now(timezone.utc).isoformat(),
|
|
_build_summary(name, source, trust, verdict, findings))
|
|
|
|
|
|
def _content_digest(skill_path: Path) -> str:
|
|
"""Canonical SHA-256 over (POSIX relative path, file bytes) ORDERED by the rel-path STRING — Path sorting is
|
|
case-insensitive on Windows and diverged from ``skills_hub.bundle_content_hash`` (every installed skill then
|
|
reported ``update_available`` forever). String order keeps both sides byte-symmetric.
|
|
|
|
Ordering by ``sorted(rglob(...))`` diverged from the bundle side on Windows: Path comparison is
|
|
case-insensitive there (normcase), while ``bundle_content_hash`` sorts plain strings — the same skill
|
|
hashed to different digests and every installed skill reported ``update_available`` forever (#62310).
|
|
"""
|
|
if not skill_path.is_dir():
|
|
return hashlib.sha256(skill_path.read_bytes()).hexdigest()
|
|
h = hashlib.sha256()
|
|
for rel, p in sorted((p.relative_to(skill_path).as_posix(), p) for p in skill_path.rglob("*") if p.is_file()):
|
|
h.update(rel.encode("utf-8") + b"\x00")
|
|
h.update(p.read_bytes())
|
|
return h.hexdigest()
|
|
|
|
|
|
def content_hash(skill_path: Path) -> str:
|
|
"""Short integrity hash (paths mixed in, so swapping two files' contents changes it). MUST stay symmetric
|
|
with ``tools.skills_hub_install.bundle_content_hash`` — change both at once."""
|
|
return f"sha256:{_content_digest(skill_path)[:16]}"
|
|
|
|
|
|
def scan_skill_cached(skill_path: Path, source: str = "community", *, source_url: str = "",
|
|
cache_dir: Path | None = None) -> Tuple[ScanResult, dict]:
|
|
"""Scan plus attestation dict; the cache (keyed by content digest + source identity) only serves exact
|
|
current content under the current scanner version."""
|
|
digest = _content_digest(skill_path)
|
|
cache_root = cache_dir or skill_path.parent / ".scan-cache"
|
|
source_identity = hashlib.sha256(f"{source}\0{source_url}".encode("utf-8")).hexdigest()[:16]
|
|
cache_file = cache_root / f"{digest}-{source_identity}.json"
|
|
expected = {"bundle_hash": f"sha256:{digest}", "scanner_version": SCANNER_VERSION, "source": source,
|
|
"source_url": source_url}
|
|
cached = None
|
|
with suppress(OSError, json.JSONDecodeError):
|
|
cached = json.loads(cache_file.read_text(encoding="utf-8"))
|
|
if isinstance(cached, dict) and all(cached.get(k) == v for k, v in expected.items()):
|
|
result = ScanResult(skill_path.name, source, cached["trust_level"], cached["verdict"],
|
|
[Finding(**item) for item in cached.get("findings", [])], cached["scanned_at"],
|
|
cached.get("summary", ""))
|
|
provenance = {**cached, "fresh": False}
|
|
else:
|
|
result = scan_skill(skill_path, source=source)
|
|
findings = [asdict(item) for item in result.findings]
|
|
provenance = {**expected, "verdict": result.verdict, "trust_level": result.trust_level, "findings": findings,
|
|
"rules": sorted({item["pattern_id"] for item in findings}), "scanned_at": result.scanned_at,
|
|
"summary": result.summary, "fresh": True}
|
|
with suppress(OSError):
|
|
cache_root.mkdir(parents=True, exist_ok=True)
|
|
cache_file.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
|
|
result.scan_provenance = provenance
|
|
return result, provenance
|
|
|
|
|
|
def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, str]:
|
|
"""``(allowed, reason)`` from verdict + trust; *force* overrides every block except a dangerous verdict on
|
|
community/trusted sources. ``allowed`` is None when policy says "ask"."""
|
|
decision = INSTALL_POLICY.get(result.trust_level, INSTALL_POLICY["community"])[VERDICT_INDEX.get(result.verdict, 2)]
|
|
n = len(result.findings)
|
|
hard_block = result.verdict == "dangerous" and result.trust_level in ("community", "trusted")
|
|
if decision == "allow":
|
|
return True, f"Allowed ({result.trust_level} source, {result.verdict} verdict)"
|
|
if force and not hard_block:
|
|
return True, f"Force-installed despite {result.verdict} verdict ({n} findings)"
|
|
if decision == "ask":
|
|
return None, f"Requires confirmation ({result.trust_level} source + {result.verdict} verdict, {n} findings)"
|
|
blocked = f"Blocked ({result.trust_level} source + {result.verdict} verdict, {n} findings). "
|
|
return False, blocked + ("--force does not override a dangerous verdict." if hard_block else "Use --force to override.")
|
|
|
|
|
|
def format_scan_report(result: ScanResult) -> str:
|
|
"""Compact multi-line report for CLI/chat display; findings sorted critical → low."""
|
|
lines = [f"Scan: {result.skill_name} ({result.source}/{result.trust_level}) Verdict: {result.verdict.upper()}"]
|
|
if result.findings:
|
|
order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
for f in sorted(result.findings, key=lambda f: order.get(f.severity, 4)):
|
|
lines.append(f" {f.severity.upper().ljust(8)} {f.category.ljust(14)} "
|
|
f"{f'{f.file}:{f.line}'.ljust(30)} \"{f.match[:60]}\"")
|
|
lines.append("")
|
|
allowed, reason = should_allow_install(result)
|
|
status = "ALLOWED" if allowed is True else "NEEDS CONFIRMATION" if allowed is None else "BLOCKED"
|
|
return "\n".join(lines + [f"Decision: {status} — {reason}"])
|
|
|
|
|
|
def _check_structure(skill_dir: Path, ignore=None) -> List[Finding]:
|
|
"""Structural anomalies (counts, sizes, binaries, stray executables, escaping symlinks); *ignore(rel) -> bool*
|
|
excludes paths from every count and finding."""
|
|
findings = []
|
|
|
|
def add(pid, sev, cat, rel, match, desc):
|
|
findings.append(Finding(pid, sev, cat, rel, 0, match, desc))
|
|
file_count = total_size = 0
|
|
for f in skill_dir.rglob("*"):
|
|
rel = str(f.relative_to(skill_dir))
|
|
if not (f.is_file() or f.is_symlink()) or (ignore is not None and ignore(rel)):
|
|
continue
|
|
file_count += 1
|
|
if f.is_symlink():
|
|
try:
|
|
resolved = f.resolve()
|
|
if not resolved.is_relative_to(skill_dir.resolve()):
|
|
add("symlink_escape", "critical", "traversal", rel, f"symlink -> {resolved}",
|
|
"symlink points outside the skill directory")
|
|
except OSError:
|
|
add("broken_symlink", "medium", "traversal", rel, "broken symlink", "broken or circular symlink")
|
|
continue
|
|
try:
|
|
st = f.stat()
|
|
except OSError:
|
|
continue
|
|
total_size += (size := st.st_size)
|
|
if size > MAX_SINGLE_FILE_KB * 1024:
|
|
add("oversized_file", "medium", "structural", rel, f"{size // 1024}KB",
|
|
f"file is {size // 1024}KB (limit: {MAX_SINGLE_FILE_KB}KB)")
|
|
if (ext := f.suffix.lower()) in SUSPICIOUS_BINARY_EXTENSIONS:
|
|
add("binary_file", "critical", "structural", rel, f"binary: {ext}",
|
|
f"binary/executable file ({ext}) should not be in a skill")
|
|
if ext not in _SCRIPT_EXTENSIONS and st.st_mode & 0o111:
|
|
add("unexpected_executable", "medium", "structural", rel, "executable bit set",
|
|
"file has executable permission but is not a recognized script type")
|
|
if file_count > MAX_FILE_COUNT:
|
|
add("too_many_files", "medium", "structural", "(directory)", f"{file_count} files",
|
|
f"skill has {file_count} files (limit: {MAX_FILE_COUNT})")
|
|
if total_size > MAX_TOTAL_SIZE_KB * 1024: # informational only: large skills are legitimate
|
|
add("oversized_skill", "low", "structural", "(directory)", f"{total_size // 1024}KB total",
|
|
f"skill is {total_size // 1024}KB total (limit: {MAX_TOTAL_SIZE_KB}KB)")
|
|
return findings
|
|
|
|
|
|
# `.skillignore` is Hermes-native; `.clawhubignore` is honored for skills published through ClawHub.
|
|
_SKILL_IGNORE_FILENAMES = (".skillignore", ".clawhubignore")
|
|
|
|
|
|
def _load_skill_ignore(skill_dir: Path):
|
|
"""Build ``ignore(rel_posix_path) -> bool`` from `.skillignore` / `.clawhubignore`. gitignore basics: blank
|
|
lines and ``#`` comments skipped; trailing ``/`` = directory (it and everything under it); ``*``/``?`` globs via
|
|
fnmatch on the full path and each segment; leading ``/`` anchors to the root. Ignore files always excluded;
|
|
``SKILL.md`` never."""
|
|
patterns: List[str] = []
|
|
for ig in (skill_dir / name for name in _SKILL_IGNORE_FILENAMES):
|
|
with suppress(UnicodeDecodeError, OSError):
|
|
if ig.is_file():
|
|
patterns.extend(s for s in map(str.strip, ig.read_text(encoding="utf-8").splitlines())
|
|
if s and not s.startswith("#"))
|
|
|
|
def ignore(rel: str) -> bool:
|
|
rel_posix = Path(rel).as_posix()
|
|
segs = rel_posix.split("/")
|
|
base = segs[-1]
|
|
if base == "SKILL.md":
|
|
return False
|
|
if base in _SKILL_IGNORE_FILENAMES:
|
|
return True
|
|
for pat in patterns:
|
|
anchored = pat.startswith("/")
|
|
p = pat.strip("/")
|
|
if not p:
|
|
continue
|
|
below = rel_posix.startswith(p + "/")
|
|
if pat.endswith("/"): # the dir itself or anything under it; unanchored also as an inner path component
|
|
if rel_posix == p or below or (not anchored and ("/" + p + "/") in ("/" + rel_posix + "/")):
|
|
return True
|
|
# Unanchored: also the basename, any path segment, or a prefix dir (`docs` ignores docs/plans/x.md).
|
|
elif fnmatch.fnmatch(rel_posix, p) or (not anchored and (fnmatch.fnmatch(base, p) or below or (
|
|
"/" not in p and any(fnmatch.fnmatch(seg, p) for seg in segs)))):
|
|
return True
|
|
return False
|
|
|
|
return ignore
|
|
|
|
|
|
_SOURCE_PREFIX_ALIASES = ("skills-sh/", "skills.sh/", "skils-sh/", "skils.sh/")
|
|
|
|
|
|
def _resolve_trust_level(source: str) -> str:
|
|
"""Source id -> trust level. "official" is provenance, not a user-controlled GitHub id like "official/<repo>";
|
|
trusted repos match exactly or as a skill path inside the repo — never a sibling sharing the prefix."""
|
|
src = source[len(next((p for p in _SOURCE_PREFIX_ALIASES if source.startswith(p)), "")):]
|
|
if src == "agent-created":
|
|
return "agent-created"
|
|
if src == "official":
|
|
return "builtin"
|
|
return "trusted" if any(src == t or src.startswith(f"{t}/") for t in TRUSTED_REPOS) else "community"
|
|
|
|
|
|
def _determine_verdict(findings: List[Finding]) -> str:
|
|
"""critical → dangerous, high → caution; medium/low alone are informational (safe)."""
|
|
sev = {f.severity for f in findings}
|
|
return "dangerous" if "critical" in sev else "caution" if "high" in sev else "safe"
|
|
|
|
|
|
def _build_summary(name: str, source: str, trust: str, verdict: str, findings: List[Finding]) -> str:
|
|
if not findings:
|
|
return f"{name}: clean scan, no threats detected"
|
|
return f"{name}: {verdict} — {len(findings)} finding(s) in {', '.join(sorted({f.category for f in findings}))}"
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
|
|
def full_content_hash(skill_path: Path) -> str:
|
|
"""Full canonical digest used to bind scanner attestations."""
|
|
return f"sha256:{_content_digest(skill_path)}"
|
|
# ---- END PLUGIN-COMPAT ----
|