# autoscan — Copilot Task Instructions **Project:** Chris4K/autoscan **Purpose:** These are precise implementation tasks for GitHub Copilot. Each task is self-contained: file to create, exact function signatures, inputs/outputs, edge cases, and how it connects to the existing architecture. Read `ARCHITECTURE.md` and `HOW_TO_EXTEND.md` before starting any task. All scanner modules follow the pattern in `scanners/radon_runner.py` (see that file as template). --- ## TASK 01 — CVE Trigger Correlator **File:** `scanners/cve_trigger_runner.py` **Category:** `ml-security` | **Group flag:** `run_security=True` **Binary required:** No — pure Python grep **Tool name (for frozenset):** `cve-trigger` ### What it does pip-audit already finds CVEs. This scanner cross-references each CVE found against reachable trigger patterns in the Python source. A CVE without a reachable trigger = skip. CVE + trigger in same repo = confirmed finding with severity CRITICAL. ### Function signature ```python def cve_trigger(work: str, pip_audit_findings: list[dict] | None = None) -> tuple[list[dict], str]: ``` `pip_audit_findings` is the output list from `pip_audit()` in the same scan. If `None`, re-run pip-audit internally to get CVEs. ### CVE → trigger mapping (hardcode this dict in the module) ```python CVE_TRIGGERS: dict[str, list[str]] = { # PyTorch "PYSEC-2026-139": ["torch.load("], # pt2 deserialization RCE "PYSEC-2025-194": ["torch.jit.script("], # JIT memory corruption RCE "PYSEC-2025-195": ["torch.lstm_cell("], # lstm_cell RCE "PYSEC-2025-192": ["pack_padded_sequence("], # RNN heap corruption "PYSEC-2025-193": ["unpack_sequence("], # RNN heap corruption "PYSEC-2025-210": ["torch.profiler.profile("], # profiler DoS "PYSEC-2025-191": ["mkldnn_max_pool2d("], # pool DoS "PYSEC-2025-197": ["caching_allocator_delete("], # CUDA allocator DoS "PYSEC-2026-89": ["markdown.markdown(", "Markdown("], # markdown DoS # Transformers "PYSEC-2025-211": ["from_pretrained("], # Perceiver deserialization "PYSEC-2025-212": ["from_pretrained("], # Transformer-XL deserialization "PYSEC-2025-213": ["from_pretrained("], # megatron_gpt2 deserialization "PYSEC-2025-214": ["convert_config("], # SEW code injection "PYSEC-2025-215": ["convert_config("], # SEW-D code injection "PYSEC-2025-216": ["convert_config("], # HuBERT code injection "PYSEC-2025-217": ["from_pretrained("], # X-CLIP deserialization "PYSEC-2025-218": ["from_pretrained("], # GLM4 deserialization # CVE-2026-1839: Transformers Trainer torch.load without weights_only "CVE-2026-1839": ["torch.load(", "_load_rng_state("], # CVE-2025-32434: torch.load weights_only=True still unsafe before torch 2.6 "CVE-2025-32434": ["torch.load("], # Keras CVE-2025-1550: custom layer RCE "CVE-2025-1550": ["keras.models.load_model(", "tf.keras.models.load_model("], # numpy "PYSEC-numpy-001": ["np.load(", "numpy.load("], # allow_pickle # joblib/sklearn "PYSEC-joblib-001":["joblib.load("], # pickle RCE } ``` ### Trigger validation rules For each trigger hit, apply these secondary checks before emitting a finding: | Trigger | Extra condition required | Reason | |---------|-------------------------|--------| | `torch.load(` | Line must NOT contain `weights_only=True` | weights_only=True is safe | | `np.load(` | Line must contain `allow_pickle=True` | default is safe since numpy 1.16.3 | | `from_pretrained(` | Argument must NOT be a string literal | hardcoded model names are acceptable | | `markdown.markdown(` | Argument must NOT be a string literal | static content is safe | ### Output per finding ```python make_finding( tool="cve-trigger", rule=cve_id, # e.g. "PYSEC-2026-139" severity="ERROR", file=rel_path, line=line_number, message=f"{cve_id} trigger found: `{trigger}` — {description}. " f"CVE confirmed exploitable if user input reaches this call.", owasp=["A06:2021-Vulnerable_and_Outdated_Components"], category="ml-security", confidence="confirmed", # both CVE and trigger present = confirmed ) ``` ### Edge cases - If pip-audit not available and no `pip_audit_findings` passed → return `([], "cve-trigger: pip-audit output unavailable")` - If CVE found in pip-audit but no trigger match in code → do NOT emit finding (this is the core value: filter noise) - Same line may match multiple CVEs → emit one finding per CVE, not per trigger - Binary files → skip silently ### Registration - `scanners/__init__.py`: add `from .cve_trigger_runner import cve_trigger` - `core/scanner.py` `_TASK_TO_TOOL`: `"cve-trigger": "cve-trigger"` - `core/scanner.py` task list: append after pip_audit task, pass its results - `sentinel/routes/discover.py` `_ALLOWED_SCANNERS`: add `"cve-trigger"` - `sentinel/services/scanner.py` `_TOOL_NAMES`: add `"cve-trigger"` to `_sec_tools` - `report/remediation.py`: add entries for each CVE ID --- ## TASK 02 — Gradio Global State Leak Detector **File:** `scanners/gradio_state_runner.py` **Category:** `ml-security` | **Group flag:** `run_security=True` **Binary required:** No — pure Python AST **Tool name:** `gradio-state` ### What it does Parses all Python files with `ast`. Finds calls to `gr.State()` or `gradio.State()` that appear at module scope (outside any function or class method). These create shared global state visible to ALL users. ### Function signature ```python def gradio_state(work: str) -> tuple[list[dict], str]: ``` ### Detection logic ```python import ast, pathlib class StateVisitor(ast.NodeVisitor): def __init__(self): self.depth = 0 # function/class nesting depth self.findings = [] def visit_FunctionDef(self, node): self.depth += 1 self.generic_visit(node) self.depth -= 1 visit_AsyncFunctionDef = visit_FunctionDef def visit_ClassDef(self, node): self.depth += 1 self.generic_visit(node) self.depth -= 1 def visit_Call(self, node): if self.depth == 0: # module scope only name = _call_name(node) # "gr.State" or "gradio.State" if name in ("gr.State", "gradio.State"): self.findings.append(node.lineno) self.generic_visit(node) ``` ### Severity escalation After finding global state, check the same file (and all files in `work`) for: - `gr.ChatInterface` or `gr.Chatbot` → escalate to `ERROR` / `confirmed` (full LLM conversation history leaks between users) - `openai`, `anthropic`, `requests.post` → escalate to `ERROR` (API calls may include prior user context) - Otherwise → `WARNING` / `likely` ### Output per finding ```python make_finding( tool="gradio-state", rule="GRADIO-GLOBAL-STATE", severity="ERROR" if has_llm else "WARNING", file=rel_path, line=line_no, message="gr.State() defined at module scope — shared across ALL concurrent users. " + ("LLM chat history will leak between sessions." if has_llm else "Any data stored here is visible to all users."), owasp=["A01:2021-Broken_Access_Control"], category="ml-security", confidence="confirmed" if has_llm else "likely", ) ``` ### Registration Same pattern as Task 01. Tool name: `"gradio-state"`. Add to `_sec_tools`. --- ## TASK 03 — Semgrep Rule Pack: ML Model Loading **File:** `rules/ml_pretrained.yaml` **Registration:** Add to `ALL_SECURITY` in `rules/__init__.py` **Label for semgrep_pack call:** `"Semgrep:ML-Pretrained"` ### Rules to implement **Rule 1: trust-remote-code-user-input** ```yaml - id: trust-remote-code-user-input patterns: - pattern: $M.from_pretrained($X, ..., trust_remote_code=True, ...) - pattern-not: $M.from_pretrained("...", ..., trust_remote_code=True, ...) message: > trust_remote_code=True with a non-literal model name executes arbitrary Python from the model repository. If $X is user-controlled this is unauthenticated RCE. Use a hardcoded model name or validate against an allowlist. severity: ERROR languages: [python] metadata: owasp: ["A03:2021-Injection"] confidence: confirmed category: ml-security cve: "PYSEC-2025-211,PYSEC-2025-218" ``` **Rule 2: torch-load-missing-weights-only** ```yaml - id: torch-load-missing-weights-only patterns: - pattern: torch.load($X, ...) - pattern-not: torch.load($X, ..., weights_only=True, ...) - pattern-not: torch.load("...", ...) message: > torch.load() without weights_only=True deserializes via pickle — RCE if the file is attacker-controlled. Add weights_only=True or switch to safetensors. severity: ERROR languages: [python] metadata: owasp: ["A08:2021-Software_and_Data_Integrity_Failures"] confidence: likely category: ml-security cve: "PYSEC-2026-139,CVE-2025-32434,CVE-2026-1839" ``` **Rule 3: load-dataset-user-input** ```yaml - id: load-dataset-user-input patterns: - pattern: load_dataset($X, ...) - pattern-not: load_dataset("...", ...) message: > load_dataset() with a variable name downloads and executes the dataset's Python loading script. If $X is user-controlled this is RCE without any file upload. Pin to a hardcoded dataset name. severity: ERROR languages: [python] metadata: owasp: ["A03:2021-Injection"] confidence: likely category: ml-security ``` **Rule 4: joblib-load-user-input** ```yaml - id: joblib-load-user-input patterns: - pattern: joblib.load($X) - pattern-not: joblib.load("...") message: > joblib.load() uses pickle internally. Loading a user-supplied file path executes arbitrary code. Validate the path against an allowlist. severity: ERROR languages: [python] metadata: owasp: ["A08:2021-Software_and_Data_Integrity_Failures"] confidence: likely category: ml-security ``` **Rule 5: numpy-allow-pickle** ```yaml - id: numpy-allow-pickle-user-input patterns: - pattern: np.load($X, ..., allow_pickle=True, ...) - pattern-not: np.load("...", ..., allow_pickle=True, ...) message: > numpy.load() with allow_pickle=True on a user-supplied path enables pickle RCE. Use allow_pickle=False (default since numpy 1.16.3) or validate the file source. severity: ERROR languages: [python] metadata: owasp: ["A08:2021-Software_and_Data_Integrity_Failures"] confidence: confirmed category: ml-security ``` **Rule 6: safetensors-metadata-eval** ```yaml - id: safetensors-metadata-eval patterns: - pattern: | with safe_open($F, ...) as $H: ... $X = $H.metadata() ... eval($X[...]) - pattern: | with safe_open($F, ...) as $H: ... $M = $H.metadata() ... importlib.import_module($M[...]) message: > safetensors metadata is attacker-controlled when files come from untrusted sources. eval() or importlib on metadata values enables code injection even in the "safe" safetensors format. severity: ERROR languages: [python] metadata: owasp: ["A03:2021-Injection"] confidence: confirmed category: ml-security ``` **Rule 7: keras-load-model-user-input** ```yaml - id: keras-load-model-user-input patterns: - pattern: keras.models.load_model($X) - pattern-not: keras.models.load_model("...") message: > keras.models.load_model() on a user-supplied path is RCE via custom Lambda layers (CVE-2025-1550). Use safe_mode=True (Keras 3+) or validate the source. severity: ERROR languages: [python] metadata: owasp: ["A08:2021-Software_and_Data_Integrity_Failures"] confidence: likely category: ml-security cve: "CVE-2025-1550" ``` --- ## TASK 04 — Semgrep Rule Pack: Gradio DoS / API Abuse **File:** `rules/ml_gradio_dos.yaml` **Registration:** Add to `ALL_SECURITY` in `rules/__init__.py` **Label:** `"Semgrep:ML-GradioDoS"` ### Rules to implement **Rule 1: gradio-unbounded-numeric-input** ```yaml - id: gradio-unbounded-numeric-input patterns: - pattern: gr.Number(label=$L, ...) - pattern-not: gr.Number(..., maximum=..., ...) - metavariable-regex: metavariable: $L regex: '(?i)(limit|count|size|num|top_k|batch|steps|iter|max|n_result)' message: > gr.Number('$L') has no maximum= bound. Sending limit=999999 via the /run/predict API (bypassing the UI slider) can exhaust memory or hammer downstream APIs. Add maximum= to the component definition. severity: WARNING languages: [python] metadata: owasp: ["A05:2021-Security_Misconfiguration"] confidence: likely category: ml-security ``` **Rule 2: gradio-slider-no-maximum** ```yaml - id: gradio-slider-no-maximum patterns: - pattern: gr.Slider(label=$L, ...) - pattern-not: gr.Slider(..., maximum=..., ...) - metavariable-regex: metavariable: $L regex: '(?i)(limit|count|size|num|token|step)' message: > gr.Slider('$L') without maximum= — API callers can exceed the visual slider range. severity: WARNING languages: [python] metadata: owasp: ["A05:2021-Security_Misconfiguration"] confidence: possible category: ml-security ``` **Rule 3: gradio-concurrency-one-blocks-all** ```yaml - id: gradio-concurrency-limit-one patterns: - pattern: $FN.click(..., concurrency_limit=1, ...) message: > concurrency_limit=1 means a single long-running request (e.g. max_tokens=99999) blocks all other users. Pair with input length validation. severity: WARNING languages: [python] metadata: owasp: ["A05:2021-Security_Misconfiguration"] confidence: possible category: ml-security ``` --- ## TASK 05 — Semgrep Rule Pack: Gradio SSRF + OAuth Token Leak **File:** `rules/ml_gradio_ssrf.yaml` **Registration:** Add to `ALL_SECURITY` **Label:** `"Semgrep:ML-GradioSSRF"` ### Rules to implement **Rule 1: gr-load-ssrf (CVE-2026-28416)** ```yaml - id: gr-load-ssrf patterns: - pattern: gr.load($X, ...) - pattern-not: gr.load("...", ...) message: > gr.load() with a variable Space name makes an HTTP request from HF infrastructure to the supplied URL (CVE-2026-28416). If $X is user-controlled this is SSRF. Use a hardcoded Space name or validate against an allowlist. severity: ERROR languages: [python] metadata: owasp: ["A10:2021-Server_Side_Request_Forgery"] confidence: likely category: ml-security cve: "CVE-2026-28416" ``` **Rule 2: gradio-mocked-oauth-token-leak (CVE-2026-27167)** ```yaml - id: gradio-mocked-oauth-token-leak patterns: - pattern: gr.LoginButton(...) message: > gr.LoginButton() outside HF Spaces enables a mocked OAuth route at /login/huggingface that returns the server owner's HF access token to any visitor (CVE-2026-27167, fixed in Gradio 6.6.0). Ensure gradio>=6.6.0 or remove gr.LoginButton if running outside HF Spaces. severity: ERROR languages: [python] metadata: owasp: ["A02:2021-Cryptographic_Failures"] confidence: likely category: ml-security cve: "CVE-2026-27167" ``` **Rule 3: gradio-no-revision-pin** ```yaml - id: from-pretrained-no-revision-pin patterns: - pattern: $M.from_pretrained($X, ...) - pattern-not: $M.from_pretrained($X, ..., revision=..., ...) message: > from_pretrained() without revision= always pulls HEAD. If the upstream repo is compromised and pushes new code, the next Space restart silently executes it (Bandit B615). Pin to a commit hash: revision="abc123". severity: WARNING languages: [python] metadata: owasp: ["A08:2021-Software_and_Data_Integrity_Failures"] confidence: possible category: ml-security ``` --- ## TASK 06 — Semgrep Rule Pack: MCP Security **File:** `rules/ml_mcp.yaml` **Registration:** Add to `ALL_LLM` in `rules/__init__.py` **Label:** `"Semgrep:ML-MCP"` ### Rules to implement **Rule 1: mcp-server-no-auth** ```yaml - id: mcp-server-no-auth patterns: - pattern: $D.launch(..., mcp_server=True, ...) - pattern-not: $D.launch(..., auth=..., ...) message: > mcp_server=True exposes ALL Gradio functions as MCP tools with no authentication. Any MCP client can invoke every tool without credentials. Add auth= or restrict the exposed functions explicitly. severity: WARNING languages: [python] metadata: owasp: ["A01:2021-Broken_Access_Control"] confidence: confirmed category: llm ``` **Rule 2: mcp-dynamic-docstring-injection** ```yaml - id: mcp-dynamic-docstring pattern: $F.__doc__ = $X message: > Dynamically assigned __doc__ on a function pollutes the MCP tool schema. If $X is user-influenced, attackers can inject instructions into the tool description read by LLM clients (prompt injection via tool metadata). severity: WARNING languages: [python] metadata: owasp: ["A03:2021-Injection"] confidence: possible category: llm ``` **Rule 3: mcp-tool-no-input-validation** ```yaml - id: mcp-tool-string-no-validation patterns: - pattern: | def $F($X: str, ...): ... $API($X, ...) - pattern-not: | def $F($X: str, ...): ... if ...: ... $API($X, ...) message: > MCP tool function '$F' accepts a string parameter and passes it directly to an API call without input validation. MCP clients (LLMs) can pass malicious values. Add validation before the API call. severity: WARNING languages: [python] metadata: owasp: ["A03:2021-Injection"] confidence: possible category: llm ``` --- ## TASK 07 — README Injection Scanner **File:** `scanners/readme_inject_runner.py` **Category:** `llm` | **Group flag:** `run_llm=True` **Binary required:** No — pure Python regex **Tool name:** `readme-inject` ### What it does Scans `README.md`, `README.rst`, `README.txt` for patterns that look like LLM prompt injection attempts. When an AI assistant summarizes a Space, it reads the README — injected instructions execute in the LLM's context. ### Function signature ```python def readme_inject(work: str) -> tuple[list[dict], str]: ``` ### Detection patterns (compile all as `re.IGNORECASE | re.DOTALL`) ```python PATTERNS = [ # Direct instruction injection (r"SYSTEM\s*:", "Direct system prompt injection attempt"), (r"IGNORE\s+(PREVIOUS|ALL|PRIOR)\s+INSTRUCTIONS?", "Classic jailbreak pattern"), (r"YOU\s+ARE\s+NOW\s+IN\s+\w+\s+MODE", "Mode-switching injection"), (r"\bOVERRIDE\b.{0,50}\bINSTRUCTIONS?\b", "Override injection"), # Template injection markers (r"<\|system\|>", "ChatML system token injection"), (r"\[INST\]\s*<>", "Llama instruction injection"), (r"<\|im_start\|>system", "OpenAI chat format injection"), (r"\{%.*?%\}", "Jinja2/template injection"), # Hidden in HTML comments (r"", "Instruction hidden in HTML comment"), # Data exfiltration patterns (r"fetch\s*\(\s*['\"]https?://(?!huggingface\.co)", "Exfil URL in README"), (r"navigator\.sendBeacon", "JS beacon exfil attempt"), # Encoding tricks (r"&#x[0-9a-fA-F]{2,4};.*SYSTEM", "HTML-encoded injection"), ] ``` ### Severity logic - HTML comment injection → `ERROR` (deliberate concealment) - Exfil URL → `ERROR` - Direct system patterns → `WARNING` - Template markers → `WARNING` ### Output ```python make_finding( tool="readme-inject", rule="README-PROMPT-INJECT", severity=severity, file=rel_readme_path, line=line_number, message=f"Possible LLM prompt injection in README: {description}. " "When AI assistants summarize this Space, injected instructions " "may execute in the LLM context.", owasp=["A03:2021-Injection"], category="llm", confidence="possible", # static only — cannot confirm intent ) ``` ### Registration Add to `ALL_LLM`. Tool name: `"readme-inject"`. Add to `_llm_tools` in services. --- ## TASK 08 — Gradio Version Vulnerability Scanner **File:** `scanners/gradio_version_runner.py` **Category:** `ml-security` | **Group flag:** `run_security=True` **Binary required:** No — parses requirements.txt **Tool name:** `gradio-version` ### What it does Parses `requirements.txt`, `setup.cfg`, `pyproject.toml` to extract the installed/pinned Gradio version. Maps version ranges to known CVEs. Emits one finding per affected CVE with exact version evidence. This complements pip-audit (which checks installed packages at scan time) by working on the static requirements file — useful for repos not yet installed. ### Known CVE version ranges (hardcode this) ```python GRADIO_CVES = [ { "cve": "CVE-2023-51449", "title": "Path traversal — arbitrary file read", "affected": "<4.11.0", "severity": "ERROR", "owasp": "A01:2021-Broken_Access_Control", "note": "Reads /proc/self/environ → leaks all Space secrets", }, { "cve": "CVE-2024-1561", "title": "Absolute path traversal via /file= endpoint", "affected": "<4.13.0", "severity": "ERROR", "owasp": "A01:2021-Broken_Access_Control", "note": "Arbitrary file read on the server", }, { "cve": "CVE-2026-27167", "title": "Mocked OAuth leaks server HF token via /login/huggingface", "affected": ">=4.16.0,<6.6.0", "severity": "ERROR", "owasp": "A02:2021-Cryptographic_Failures", "note": "Any visitor to /login/huggingface steals server HF token", }, { "cve": "CVE-2026-28416", "title": "SSRF via gr.load() malicious proxy_url", "affected": "<6.6.0", "severity": "ERROR", "owasp": "A10:2021-Server_Side_Request_Forgery", "note": "Attacker-controlled proxy_url reaches internal services", }, { "cve": "PYSEC-2024-274", "title": "Code injection via component_meta.py Jinja2 exec()", "affected": "<=4.36.1", "severity": "ERROR", "owasp": "A03:2021-Injection", "note": "User-controlled label/prop passed to Jinja2 exec()", }, ] ``` ### Use `packaging.version.Version` for comparison. Return a finding per CVE. --- ## TASK 09 — Live HTTP Prober Service **File:** `sentinel/services/prober.py` **Type:** New Sentinel service — separate from static scanners **Trigger:** Called after scan completes if `probe_live=True` ### What it does Probes a live HF Space URL for runtime vulnerabilities that cannot be detected from static analysis alone. ### Function signatures ```python async def probe_space(space_url: str, hf_token: str | None = None) -> list[dict]: """Run all live probes against a Space URL. Returns findings list.""" async def probe_file_endpoint(base_url: str) -> list[dict]: """CVE-2024-1561 / no-auth /file= endpoint check.""" async def probe_queue_leak(base_url: str, duration: int = 15) -> list[dict]: """/queue/status input data exposure check.""" async def probe_oauth_token_leak(base_url: str) -> list[dict]: """CVE-2026-27167 mocked OAuth check.""" async def probe_mcp_unauth(base_url: str) -> list[dict]: """MCP endpoint reachable without auth.""" ``` ### probe_file_endpoint logic ```python # Step 1: upload a canary file via /upload if available # Step 2: from a fresh session (no cookies), attempt to read it via /file= # Step 3: if readable → finding # Also try known paths: /file=/etc/passwd, /file=/proc/self/environ # Return timing data as evidence even if content is blocked ``` ### probe_oauth_token_leak logic ```python # GET {base_url}/login/huggingface # If response sets a session cookie AND status is 200/302: # - Decode the cookie (signed with hardcoded secret from "-v4") # - If HF token pattern found → CRITICAL finding # This is CVE-2026-27167 — fixed in 6.6.0 ``` ### probe_queue_leak logic ```python # Poll GET {base_url}/queue/status for `duration` seconds # For each job in response: # - Check if `input` or `data` field is present # - If any field contains /tmp/gradio path → try /file= fetch # - If any field contains PII patterns (email, token) → finding ``` ### probe_mcp_unauth logic ```python # GET {base_url}/mcp # If 200 and response contains tool definitions → finding # List exposed tool names in the finding message ``` ### Sentinel UI integration Add a **"Probe Live"** button to `sentinel/templates/scan.html` that appears only when the target has a `.hf.space` URL. POST to new route `/api/probe/{target_id}` which calls `probe_space()` and persists results. Do NOT integrate live probing into the main scan queue — keep it separate so users consciously trigger it after confirming H1 scope. --- ## TASK 10 — Transformers ReDoS Semgrep Rules **File:** `rules/ml_redos.yaml` **Registration:** Add to `ALL_SECURITY` **Label:** `"Semgrep:ML-ReDoS"` ### Background Multiple ReDoS CVEs in transformers 2025/2026 affect user-controlled regex inputs in AdamWeightDecay, EnglishNormalizer, and chat.py SETTING_RE. ### Rules to implement **Rule 1: user-controlled-regex** ```yaml - id: user-controlled-re-search patterns: - pattern: re.search($PATTERN, $INPUT, ...) - pattern-not: re.search("...", ...) message: > re.search() with a user-controlled pattern is vulnerable to ReDoS. Transformers has multiple CVEs (2025/2026) from this exact pattern. Validate regex patterns against a complexity limit before use, or compile with a timeout via the `regex` library (regex.search with timeout=). severity: WARNING languages: [python] metadata: owasp: ["A05:2021-Security_Misconfiguration"] confidence: possible category: security ``` **Rule 2: include_in_weight_decay pattern (specific transformers vector)** ```yaml - id: transformers-redos-weight-decay patterns: - pattern: | re.search($P, $N) where: - pattern-inside: | def _do_use_weight_decay($NAME, ...): ... message: > Pattern matches the AdamWeightDecay ReDoS vector from transformers. The include_in_weight_decay list accepts user regex that causes catastrophic backtracking. Fixed in transformers 4.53.0. severity: ERROR languages: [python] metadata: owasp: ["A05:2021-Security_Misconfiguration"] confidence: confirmed category: security ``` --- ## TASK 11 — Novel: Pickle via `__reduce__` Detector (Static) **File:** `rules/ml_pickle_gadget.yaml` **Registration:** Add to `ALL_SECURITY` **Label:** `"Semgrep:ML-PickleGadget"` ### What it does (not yet documented anywhere) Detects custom `__reduce__` implementations in code that also does `torch.save()` or `pickle.dump()`. A class with `__reduce__` that is serialized is a **pickle gadget** — if it reaches a model file, downstream loaders (including HF users) will execute it on load. ### Rules ```yaml - id: pickle-reduce-gadget patterns: - pattern: | class $C: ... def __reduce__(self): return ($FN, (...)) ... message: > Class '$C' defines __reduce__() — a pickle gadget. If instances of this class are serialized via torch.save() or pickle.dump() and distributed, anyone who loads the file will execute $FN. Verify this is intentional and the callable is safe. severity: WARNING languages: [python] metadata: owasp: ["A08:2021-Software_and_Data_Integrity_Failures"] confidence: possible category: ml-security ``` ```yaml - id: pickle-reduce-with-os-system patterns: - pattern: | class $C: ... def __reduce__(self): return (os.system, (...)) message: > Class '$C' defines __reduce__() returning os.system — this is a classic pickle RCE payload. If serialized to a model file this executes a shell command on every user who loads the model. severity: ERROR languages: [python] metadata: owasp: ["A08:2021-Software_and_Data_Integrity_Failures"] confidence: confirmed category: ml-security ``` --- ## TASK 12 — Novel: Chat Template Injection Detector (Static) **File:** `rules/ml_chat_template.yaml` **Registration:** Add to `ALL_LLM` **Label:** `"Semgrep:ML-ChatTemplate"` ### What it does (not documented as a scanner rule anywhere) Detects user-controlled strings being interpolated into chat templates before `apply_chat_template()`. If the template uses Jinja2 and the content is not escaped, an attacker can inject role boundaries, system messages, or special tokens. ### Rules ```yaml - id: chat-template-user-input-fstring patterns: - pattern: | $MSGS = [{"role": "user", "content": f"...{$X}..."}] ... $T.apply_chat_template($MSGS, ...) - pattern-not-inside: | $X = "..." message: > User input $X is interpolated into a chat message before apply_chat_template(). If $X contains role boundary tokens (<|im_end|>, [/INST], etc.) the chat template may parse them as structural markers, escaping the user role. Sanitize or escape special tokens before interpolation. severity: WARNING languages: [python] metadata: owasp: ["A03:2021-Injection"] confidence: possible category: llm ``` ```yaml - id: system-prompt-user-concat patterns: - pattern: | {"role": "system", "content": $X + $Y} - pattern-not: | {"role": "system", "content": "..." + "..."} message: > System prompt content built by string concatenation with a variable. If either operand is user-controlled, the attacker controls the system prompt. severity: ERROR languages: [python] metadata: owasp: ["A03:2021-Injection"] confidence: likely category: llm ``` --- ## TASK 13 — Remediation Entries **File:** `report/remediation.py` Add entries for all new rule IDs. Pattern: `"RULE-ID": "plain English fix"`. ```python # CVE-specific "CVE-2026-27167": "Upgrade gradio to >=6.6.0. The mocked OAuth route at /login/huggingface leaks the server's HF token in versions 4.16.0–6.5.x.", "CVE-2026-28416": "Upgrade gradio to >=6.6.0. Validate Space names against an allowlist before passing to gr.load().", "CVE-2026-1839": "Use torch.load(..., weights_only=True) and upgrade to torch>=2.6 and transformers>=5.0.0rc3.", "CVE-2025-32434": "Upgrade torch to >=2.6. weights_only=True alone is insufficient on earlier versions.", "CVE-2025-1550": "Use keras.models.load_model(..., safe_mode=True) (Keras 3+) or validate file source.", # Rule-specific "GRADIO-GLOBAL-STATE": "Move gr.State() inside the function that uses it, or use session-scoped storage.", "trust-remote-code-user-input": "Hardcode the model name or validate against an allowlist. Never pass user input to trust_remote_code=True.", "torch-load-missing-weights-only": "Add weights_only=True and upgrade to torch>=2.6. Prefer safetensors format.", "load-dataset-user-input": "Hardcode the dataset name. Never pass user input to load_dataset().", "joblib-load-user-input": "Validate the file path against an allowlist. Prefer safer serialization formats.", "numpy-allow-pickle-user-input": "Remove allow_pickle=True. Use allow_pickle=False (safe default since numpy 1.16.3).", "safetensors-metadata-eval": "Never eval() or import from safetensors metadata. Treat metadata as untrusted input.", "keras-load-model-user-input": "Use safe_mode=True or validate file origin before loading.", "gr-load-ssrf": "Hardcode the Space name. Never pass user input to gr.load().", "gradio-mocked-oauth-token-leak": "Upgrade gradio to >=6.6.0 or remove gr.LoginButton when running outside HF Spaces.", "from-pretrained-no-revision-pin": "Add revision='' to pin to a specific trusted commit.", "gradio-unbounded-numeric-input": "Add maximum= to gr.Number() and enforce server-side with min(user_val, MAX).", "mcp-server-no-auth": "Add auth=('user','pass') to demo.launch() or restrict MCP-exposed functions.", "mcp-dynamic-docstring": "Use a static string literal for function __doc__. Never build from user input.", "README-PROMPT-INJECT": "Remove instruction-like patterns from README. Use plain descriptive text only.", "pickle-reduce-gadget": "Audit __reduce__() usage. Ensure serialized objects cannot execute system calls.", "pickle-reduce-with-os-system": "Remove os.system from __reduce__(). This is a live pickle RCE payload.", "chat-template-user-input-fstring": "Escape special tokens before interpolation, or use the tokenizer's built-in sanitization.", "system-prompt-user-concat": "Never concatenate user input into the system role. Use a hardcoded system prompt only.", "user-controlled-re-search": "Validate regex complexity before use or use the `regex` library with timeout parameter.", ``` --- ## TASK 14 — Registration Checklist **After completing all tasks above, verify every item:** ``` [ ] scanners/__init__.py — exports: cve_trigger, gradio_state, gradio_version, readme_inject [ ] core/scanner.py — _TASK_TO_TOOL has all 4 new tool names [ ] core/scanner.py — scan_repo() task list includes all 4 [ ] rules/__init__.py — ALL_SECURITY includes Tasks 03,04,05,08,10,11 [ ] rules/__init__.py — ALL_LLM includes Tasks 06,12 [ ] sentinel/routes/discover.py — _ALLOWED_SCANNERS has all new names [ ] sentinel/services/scanner.py — _TOOL_NAMES, _sec_tools, _llm_tools updated [ ] sentinel/services/prober.py — created (Task 09) [ ] sentinel/routes/ — new /api/probe/{id} route added [ ] sentinel/templates/scan.html — "Probe Live" button added [ ] report/remediation.py — all new rule IDs added (Task 13) [ ] tests/test_cve_trigger.py — created [ ] tests/test_gradio_state.py — created [ ] tests/test_gradio_version.py — created [ ] tests/test_readme_inject.py — created ``` --- ## Testing conventions Follow `tests/test_radon_runner.py` as template. Every runner needs: 1. `test_not_installed_returns_empty` (or `test_no_requirements_file`) 2. `test_no_findings_on_clean_input` 3. `test_detects_known_bad_pattern` 4. `test_ignores_safe_variant` (e.g. weights_only=True) 5. `test_severity_escalation` where applicable 6. `test_correct_owasp_category` Run with: ```bash pytest tests/ -m "not slow and not hf" -q ```