| 1 | """ |
| 2 | Cyclops v2 - Pillar 2, the novelty brain. |
| 3 | |
| 4 | Pipeline on a target source tree: |
| 5 | 1. (cache) skip if this commit already analyzed for this phase. |
| 6 | 2. semgrep fast-pass -> candidate high-severity sinks (cheap, broad). |
| 7 | 3. joern dataflow -> which candidate sinks are REACHABLE from untrusted |
| 8 | sources (source->sink taint). Reachable path = a |
| 9 | HYPOTHESIS written to the graph. NOT a finding. |
| 10 | 4. hand hypotheses to the oracle (Pillar 3) for execution proof. |
| 11 | |
| 12 | A reachable dataflow path is a hypothesis. Only oracle.py can turn it into a |
| 13 | finding, and only memory.promote_to_finding (which requires a Proof) records it. |
| 14 | """ |
| 15 | import os, sys, json, subprocess, glob |
| 16 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "memory")) |
| 17 | from memory import Memory |
| 18 | |
| 19 | CYCLOPS_HOME = os.environ.get("CYCLOPS_HOME", "/opt/cyclops-v2") |
| 20 | VENV_PY = os.environ.get("CYCLOPS_PY", "/opt/cyclops-venv/bin/python") |
| 21 | SEMGREP = os.environ.get("CYCLOPS_SEMGREP", "/opt/cyclops-venv/bin/semgrep") |
| 22 | JOERN = os.environ.get("CYCLOPS_JOERN", "/opt/joern/joern-cli/joern") |
| 23 | QUERY_DIR = os.path.join(CYCLOPS_HOME, "brain", "joern-queries") |
| 24 | RULE_DIR = os.path.join(CYCLOPS_HOME, "brain", "semgrep-rules") |
| 25 | |
| 26 | QUERY_SINKCLASS = { |
| 27 | "c-memory-corruption.sc": ("memory-corruption", "RCE via Memory Corruption"), |
| 28 | "deserialization-rce.sc": ("deserialization", "RCE via Deserialization"), |
| 29 | "command-injection.sc": ("command-injection", "RCE via Command Injection"), |
| 30 | "path-traversal.sc": ("path-traversal", "Arbitrary File Read/Write"), |
| 31 | "ssrf.sc": ("ssrf", "SSRF chains to Critical"), |
| 32 | "template-injection.sc": ("template-injection","RCE via Template Injection"), |
| 33 | "js-electron-rce.sc": ("electron-rce", "One-Click/Zero-Click RCE"), |
| 34 | } |
| 35 | |
| 36 | |
| 37 | def semgrep_pass(src): |
| 38 | """Fast broad pass. Returns list of candidate sink dicts.""" |
| 39 | if not os.path.isdir(RULE_DIR) or not os.listdir(RULE_DIR): |
| 40 | return [] |
| 41 | try: |
| 42 | out = subprocess.run( |
| 43 | [SEMGREP, "--quiet", "--json", "--config", RULE_DIR, src], |
| 44 | capture_output=True, text=True, timeout=1200) |
| 45 | data = json.loads(out.stdout or "{}") |
| 46 | except Exception as e: |
| 47 | print(f"[brain] semgrep error: {e}") |
| 48 | return [] |
| 49 | cands = [] |
| 50 | for r in data.get("results", []): |
| 51 | cands.append({ |
| 52 | "file": r.get("path"), |
| 53 | "line": r.get("start", {}).get("line", 0), |
| 54 | "rule": r.get("check_id"), |
| 55 | "msg": r.get("extra", {}).get("message", ""), |
| 56 | }) |
| 57 | print(f"[brain] semgrep: {len(cands)} candidate sinks") |
| 58 | return cands |
| 59 | |
| 60 | |
| 61 | def joern_query(src, script, project): |
| 62 | """Run one joern dataflow script. Returns parsed FINDING lines.""" |
| 63 | path = os.path.join(QUERY_DIR, script) |
| 64 | if not os.path.exists(path): |
| 65 | return [] |
| 66 | try: |
| 67 | out = subprocess.run( |
| 68 | [JOERN, "--script", path, "--param", f"inputPath={src}", |
| 69 | "--param", f"projectName={project}"], |
| 70 | capture_output=True, text=True, timeout=3600) |
| 71 | except subprocess.TimeoutExpired: |
| 72 | print(f"[brain] joern timeout on {script}") |
| 73 | return [] |
| 74 | findings = [] |
| 75 | for ln in (out.stdout or "").splitlines(): |
| 76 | if ln.startswith("FINDING:"): |
| 77 | try: |
| 78 | body = ln[len("FINDING:"):].strip() |
| 79 | locpart, rest = body.split(" ", 1) |
| 80 | fp, _, lno = locpart.rpartition(":") |
| 81 | kv = {} |
| 82 | for tok in ("class", "source", "sink"): |
| 83 | if tok + "=" in rest: |
| 84 | seg = rest.split(tok + "=", 1)[1] |
| 85 | for nxt in (" class=", " source=", " sink="): |
| 86 | if nxt.strip(" =") != tok and nxt in seg: |
| 87 | seg = seg.split(nxt)[0] |
| 88 | kv[tok] = seg.strip() |
| 89 | findings.append({"file": fp, "line": int(lno) if lno.isdigit() else 0, |
| 90 | "cls": kv.get("class", ""), "source": kv.get("source", ""), |
| 91 | "sink": kv.get("sink", "")}) |
| 92 | except Exception: |
| 93 | continue |
| 94 | return findings |
| 95 | |
| 96 | |
| 97 | def analyze(repo, archetype, src, lang="", commit="", released_at=""): |
| 98 | m = Memory() |
| 99 | tid = m.upsert_target(repo, archetype, lang=lang, commit=commit, |
| 100 | released_at=released_at, status="ANALYZING") |
| 101 | if m.analysis_done(tid, "brain"): |
| 102 | print(f"[brain] {repo}@{commit} already analyzed (cache hit) - skipping") |
| 103 | return tid, [] |
| 104 | |
| 105 | semgrep_pass(src) |
| 106 | |
| 107 | hyps = [] |
| 108 | for script, (sink_class, impact) in QUERY_SINKCLASS.items(): |
| 109 | fs = joern_query(src, script, f"cyc_{archetype}") |
| 110 | for f in fs: |
| 111 | hid = m.add_hypothesis(tid, sink_class, f["file"], f["line"], |
| 112 | f["source"], f["sink"], "joern") |
| 113 | hyps.append({"hid": hid, "sink_class": sink_class, "impact": impact, |
| 114 | "file": f["file"], "line": f["line"], |
| 115 | "source": f["source"], "sink": f["sink"]}) |
| 116 | if fs: |
| 117 | print(f"[brain] {script}: {len(fs)} reachable source->sink paths") |
| 118 | m.mark_analysis_done(tid, "brain") |
| 119 | m.enqueue("oracle", {"target": tid, "repo": repo, "src": src, |
| 120 | "archetype": archetype, "hypotheses": hyps}) |
| 121 | print(f"[brain] {repo}: {len(hyps)} HYPOTHESES queued for oracle proof") |
| 122 | return tid, hyps |
| 123 | |
| 124 | |
| 125 | if __name__ == "__main__": |
| 126 | if len(sys.argv) < 4: |
| 127 | print("usage: brain.py <repo> <archetype> <src_path> [lang] [commit]") |
| 128 | sys.exit(1) |
| 129 | analyze(sys.argv[1], sys.argv[2], sys.argv[3], |
| 130 | lang=sys.argv[4] if len(sys.argv) > 4 else "", |
| 131 | commit=sys.argv[5] if len(sys.argv) > 5 else "") |