Files
deer-flow/backend/packages/harness/deerflow/skills/skillscan/orchestrator.py
T
Daoyuan LiandGitHub 0cd55067f3 fix(skills): reject colon in zip member names to close NTFS ADS smuggling gap (#4236)
Neither is_unsafe_zip_member (installer.py) nor its duplicated check in
skillscan/orchestrator.py rejected a colon in a zip member name. On
Windows/NTFS, a name like scripts/run.sh:hidden.txt addresses an
Alternate Data Stream on run.sh instead of creating a new file, so the
hidden content is invisible to Path.rglob()/os.walk()-based scanning in
both the deterministic static scanner and the extracted-file content
scanner, while still landing genuinely on disk. Reject any colon in a
zip member's relative path outright in both files; a colon has no
legitimate use there since zip entries use forward slashes and a real
Windows drive prefix is already caught by the existing absolute-path
check.
2026-07-19 22:36:15 +08:00

710 lines
35 KiB
Python

"""Native deterministic scanning for DeerFlow skills.
``scan_archive_preflight()`` and ``scan_skill_dir()`` are synchronous pure
functions of their inputs; async callers must dispatch them off the event
loop. Policy is one code constant — ``CRITICAL`` blocks, everything else is a
warning — applied by ``enforce_static_scan()``, which also honours the
``skill_scan.enabled`` kill switch. Rule specs live next to the analyzers
that match them so a rule is authored, read, and tested in one place.
"""
from __future__ import annotations
import ast
import io
import logging
import posixpath
import re
import stat
import zipfile
from collections.abc import Iterable
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any
from deerflow.skills.package_paths import is_eval_fixture_skill_md
from deerflow.skills.skillscan.models import (
FindingSeverity,
RuleSpec,
ScanResult,
SecurityFinding,
StaticScanBlockedError,
StaticScannerError,
)
logger = logging.getLogger(__name__)
MAX_TOTAL_ARCHIVE_BYTES = 512 * 1024 * 1024
MAX_FILE_BYTES = 64 * 1024 * 1024
_BLOCK_SEVERITY = "CRITICAL"
_NESTED_ZIP_PEEK_MEMBER_LIMIT = 256
_MAX_ARCHIVE_MEMBERS = 4096
_SPECS = [
RuleSpec("package-path-traversal", "CRITICAL", "Archive member path traverses outside the skill root.", "Remove parent-directory traversal from the package path."),
RuleSpec("package-absolute-path", "CRITICAL", "Archive member path is absolute.", "Use relative paths inside the skill archive."),
RuleSpec(
"package-ads-stream-name",
"CRITICAL",
"Archive member path contains a colon, which on Windows/NTFS addresses an alternate data stream hidden from directory listing.",
"Remove colons from archive member paths.",
),
RuleSpec("package-symlink", "HIGH", "Package contains a symlink entry.", "Remove symlinks from the skill package."),
RuleSpec("package-nested-skill-md", "CRITICAL", "Package contains a nested SKILL.md file.", "Keep exactly one SKILL.md at the skill root."),
RuleSpec("package-oversized-total", "CRITICAL", "Package total uncompressed size exceeds the limit.", "Remove large files or split assets out of the skill package."),
RuleSpec("package-too-many-members", "CRITICAL", "Package contains more members than the allowed limit.", "Reduce the number of files in the skill package."),
RuleSpec("package-oversized-file", "CRITICAL", "Package contains a file that exceeds the per-file size limit.", "Remove or shrink the oversized file."),
RuleSpec("package-executable-binary", "CRITICAL", "Package contains an executable binary.", "Remove binary executables from the skill package."),
RuleSpec("package-nested-archive", "HIGH", "Package contains a nested archive file.", "Unpack and review nested archives before packaging the skill."),
RuleSpec("package-hidden-sensitive-file", "HIGH", "Package contains a hidden sensitive file.", "Remove hidden credential or package-manager config files."),
RuleSpec("package-git-directory", "MEDIUM", "Package contains a .git directory.", "Package only source files needed by the skill, excluding repository metadata."),
RuleSpec("secret-private-key", "CRITICAL", "Private key material is embedded in skill content.", "Move private keys to a managed secret store and remove them from the skill."),
RuleSpec("secret-cloud-token", "CRITICAL", "High-confidence cloud or API token is embedded in skill content.", "Move tokens to environment variables or a secret store."),
RuleSpec("secret-env-assignment", "HIGH", "Secret-like assignment contains a non-placeholder value.", "Replace hardcoded credentials with documented runtime configuration."),
RuleSpec("declaration-prompt-override", "HIGH", "SKILL.md contains a prompt override phrase.", "Rephrase examples so they describe unsafe text instead of instructing the agent to follow it."),
RuleSpec("declaration-sensitive-capability", "HIGH", "SKILL.md declares a sensitive capability.", "Make the capability explicit, narrow, and justified, or remove it."),
RuleSpec("declaration-sensitive-path", "HIGH", "SKILL.md references sensitive host or credential paths.", "Remove references to sensitive host paths unless they are harmless documentation."),
RuleSpec("declaration-external-endpoint", "MEDIUM", "SKILL.md declares an external network endpoint.", "Document why the endpoint is needed and prefer HTTPS."),
RuleSpec("python-dynamic-exec", "CRITICAL", "Python dynamic code execution primitive is used in a skill file.", "Remove dynamic execution and replace it with explicit typed logic."),
RuleSpec("python-shell-exec", "CRITICAL", "Python shell execution primitive is used in a skill file.", "Use subprocess with a fixed argument list and shell=False, or remove shell execution."),
RuleSpec("python-sensitive-exfil", "CRITICAL", "Python code reads a sensitive path and uses an outbound network sink in the same file.", "Remove the sensitive read or network sink, and keep credential access outside skills."),
RuleSpec("python-env-dump-exfil", "CRITICAL", "Python code reads the process environment in bulk and uses an outbound network sink in the same file.", "Avoid bulk environment reads and never send environment data over the network."),
RuleSpec("python-reverse-shell", "CRITICAL", "Python code matches a reverse-shell shape.", "Remove reverse-shell behavior from the skill."),
RuleSpec("python-dynamic-import", "HIGH", "Python dynamically imports a non-literal module.", "Use explicit imports or a constrained allowlist."),
RuleSpec("python-subprocess", "HIGH", "Python invokes subprocess without shell=True.", "Review subprocess usage and keep arguments fixed and minimal."),
RuleSpec("python-sensitive-path-read", "HIGH", "Python reads a sensitive path.", "Remove sensitive host-path access from the skill."),
RuleSpec("python-unsafe-deserialization", "MEDIUM", "Python uses unsafe deserialization.", "Use safe loaders or trusted typed formats."),
RuleSpec("shell-reverse-shell", "CRITICAL", "Shell script contains a reverse-shell idiom.", "Remove reverse-shell behavior from the skill."),
RuleSpec("shell-reverse-shell-heuristic", "HIGH", "Shell script resembles a reverse-shell idiom.", "Confirm this is not reverse-shell behavior; unmistakable reverse-shell signals are blocked outright."),
RuleSpec("shell-sensitive-exfil", "CRITICAL", "Shell script reads sensitive paths and sends data over the network.", "Remove sensitive reads or outbound transfer commands."),
RuleSpec("shell-curl-pipe-shell", "HIGH", "Shell script pipes remote content into a shell.", "Download, verify, and execute reviewed code explicitly instead."),
RuleSpec("shell-destructive-command", "HIGH", "Shell script contains an unmistakably destructive command.", "Remove destructive commands from skill scripts."),
RuleSpec("shell-env-dump", "MEDIUM", "Shell script dumps the environment.", "Avoid bulk environment dumps in skills."),
RuleSpec("network-cloud-metadata", "CRITICAL", "Skill content references a cloud metadata service.", "Remove cloud metadata access from the skill."),
RuleSpec("resource-fork-bomb", "CRITICAL", "Skill content contains a fork-bomb pattern.", "Remove resource-exhaustion payloads."),
RuleSpec("network-cleartext-http", "MEDIUM", "Skill content references a non-local cleartext HTTP endpoint.", "Use HTTPS or document why cleartext local development is required."),
RuleSpec("network-local-http", "LOW", "Skill content references a local HTTP endpoint.", "Confirm the local endpoint is expected for this skill."),
]
RULES: dict[str, RuleSpec] = {spec.rule_id: spec for spec in _SPECS}
_ARCHIVE_SUFFIXES = (
".zip",
".tar",
".tar.gz",
".tgz",
".tar.bz2",
".tbz2",
".tar.xz",
".txz",
".7z",
".rar",
".whl",
)
_HIDDEN_SENSITIVE_FILES = {
".env",
".npmrc",
".pypirc",
".netrc",
"credentials",
"config",
}
_PLACEHOLDER_VALUES = {"", "x", "xx", "xxx", "xxxx", "changeme", "change-me", "example", "placeholder", "test", "dummy", "your-key", "<your-key>"}
_SENSITIVE_PATH_RE = re.compile(r"(~/.ssh|/etc/passwd|/etc/shadow|/var/run/docker\.sock|docker\.sock|169\.254\.169\.254)")
_EXTERNAL_HTTP_RE = re.compile(r"http://([A-Za-z0-9.-]+)(?::\d+)?(?:/|\b)")
_URL_RE = re.compile(r"https?://[^\s)'\"<>]+")
_LOCAL_HTTP_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
# `rm` with a recursive flag (any order/combination, optional --no-preserve-root)
# targeting the filesystem root, a wildcard, or a complete system-root directory.
# Subpaths like ``/tmp/scratch`` or ``/home/user/project`` stay unflagged.
_DESTRUCTIVE_RM_RE = (
r"\brm\s+(?:-\S+\s+|--no-preserve-root\s+)*-\S*[rR]\S*\s+"
r"(?:-\S+\s+|--no-preserve-root\s+)*"
r"/(?:\*|\s|$|(?:bin|boot|dev|etc|home|lib|lib64|opt|proc|root|run|sbin|srv|sys|usr|var)(?:/\*?)?(?:\s|$))"
)
def skill_scan_enabled(app_config: Any | None = None) -> bool:
if app_config is None:
try:
from deerflow.config import get_app_config
app_config = get_app_config()
except Exception:
app_config = None
skill_scan_config = getattr(app_config, "skill_scan", None)
if skill_scan_config is not None and hasattr(skill_scan_config, "enabled"):
return bool(skill_scan_config.enabled)
return True
def format_static_findings(findings: list[SecurityFinding]) -> str:
parts = []
for finding in findings:
location = finding["file"] or "<archive>"
if finding["line"] is not None:
location = f"{location}:{finding['line']}"
parts.append(f"{finding['rule_id']} ({finding['severity']}) at {location}: {finding['message']} Remediation: {finding['remediation']}")
return "; ".join(parts)
def enforce_static_scan(
skill_dir: Path,
*,
skill_name: str | None = None,
app_config: Any | None = None,
) -> list[SecurityFinding]:
if not skill_scan_enabled(app_config):
return []
result = scan_skill_dir(Path(skill_dir))
blocked = [finding for finding in result["findings"] if finding["severity"] == _BLOCK_SEVERITY]
if blocked:
raise StaticScanBlockedError(
blocked,
skill_name=skill_name,
message=f"Static security scan blocked skill '{skill_name}': {format_static_findings(blocked)}" if skill_name else f"Static security scan blocked skill content: {format_static_findings(blocked)}",
)
if result["scanner_errors"]:
logger.warning("SkillScan analyzer errors for %s: %s", skill_name or skill_dir, "; ".join(result["scanner_errors"]))
warnings = [finding for finding in result["findings"] if finding["severity"] != _BLOCK_SEVERITY]
if warnings:
logger.warning("SkillScan warning findings for %s: %s", skill_name or skill_dir, format_static_findings(warnings))
return [dict(finding) for finding in result["findings"]] # type: ignore[misc]
def scan_archive_preflight(archive_path: Path) -> ScanResult:
findings: list[SecurityFinding] = []
scanner_errors: list[str] = []
total_size = 0
try:
with zipfile.ZipFile(archive_path, "r") as zf:
members = zf.infolist()
if len(members) > _MAX_ARCHIVE_MEMBERS:
# Early-abort before the per-member reads below: a huge member
# count is a bounded DoS vector even when the total size is small.
finding = _finding("package-too-many-members", file=None, evidence=f"{len(members)} members")
return _scan_result([finding], scanner_errors)
for info in members:
normalized = _normalize_archive_name(info.filename)
findings.extend(_scan_archive_member_metadata(info, normalized))
if info.is_dir():
continue
total_size += max(info.file_size, 0)
if info.file_size > MAX_FILE_BYTES:
findings.append(_finding("package-oversized-file", file=normalized, evidence=f"{info.file_size} bytes"))
if _is_hidden_sensitive_path(normalized):
findings.append(_finding("package-hidden-sensitive-file", file=normalized, evidence=Path(normalized).name))
if ".git" in PurePosixPath(normalized).parts:
findings.append(_finding("package-git-directory", file=normalized, evidence=".git"))
if _is_symlink_member(info):
continue
try:
with zf.open(info) as member:
prefix = member.read(8)
except Exception as e:
scanner_errors.append(f"{normalized}: failed to read archive member prefix: {e}")
continue
if _is_executable_binary(prefix):
findings.append(_finding("package-executable-binary", file=normalized, evidence=_binary_magic_evidence(prefix)))
if _is_nested_archive_name(normalized) or _looks_like_archive(prefix):
findings.append(_nested_archive_finding(normalized, prefix, lambda: _read_archive_member(zf, info), scanner_errors))
if total_size > MAX_TOTAL_ARCHIVE_BYTES:
findings.append(_finding("package-oversized-total", file=None, evidence=f"{total_size} bytes"))
except (zipfile.BadZipFile, OSError) as e:
raise StaticScannerError(f"failed to read skill archive: {e}") from e
return _scan_result(_dedupe(findings), scanner_errors)
def scan_skill_dir(skill_dir: Path) -> ScanResult:
root = Path(skill_dir)
if not root.is_dir():
raise StaticScannerError(f"skill_dir is not a directory: {root}")
findings: list[SecurityFinding] = []
scanner_errors: list[str] = []
for path in sorted(candidate for candidate in root.rglob("*") if candidate.is_file()):
rel_path = _relative_file(path, root)
try:
file_bytes = path.read_bytes()
except OSError as e:
scanner_errors.append(f"{rel_path}: failed to read file: {e}")
continue
findings.extend(_scan_file_package_properties(rel_path, file_bytes, path.stat().st_size))
text = _decode_text_for_analysis(file_bytes)
if text is None:
continue
try:
findings.extend(_scan_text_file(rel_path, text))
except Exception as e:
scanner_errors.append(f"{rel_path}: analyzer failed: {e}")
logger.warning("SkillScan analyzer failed for %s", rel_path, exc_info=True)
return _scan_result(_dedupe(findings), scanner_errors)
def _scan_archive_member_metadata(info: zipfile.ZipInfo, normalized: str) -> list[SecurityFinding]:
findings: list[SecurityFinding] = []
if _archive_member_is_absolute(info.filename):
findings.append(_finding("package-absolute-path", file=normalized, evidence=info.filename))
elif _archive_member_traverses(info.filename):
findings.append(_finding("package-path-traversal", file=normalized, evidence=info.filename))
elif _archive_member_has_colon(info.filename):
findings.append(_finding("package-ads-stream-name", file=normalized, evidence=info.filename))
if _is_symlink_member(info):
findings.append(_finding("package-symlink", file=normalized, evidence=info.filename))
parts = PurePosixPath(normalized).parts
if parts and parts[-1] == "SKILL.md" and len(parts) > 2 and not is_eval_fixture_skill_md(PurePosixPath(normalized)):
findings.append(_finding("package-nested-skill-md", file=normalized, evidence=normalized))
return findings
def _scan_file_package_properties(rel_path: str, file_bytes: bytes, file_size: int) -> list[SecurityFinding]:
findings: list[SecurityFinding] = []
path = PurePosixPath(rel_path)
if path.name == "SKILL.md" and len(path.parts) > 1 and not is_eval_fixture_skill_md(path):
findings.append(_finding("package-nested-skill-md", file=rel_path, evidence=rel_path))
if file_size > MAX_FILE_BYTES:
findings.append(_finding("package-oversized-file", file=rel_path, evidence=f"{file_size} bytes"))
if _is_hidden_sensitive_path(rel_path):
findings.append(_finding("package-hidden-sensitive-file", file=rel_path, evidence=path.name))
if ".git" in path.parts:
findings.append(_finding("package-git-directory", file=rel_path, evidence=".git"))
if _is_nested_archive_name(rel_path) or _looks_like_archive(file_bytes):
findings.append(_nested_archive_finding(rel_path, file_bytes[:8], lambda: file_bytes, []))
if _is_executable_binary(file_bytes[:8]):
findings.append(_finding("package-executable-binary", file=rel_path, evidence=_binary_magic_evidence(file_bytes[:8])))
return findings
def _scan_text_file(rel_path: str, text: str) -> list[SecurityFinding]:
findings: list[SecurityFinding] = []
findings.extend(_scan_secrets(rel_path, text))
if PurePosixPath(rel_path).name == "SKILL.md":
findings.extend(_scan_declaration(rel_path, text))
if _is_python_path(rel_path, text):
findings.extend(_scan_python(rel_path, text))
if _is_shell_path(rel_path, text):
findings.extend(_scan_shell(rel_path, text))
findings.extend(_scan_network_and_resource(rel_path, text))
return findings
def _scan_secrets(rel_path: str, text: str) -> list[SecurityFinding]:
findings: list[SecurityFinding] = []
private_key = re.search(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", text)
if private_key:
findings.append(_finding_from_match("secret-private-key", rel_path, text, private_key))
token_patterns = [
r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b",
r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b",
r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b",
r"\bsk-[A-Za-z0-9]{20,}\b",
]
for pattern in token_patterns:
match = re.search(pattern, text)
if match and not _looks_like_placeholder(match.group(0)):
findings.append(_finding_from_match("secret-cloud-token", rel_path, text, match))
break
assignment_re = re.compile(r"(?im)\b(token|password|passwd|api[_-]?key|secret|credential)s?\b\s*[:=]\s*[\"']?([^\"'\s#]+)")
for match in assignment_re.finditer(text):
value = match.group(2).strip()
if not _looks_like_placeholder(value):
findings.append(_finding_from_match("secret-env-assignment", rel_path, text, match))
break
return findings
def _scan_declaration(rel_path: str, text: str) -> list[SecurityFinding]:
findings: list[SecurityFinding] = []
prompt_re = re.compile(r"(?i)\b(ignore|disregard)\s+(all\s+)?(previous|prior)\s+instructions\b|\boverride\s+(the\s+)?(system|developer)\s+instructions\b")
if match := prompt_re.search(text):
findings.append(_finding_from_match("declaration-prompt-override", rel_path, text, match))
capability_re = re.compile(r"(?i)(execute\s+(arbitrary\s+)?commands?|shell\s+commands?|credential\s+access|read\s+secrets?|arbitrary\s+network|network\s+egress)")
if match := capability_re.search(text):
findings.append(_finding_from_match("declaration-sensitive-capability", rel_path, text, match))
if match := _SENSITIVE_PATH_RE.search(text):
findings.append(_finding_from_match("declaration-sensitive-path", rel_path, text, match))
for match in _URL_RE.finditer(text):
if match.group(0).startswith("http://"):
host = _http_host(match.group(0))
if host and host not in _LOCAL_HTTP_HOSTS:
findings.append(_finding_from_match("declaration-external-endpoint", rel_path, text, match))
break
return findings
def _scan_python(rel_path: str, text: str) -> list[SecurityFinding]:
findings: list[SecurityFinding] = []
try:
tree = ast.parse(text)
except SyntaxError:
return findings
aliases = _collect_python_aliases(tree)
has_sensitive_read = False
has_env_dump = False
has_network_sink = False
sensitive_node: ast.AST | None = None
env_node: ast.AST | None = None
network_node: ast.AST | None = None
reverse_shell_parts: set[str] = set()
reverse_shell_node: ast.AST | None = None
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if _SENSITIVE_PATH_RE.search(node.value):
has_sensitive_read = True
sensitive_node = sensitive_node or node
if _is_outbound_url(node.value):
has_network_sink = True
network_node = network_node or node
if isinstance(node, (ast.Attribute, ast.Name)) and _python_name(node, aliases) == "os.environ":
has_env_dump = True
env_node = env_node or node
if not isinstance(node, ast.Call):
continue
call_name = _python_call_name(node, aliases)
if call_name in {"eval", "exec"} or (call_name == "compile" and _compile_mode_is_exec(node)):
findings.append(_finding_for_node("python-dynamic-exec", rel_path, node, call_name))
elif call_name in {"os.system", "os.popen"} or (call_name.startswith("subprocess.") and _call_has_shell_true(node)):
findings.append(_finding_for_node("python-shell-exec", rel_path, node, call_name))
elif call_name.startswith("subprocess."):
findings.append(_finding_for_node("python-subprocess", rel_path, node, call_name))
elif call_name == "__import__" or call_name == "importlib.import_module":
if not node.args or not isinstance(node.args[0], ast.Constant):
findings.append(_finding_for_node("python-dynamic-import", rel_path, node, call_name))
elif call_name in {"pickle.load", "pickle.loads"} or (call_name == "yaml.load" and not _yaml_load_uses_safe_loader(node)):
findings.append(_finding_for_node("python-unsafe-deserialization", rel_path, node, call_name))
if _call_is_network_sink(call_name):
has_network_sink = True
network_node = network_node or node
if call_name == "os.dup2":
reverse_shell_parts.add("dup2")
reverse_shell_node = reverse_shell_node or node
elif call_name in {"socket.socket", "socket.create_connection"}:
reverse_shell_parts.add("socket")
elif call_name.startswith("subprocess.") or call_name in {"os.system", "os.popen"}:
reverse_shell_parts.add("subprocess")
if {"dup2", "socket", "subprocess"} <= reverse_shell_parts:
findings.append(_finding_for_node("python-reverse-shell", rel_path, reverse_shell_node, "socket + dup2 + subprocess"))
if has_sensitive_read and has_network_sink:
findings.append(_finding_for_node("python-sensitive-exfil", rel_path, sensitive_node or network_node, "sensitive read + network sink"))
elif has_sensitive_read:
findings.append(_finding_for_node("python-sensitive-path-read", rel_path, sensitive_node, "sensitive path read"))
if has_env_dump and has_network_sink:
findings.append(_finding_for_node("python-env-dump-exfil", rel_path, env_node or network_node, "environment dump + network sink"))
return findings
def _scan_shell(rel_path: str, text: str) -> list[SecurityFinding]:
findings: list[SecurityFinding] = []
# Unmistakable reverse-shell signals hard-block; weaker idioms (bash -i,
# mkfifo) only warn->LLM because they appear in legitimate scripts.
if match := re.search(r"(/dev/tcp/|nc\s+-e\b)", text):
findings.append(_finding_from_match("shell-reverse-shell", rel_path, text, match))
if match := re.search(r"(bash\s+-i\b|mkfifo\s+)", text):
findings.append(_finding_from_match("shell-reverse-shell-heuristic", rel_path, text, match))
if re.search(r"(/etc/shadow|/etc/passwd)", text) and re.search(r"\b(curl|wget|nc|scp)\b", text):
findings.append(_finding_for_text("shell-sensitive-exfil", rel_path, text, "/etc"))
if match := re.search(r"\b(curl|wget)\b[^\n|;]*\|\s*(?:sh|bash)\b", text):
findings.append(_finding_from_match("shell-curl-pipe-shell", rel_path, text, match))
if match := re.search(_DESTRUCTIVE_RM_RE + r"|:\(\)\{\s*:\|:&\s*\};:|dd\s+[^#\n]*\bof=/dev/", text):
findings.append(_finding_from_match("shell-destructive-command", rel_path, text, match))
if match := re.search(r"\b(env|printenv|export\s+-p)\b", text):
findings.append(_finding_from_match("shell-env-dump", rel_path, text, match))
return findings
def _scan_network_and_resource(rel_path: str, text: str) -> list[SecurityFinding]:
findings: list[SecurityFinding] = []
if match := re.search(r"(169\.254\.169\.254|metadata\.google\.internal)", text):
findings.append(_finding_from_match("network-cloud-metadata", rel_path, text, match))
if match := re.search(r":\(\)\{\s*:\|:&\s*\};:", text):
findings.append(_finding_from_match("resource-fork-bomb", rel_path, text, match))
for match in _EXTERNAL_HTTP_RE.finditer(text):
host = match.group(1)
if host in _LOCAL_HTTP_HOSTS or host.startswith("10.") or host.startswith("192.168.") or re.match(r"172\.(1[6-9]|2\d|3[01])\.", host):
findings.append(_finding_from_match("network-local-http", rel_path, text, match))
else:
findings.append(_finding_from_match("network-cleartext-http", rel_path, text, match))
break
return findings
def _finding(rule_id: str, *, file: str | None, evidence: str | None, line: int | None = None, severity: FindingSeverity | None = None) -> SecurityFinding:
spec = RULES[rule_id]
if evidence is not None and rule_id.startswith("secret-"):
evidence = _redact_secret_evidence(evidence)
return {
"rule_id": rule_id,
"severity": severity or spec.severity,
"file": file,
"line": line,
"message": spec.message,
"remediation": spec.remediation,
"evidence": evidence,
}
def _finding_from_match(rule_id: str, rel_path: str, text: str, match: re.Match[str]) -> SecurityFinding:
return _finding(rule_id, file=rel_path, line=_line_number(text, match.start()), evidence=match.group(0))
def _finding_for_text(rule_id: str, rel_path: str, text: str, evidence: str) -> SecurityFinding:
index = text.find(evidence)
return _finding(rule_id, file=rel_path, line=_line_number(text, index if index >= 0 else 0), evidence=evidence)
def _finding_for_node(rule_id: str, rel_path: str, node: ast.AST | None, evidence: str) -> SecurityFinding:
return _finding(rule_id, file=rel_path, line=getattr(node, "lineno", 1), evidence=evidence)
def _nested_archive_finding(rel_path: str, prefix: bytes, read_data, scanner_errors: list[str]) -> SecurityFinding:
name = PurePosixPath(rel_path).name
if prefix.startswith(b"PK\x03\x04"):
try:
data = read_data()
except Exception as e:
scanner_errors.append(f"{rel_path}: failed to read nested archive for inspection: {e}")
else:
if data is not None and _nested_zip_contains_executable(data):
return _finding("package-nested-archive", file=rel_path, evidence=f"{name}: contains an executable binary member", severity="CRITICAL")
return _finding("package-nested-archive", file=rel_path, evidence=name)
def _nested_zip_contains_executable(data: bytes) -> bool:
try:
with zipfile.ZipFile(io.BytesIO(data)) as nested:
for info in nested.infolist()[:_NESTED_ZIP_PEEK_MEMBER_LIMIT]:
if info.is_dir():
continue
try:
with nested.open(info) as member:
if _is_executable_binary(member.read(8)):
return True
except Exception:
continue
except (zipfile.BadZipFile, OSError):
return False
return False
def _read_archive_member(zf: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes | None:
if info.file_size > MAX_FILE_BYTES:
return None
with zf.open(info) as member:
return member.read(MAX_FILE_BYTES + 1)
def _redact_secret_evidence(value: str) -> str:
# Drop the value entirely: the rule_id already names the secret category, and
# any retained prefix (e.g. value[:6]) leaks real token bytes into findings
# that flow to Gateway responses and LLM context.
return "[redacted]"
def _scan_result(findings: list[SecurityFinding], scanner_errors: list[str]) -> ScanResult:
blocked = any(finding["severity"] == _BLOCK_SEVERITY for finding in findings)
return {"findings": findings, "blocked": blocked, "scanner_errors": scanner_errors}
def _dedupe(findings: Iterable[SecurityFinding]) -> list[SecurityFinding]:
seen: set[tuple[str, str | None, int | None]] = set()
deduped: list[SecurityFinding] = []
for finding in findings:
key = (finding["rule_id"], finding["file"], finding["line"])
if key in seen:
continue
seen.add(key)
deduped.append(finding)
return deduped
def _line_number(text: str, index: int) -> int:
return text[: max(index, 0)].count("\n") + 1
def _normalize_archive_name(name: str) -> str:
return posixpath.normpath(name.replace("\\", "/")).removeprefix("./")
def _archive_member_is_absolute(name: str) -> bool:
normalized = name.replace("\\", "/")
return normalized.startswith("/") or PurePosixPath(normalized).is_absolute() or PureWindowsPath(name).is_absolute()
def _archive_member_traverses(name: str) -> bool:
return ".." in PurePosixPath(name.replace("\\", "/")).parts
def _archive_member_has_colon(name: str) -> bool:
# A colon has no legitimate use in a relative archive member path (zip
# entries use ``/`` separators; a real Windows drive prefix is already
# caught by ``_archive_member_is_absolute``). On Windows/NTFS a colon
# elsewhere in the path addresses an Alternate Data Stream on the
# preceding path component (e.g. ``scripts/run.sh:hidden.txt`` attaches
# hidden content to ``run.sh`` instead of creating a sibling file), and
# that stream is invisible to directory-listing-based scanning. Reject
# outright rather than trying to allow-list "safe" colon positions.
return ":" in name
def _is_symlink_member(info: zipfile.ZipInfo) -> bool:
return stat.S_ISLNK(info.external_attr >> 16)
def _relative_file(path: Path, root: Path) -> str:
return path.resolve().relative_to(root.resolve()).as_posix()
def _is_hidden_sensitive_path(rel_path: str) -> bool:
parts = PurePosixPath(rel_path).parts
if ".aws" in parts and parts[-1] == "credentials":
return True
if ".git" in parts and parts[-1] == "config":
return True
return parts[-1] in _HIDDEN_SENSITIVE_FILES and (parts[-1].startswith(".") or any(token in parts[-1].lower() for token in ("credential", "npmrc", "pypirc", "netrc")))
def _is_nested_archive_name(rel_path: str) -> bool:
lower = rel_path.lower()
return any(lower.endswith(suffix) for suffix in _ARCHIVE_SUFFIXES)
def _looks_like_archive(file_bytes: bytes) -> bool:
return file_bytes.startswith(b"PK\x03\x04") or file_bytes.startswith(b"\x1f\x8b") or file_bytes.startswith(b"7z\xbc\xaf\x27\x1c")
def _is_executable_binary(prefix: bytes) -> bool:
return prefix.startswith(b"\x7fELF") or prefix.startswith(b"MZ") or prefix.startswith((b"\xfe\xed\xfa", b"\xcf\xfa\xed\xfe", b"\xca\xfe\xba\xbe"))
def _binary_magic_evidence(prefix: bytes) -> str:
if prefix.startswith(b"\x7fELF"):
return "ELF"
if prefix.startswith(b"MZ"):
return "PE"
return "Mach-O"
def _decode_text_for_analysis(file_bytes: bytes) -> str | None:
# Binaries are rejected by the NUL probe and the decode failure below, so
# every NUL-free, UTF-8-decodable file is analyzed regardless of extension.
if b"\x00" in file_bytes[:4096]:
return None
try:
return file_bytes.decode("utf-8")
except UnicodeDecodeError:
return None
def _is_python_path(rel_path: str, text: str) -> bool:
return PurePosixPath(rel_path).suffix.lower() == ".py" or text.startswith("#!") and "python" in text.splitlines()[0].lower()
def _is_shell_path(rel_path: str, text: str) -> bool:
suffix = PurePosixPath(rel_path).suffix.lower()
return suffix in {".sh", ".bash"} or text.startswith("#!") and any(shell in text.splitlines()[0].lower() for shell in ("sh", "bash", "zsh"))
def _looks_like_placeholder(value: str) -> bool:
normalized = value.strip().strip("\"'").lower()
if normalized in _PLACEHOLDER_VALUES:
return True
return normalized.startswith("<") or normalized.startswith("${") or "your" in normalized or "example" in normalized
def _http_host(url: str) -> str | None:
match = re.match(r"https?://\[?([^]/:]+)", url)
return match.group(1) if match else None
def _is_outbound_url(value: str) -> bool:
return bool(value.startswith(("http://", "https://")) and (_http_host(value) or "") not in _LOCAL_HTTP_HOSTS)
def _collect_python_aliases(tree: ast.AST) -> dict[str, str]:
aliases: dict[str, str] = {}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
aliases[alias.asname or alias.name] = alias.name
elif isinstance(node, ast.ImportFrom) and node.module:
for alias in node.names:
aliases[alias.asname or alias.name] = f"{node.module}.{alias.name}"
return aliases
def _python_name(node: ast.AST, aliases: dict[str, str]) -> str:
if isinstance(node, ast.Name):
return aliases.get(node.id, node.id)
if isinstance(node, ast.Attribute):
base = _python_name(node.value, aliases)
return f"{base}.{node.attr}" if base else node.attr
return ""
def _python_call_name(node: ast.Call, aliases: dict[str, str]) -> str:
return _python_name(node.func, aliases)
def _compile_mode_is_exec(node: ast.Call) -> bool:
if len(node.args) >= 3 and isinstance(node.args[2], ast.Constant):
return node.args[2].value == "exec"
return any(keyword.arg == "mode" and isinstance(keyword.value, ast.Constant) and keyword.value.value == "exec" for keyword in node.keywords)
def _call_has_shell_true(node: ast.Call) -> bool:
return any(keyword.arg == "shell" and isinstance(keyword.value, ast.Constant) and keyword.value.value is True for keyword in node.keywords)
def _call_is_network_sink(call_name: str) -> bool:
return call_name in {
"requests.get",
"requests.post",
"requests.put",
"requests.patch",
"requests.delete",
"requests.head",
"requests.options",
"requests.request",
"httpx.get",
"httpx.post",
"httpx.put",
"httpx.patch",
"httpx.delete",
"httpx.head",
"httpx.options",
"httpx.request",
"httpx.stream",
"urllib.request.urlopen",
"urllib.request.urlretrieve",
"socket.socket",
"socket.create_connection",
}
def _yaml_load_uses_safe_loader(node: ast.Call) -> bool:
for keyword in node.keywords:
if keyword.arg in {"Loader", "loader"}:
name = _python_name(keyword.value, {})
if "SafeLoader" in name:
return True
return False