autoscan / docs /tasks.md
Chris4K's picture
Upload 384 files
a2a5bfd verified
|
Raw
History Blame Contribute Delete
35.9 kB

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

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)

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

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

def gradio_state(work: str) -> tuple[list[dict], str]:

Detection logic

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

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

- 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

- 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

- 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

- 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

- 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

- 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

- 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

- 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

- 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

- 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)

- 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)

- 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

- 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

- 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

- 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

- 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

def readme_inject(work: str) -> tuple[list[dict], str]:

Detection patterns (compile all as re.IGNORECASE | re.DOTALL)

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

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)

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

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

# 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

# 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

# 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

# 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

- 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)

- 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

- 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
- 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

- 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
- 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".

# 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:

pytest tests/ -m "not slow and not hf" -q