| # 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=<reasonable_cap> 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*<<SYS>>", "Llama instruction injection"), | |
| (r"<\|im_start\|>system", "OpenAI chat format injection"), | |
| (r"\{%.*?%\}", "Jinja2/template injection"), | |
| # Hidden in HTML comments | |
| (r"<!--.*?(SYSTEM|IGNORE|OVERRIDE|INJECT|assistant\s*:).*?-->", | |
| "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='<commit-sha>' to pin to a specific trusted commit.", | |
| "gradio-unbounded-numeric-input": "Add maximum=<N> 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 | |
| ``` |