Chris4K commited on
Commit
39a5a79
·
verified ·
1 Parent(s): 4525a9c

Upload 19 files

Browse files
.env.example ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- Cerebras (the star of the show) ---
2
+ CEREBRAS_API_KEY=
3
+ CEREBRAS_BASE_URL=https://api.cerebras.ai/v1
4
+ CEREBRAS_MODEL=gemma-4-31b
5
+
6
+ # --- Optional real baseline provider for an honest side-by-side ---
7
+ # Any OpenAI-compatible endpoint. The strongest demo is your OWN GPU:
8
+ # Ollama on the RTX 5090 -> the local-GPU-vs-Cerebras race.
9
+ #
10
+ # ollama serve # then: ollama pull gemma2 (or a gemma you have)
11
+ # BASELINE_BASE_URL=http://localhost:11434/api
12
+ # BASELINE_API_KEY=ollama
13
+ # BASELINE_MODEL=gemma2
14
+ # BASELINE_LABEL=RTX 5090 · Ollama
15
+ #
16
+ # If left blank, the "baseline" lane runs the SAME Cerebras model SEQUENTIALLY
17
+ # (a fair contrast that isolates Cerebras throughput + parallel fan-out).
18
+ BASELINE_BASE_URL=
19
+ BASELINE_API_KEY=
20
+ BASELINE_MODEL=
21
+ BASELINE_LABEL=
22
+
23
+ # --- Tuning ---
24
+ MAX_CONCURRENCY=10 # parallel analyst calls (stay under your RPM/TPM)
25
+ REMEDIATE_MIN_CVSS=7.0 # only auto-draft fixes for High/Critical (realistic prioritization)
26
+
27
+ # Force mock mode (no network) to rehearse the UI without burning quota:
28
+ USE_MOCK=0
DEMO_SCRIPT.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🎬 60-Second Demo Storyboard
2
+
3
+ Rules: ≤60s, show Cerebras speed (side-by-side recommended), focus on the project,
4
+ no secrets on screen (use USE_MOCK or a throwaway key; close notifications/tabs).
5
+
6
+ Record at the **batch=14** setting so the race has weight. Pre-load the sample.
7
+
8
+ | Time | On screen | Say (voiceover / captions) |
9
+ |---|---|---|
10
+ | 0:00–0:06 | The board full of raw findings (paste view scrolling) | "My scanners find thousands of issues. Finding them was never the problem — **triaging** them is." |
11
+ | 0:06–0:10 | Cursor hits **Run race ▶** | "So I built a Gemma-4 swarm on Cerebras. Watch both lanes." |
12
+ | 0:10–0:22 | The race: Cerebras chips stream + lane finishes ~1s; baseline still crawling | *(let the silence sell it)* "Same model, same work. Left runs parallel on Cerebras. Right runs it the normal way." |
13
+ | 0:22–0:27 | **Verdict** snaps in: `N× faster — 14 findings in 1.2s vs 8.4s` | "Fast enough to run on **every** scan, not once a week." |
14
+ | 0:27–0:38 | Scroll the triaged board: CVSS-sorted, exploitability, fp%, auto-fixes | "Four agents: an analyst scores real risk — not the scanner's label — a remediator drafts the fix, a commander writes the exec rollup." |
15
+ | 0:38–0:40 | Commander headline reads | "Three criticals, secret exposure first." |
16
+ | 0:40–0:53 | Click the secret-leak finding → drawer → drop a screenshot of the code → RCA returns | "And it's multimodal. Drop a screenshot of the offending code — Gemma-4 vision finds the root cause and the patch in **200 milliseconds**." |
17
+ | 0:53–1:00 | Title card: ⚡ FlashTriage · gemma-4-31b on Cerebras | "FlashTriage. Your security backlog, cleared at the speed of inference." |
18
+
19
+ ### Side-by-side tip
20
+ For the strongest contrast, run the baseline lane on **Ollama on your RTX 5090**
21
+ (`BASELINE_BASE_URL=http://localhost:11434/v1`, `BASELINE_MODEL=gemma2`,
22
+ `BASELINE_LABEL=RTX 5090 · Ollama`). The verdict then reads "vs Ns on RTX 5090 · Ollama" —
23
+ a real cloud-vs-local-GPU number computed live from `wall_ms`. No local GPU set up? The
24
+ baseline falls back to the same model run sequentially and the story still lands.
25
+
26
+ ### Capture
27
+ - Browser at 1280×800, zoom 110%, dark room.
28
+ - One take of the race; if the baseline is too slow for 60s, drop batch to 10 — the
29
+ ratio holds and the verdict still lands.
30
+ - Screen-record at 60fps so the streaming chips look crisp.
Modelfile ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ FROM gemma4:31b
2
+ PARAMETER num_ctx 4096
README.md CHANGED
@@ -1,14 +1,68 @@
1
- ---
2
- title: FlashTriage
3
- emoji: 🌍
4
- colorFrom: red
5
- colorTo: pink
6
- sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- short_description: Security Scanner Triage System
12
- ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ⚡ FlashTriage
2
+
3
+ **Scanner findings in. Triaged in seconds.** A multi-agent security-triage swarm on
4
+ **Gemma-4-31B + Cerebras**. Your 29 scanners already find the issues — FlashTriage
5
+ triages the firehose fast enough to run on *every* scan.
6
+
7
+ Built for the Cerebras × Google DeepMind Gemma 4 hackathon. Plays Track 1
8
+ (Multiverse Agents) and Track 3 (Enterprise Impact) with one build.
9
+
10
+ ## The thesis: speed is the product
11
+ Naive multi-agent triage = many sequential LLM calls = slow. FlashTriage fans the
12
+ analyst agents out concurrently and lets Cerebras' throughput collapse the wall-clock
13
+ to ~one call. The homepage is a **live race**: Cerebras (parallel) vs a sequential
14
+ baseline, findings streaming in real time, ending in an `N× faster` verdict.
15
+
16
+ ## The swarm (4 specialist roles, all Gemma-4 on Cerebras)
17
+ - **Analyst** — fan-out, per finding → CVSS, exploitability, false-positive likelihood (real risk, not the scanner's raw label).
18
+ - **Remediator** — conditional, per High/Critical → smallest correct fix + one-line patch hint.
19
+ - **Commander** — once, over the batch → executive rollup + prioritized actions.
20
+ - **Vision RCA** — multimodal, on demand → drop a screenshot of code/dashboard/alert → root cause + patch.
21
+
22
+ ## Run it
23
+ ```bash
24
+ cp .env.example .env # add CEREBRAS_API_KEY
25
+ pip install -r requirements.txt
26
+ uvicorn backend.main:app --port 8000
27
+ # open http://localhost:8000
28
+ ```
29
+ No key yet? Set `USE_MOCK=1` to rehearse the whole UI offline (simulated latency keeps
30
+ the race honest). Flip the key in for the real numbers.
31
+
32
+ ## Decoupled from your scanners
33
+ `backend/models.py` normalizes **SARIF** (Semgrep/CodeQL/Trivy/Bandit…), **generic JSON**
34
+ (custom scanners), or **raw text** into one Finding shape. Point any of your 29 tools at
35
+ it — paste output, upload a file, or wire its JSON straight in.
36
+
37
+ ## Honest baseline (your RTX 5090 vs Cerebras)
38
+ The strongest, most relatable side-by-side: run the baseline lane on **Ollama on your
39
+ own RTX 5090**. Set in `.env`:
40
+ ```
41
+ BASELINE_BASE_URL=http://localhost:11434/v1
42
+ BASELINE_API_KEY=ollama
43
+ BASELINE_MODEL=gemma2
44
+ BASELINE_LABEL=RTX 5090 · Ollama
45
+ ```
46
+ The contrast is real and brutal: FlashTriage fans the swarm out concurrently, so Cerebras
47
+ serves the whole batch in parallel while a single local GPU has to queue every call. The
48
+ verdict line names whichever baseline is live, so nothing is misrepresented. Leave the
49
+ baseline blank and it falls back to the same Cerebras model run sequentially (still fair —
50
+ it isolates throughput + parallel fan-out). The client is provider-aware: Cerebras gets
51
+ strict `json_schema` constrained decoding + `reasoning_effort`; Ollama gets `json_object`
52
+ and the Cerebras-only params are dropped so the request never chokes.
53
+
54
+ ## Backlog scale (bonus)
55
+ `python -m backend.batch_triage huge_scan.json` triages up to 50k findings via the Cerebras
56
+ **Batch API** (async, guaranteed < 24h). Real-time race for interactive work; Batch for the
57
+ overnight backlog. (Batch is Private Preview — needs to be enabled for your org.)
58
+
59
+ ## Layout
60
+ ```
61
+ backend/cerebras_client.py provider-aware OpenAI-compatible client (Cerebras + Ollama), timing, image, mock
62
+ backend/models.py scanner-output adapters (SARIF / JSON / text)
63
+ backend/agents.py the swarm + concurrent batch orchestration (the speed engine)
64
+ backend/main.py FastAPI: SSE triage stream + multimodal deep-dive
65
+ backend/batch_triage.py optional: Cerebras Batch API for 50k-finding backlogs
66
+ frontend/index.html the operator console (single file, vanilla JS, no build step)
67
+ samples/sample_findings.json realistic mixed-scanner batch for the demo
68
+ ```
SUBMISSION.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 📤 Submission Copy (paste into the Discord track channels)
2
+
3
+ Post a **separate** post per track. Attach the demo video. Add your @handle + repo.
4
+
5
+ ---
6
+
7
+ ## Track 1 — Multiverse Agents (#g4hackathon-multiverse-agents)
8
+
9
+ **Project Name:** FlashTriage
10
+
11
+ **Team Members:** @your_handle
12
+
13
+ **Project Description:**
14
+ FlashTriage is a multi-agent security-triage swarm built on Gemma-4-31B and Cerebras.
15
+ Four specialist agents — Analyst, Remediator, Commander, and a multimodal Vision-RCA —
16
+ turn raw output from any code/model scanner into risk-ranked, fix-ready findings. The
17
+ analysts fan out concurrently so Cerebras' throughput collapses an entire batch into
18
+ ~one call's wall-clock, and the Vision agent reads a screenshot of the offending code to
19
+ return root cause and a patch in milliseconds.
20
+
21
+ **GitHub:** https://github.com/your/flashtriage
22
+ **Demo Video:** (attached)
23
+
24
+ ---
25
+
26
+ ## Track 3 — Enterprise Impact (#g4hackathon-enterprise-impact)
27
+
28
+ **Project Name:** FlashTriage
29
+
30
+ **Team Members:** @your_handle
31
+
32
+ **Project Description:**
33
+ Enterprise scanners don't have a detection problem — they have a triage problem; hundreds
34
+ of findings per scan drown security teams. FlashTriage ingests SARIF/JSON from any of the
35
+ common scanners (Semgrep, Trivy, Bandit, ModelScan, Garak…) and a Gemma-4 swarm on Cerebras
36
+ triages the whole batch in seconds: real CVSS over scanner labels, exploitability, false-
37
+ positive likelihood, drafted remediations, and an incident-commander rollup. It runs inline
38
+ on every CI scan for interactive triage, and a Cerebras Batch-API mode clears 50k-finding
39
+ backlogs overnight — turning vulnerability management from a backlog into a real-time control.
40
+
41
+ **GitHub:** https://github.com/your/flashtriage
42
+ **Demo Video:** (attached)
43
+
44
+ ---
45
+
46
+ ## Track 2 — People's Choice (optional, #g4hackathon-people-choice)
47
+ Only chase this if you'll actually post and engage. Suggested X copy:
48
+
49
+ > Security scanners find 1000s of issues. Triaging them is the real bottleneck.
50
+ > So I built FlashTriage: a 4-agent swarm on @googlegemma Gemma-4 + @Cerebras that
51
+ > triages a whole scan in ~1s — CVSS, exploitability, fixes, even reads a screenshot
52
+ > of the code for root cause. Same work, sequential = 8s. Cerebras = 1s. 🧵👇
53
+ > #Gemma4 #Cerebras
54
+
55
+ Then a 2–4 reply thread: (1) the race clip, (2) the multimodal RCA clip, (3) "why this
56
+ matters: triage at the speed of inference = run it on every CI build", (4) repo link.
57
+
58
+ ---
59
+
60
+ ## Why this scores on the rubrics
61
+ - **Speed in action / AI differentiation:** the live race computes the ratio from real `wall_ms`; multi-agent is the *ideal* Cerebras showcase (many calls → parallel → instant). Baseline can be **your own RTX 5090 via Ollama** — a real, relatable cloud-vs-local-GPU contrast where the single local GPU has to queue what Cerebras serves in parallel.
62
+ - **Agent collaboration:** 4 distinct roles with real orchestration (fan-out analysts → conditional remediators → single commander rollup).
63
+ - **Multimodal intelligence:** Vision-RCA uses Gemma-4 image input on a genuine artifact (a code/dashboard screenshot), not a gimmick.
64
+ - **Business impact / production readiness:** named enterprise problem (vuln mgmt / IR), decoupled SARIF ingestion, strict structured outputs, real-time console *plus* Batch-API backlog mode, FastAPI service.
65
+
66
+ ## Name alternatives (if FlashTriage clashes)
67
+ BlinkTriage · TriageRace · SubSecond SOC · FluxTriage
backend/__init__.py ADDED
File without changes
backend/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (174 Bytes). View file
 
backend/__pycache__/agents.cpython-313.pyc ADDED
Binary file (12.4 kB). View file
 
backend/__pycache__/cerebras_client.cpython-313.pyc ADDED
Binary file (13.3 kB). View file
 
backend/__pycache__/main.cpython-313.pyc ADDED
Binary file (7.72 kB). View file
 
backend/__pycache__/models.cpython-313.pyc ADDED
Binary file (6.57 kB). View file
 
backend/agents.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The swarm. Four specialist roles built on Gemma-4-on-Cerebras:
3
+
4
+ Analyst (fan-out, per finding) -> category, CVSS, exploitability, FP-likelihood
5
+ Remediator (conditional, per High/Critical) -> fix + minimal patch hint
6
+ Commander (once, over the batch) -> exec rollup + prioritized actions
7
+ Vision RCA (multimodal, on demand) -> root-cause + patch from a screenshot
8
+
9
+ The speed thesis lives in `run_batch`: analysts fan out concurrently (bounded by a
10
+ semaphore) so the wall-clock is ~one slow call, not the sum of all calls. The
11
+ sequential baseline runs the identical work one-at-a-time — same model, honest delta.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import time
17
+ from typing import Any, AsyncIterator, Optional
18
+
19
+ import httpx
20
+
21
+ # ---- strict JSON schemas (constrain Gemma 4 to clean, parseable output) ----
22
+
23
+ ANALYST_SCHEMA = {
24
+ "type": "object", "additionalProperties": False,
25
+ "properties": {
26
+ "category": {"type": "string"},
27
+ "severity_cvss": {"type": "number"},
28
+ "severity_label": {"type": "string", "enum": ["critical", "high", "medium", "low", "info"]},
29
+ "exploitability": {"type": "string", "enum": ["trivial", "plausible", "theoretical", "none"]},
30
+ "false_positive_likelihood": {"type": "number"},
31
+ "rationale": {"type": "string"},
32
+ "affected_asset": {"type": "string"},
33
+ },
34
+ "required": ["category", "severity_cvss", "severity_label", "exploitability",
35
+ "false_positive_likelihood", "rationale", "affected_asset"],
36
+ }
37
+
38
+ REMEDIATOR_SCHEMA = {
39
+ "type": "object", "additionalProperties": False,
40
+ "properties": {
41
+ "fix_summary": {"type": "string"},
42
+ "patch_hint": {"type": "string"},
43
+ "effort": {"type": "string", "enum": ["S", "M", "L"]},
44
+ },
45
+ "required": ["fix_summary", "patch_hint", "effort"],
46
+ }
47
+
48
+ COMMANDER_SCHEMA = {
49
+ "type": "object", "additionalProperties": False,
50
+ "properties": {
51
+ "headline": {"type": "string"},
52
+ "risk_paragraph": {"type": "string"},
53
+ "top_actions": {"type": "array", "items": {"type": "string"}},
54
+ "counts": {
55
+ "type": "object", "additionalProperties": False,
56
+ "properties": {k: {"type": "integer"} for k in
57
+ ["critical", "high", "medium", "low", "info"]},
58
+ "required": ["critical", "high", "medium", "low", "info"],
59
+ },
60
+ },
61
+ "required": ["headline", "risk_paragraph", "top_actions", "counts"],
62
+ }
63
+
64
+ VISION_SCHEMA = {
65
+ "type": "object", "additionalProperties": False,
66
+ "properties": {
67
+ "root_cause": {"type": "string"},
68
+ "evidence_from_image": {"type": "string"},
69
+ "concrete_patch": {"type": "string"},
70
+ "confidence": {"type": "number"},
71
+ },
72
+ "required": ["root_cause", "evidence_from_image", "concrete_patch", "confidence"],
73
+ }
74
+
75
+ ANALYST_SYS = (
76
+ "You are a senior application-security analyst triaging one scanner finding. "
77
+ "Judge real risk, not the scanner's raw label. Estimate a CVSS-style 0-10 score, "
78
+ "whether it's actually exploitable, and how likely it's a false positive. "
79
+ "Be terse and specific. Output only the requested JSON."
80
+ )
81
+ REMEDIATOR_SYS = (
82
+ "You are a remediation engineer. Given a triaged finding, give the smallest correct "
83
+ "fix and a one-line patch hint a developer can act on immediately. Output only JSON."
84
+ )
85
+ COMMANDER_SYS = (
86
+ "You are the incident commander. Given the triaged batch, write a crisp executive "
87
+ "rollup: one headline, one risk paragraph, and the top prioritized actions. "
88
+ "Count findings by severity. Output only JSON."
89
+ )
90
+ VISION_SYS = (
91
+ "You are a security analyst doing root-cause analysis from a screenshot of code, a "
92
+ "dashboard, or an alert. Identify the actual root cause, cite what you see in the image, "
93
+ "and give a concrete patch. Output only JSON."
94
+ )
95
+
96
+
97
+ def _analyst_user(f: dict) -> str:
98
+ return (f"scanner={f.get('scanner','?')} rule={f.get('rule','?')} "
99
+ f"raw_severity={f.get('raw_severity','?')}\n"
100
+ f"path={f.get('path','')}\n"
101
+ f"message: {f.get('message','')}\n"
102
+ f"snippet: {f.get('snippet','')[:400]}")
103
+
104
+
105
+ class Swarm:
106
+ def __init__(self, client, *, max_concurrency: int = 10, remediate_min_cvss: float = 7.0):
107
+ self.client = client
108
+ self.sem = asyncio.Semaphore(max_concurrency)
109
+ self.remediate_min_cvss = remediate_min_cvss
110
+
111
+ # ---- single agents ----
112
+ async def analyst(self, http: httpx.AsyncClient, f: dict) -> dict:
113
+ r = await self.client.chat(http, system=ANALYST_SYS, user=_analyst_user(f),
114
+ json_schema=ANALYST_SCHEMA, max_tokens=350)
115
+ return {"finding": f, "analysis": r.json or {}, "timing": r.timing}
116
+
117
+ async def remediator(self, http: httpx.AsyncClient, f: dict, analysis: dict) -> Optional[dict]:
118
+ a = (f"finding: {f.get('message','')}\ncategory: {analysis.get('category')}\n"
119
+ f"cvss: {analysis.get('severity_cvss')}\nsnippet: {f.get('snippet','')[:300]}")
120
+ r = await self.client.chat(http, system=REMEDIATOR_SYS, user=a,
121
+ json_schema=REMEDIATOR_SCHEMA, max_tokens=250)
122
+ return r.json
123
+
124
+ async def vision_rca(self, http: httpx.AsyncClient, *, context: str,
125
+ image_b64: str, media_type: str) -> dict:
126
+ r = await self.client.chat(http, system=VISION_SYS, user=context,
127
+ image_b64=image_b64, media_type=media_type,
128
+ json_schema=VISION_SCHEMA, max_tokens=400)
129
+ return {"rca": r.json or {}, "timing": r.timing}
130
+
131
+ async def commander(self, http: httpx.AsyncClient, triaged: list[dict]) -> dict:
132
+ lines = []
133
+ for t in triaged[:60]:
134
+ a = t["analysis"]
135
+ lines.append(f"- [{a.get('severity_label','?')}/{a.get('severity_cvss','?')}] "
136
+ f"{a.get('category','?')}: {t['finding'].get('message','')[:90]}")
137
+ r = await self.client.chat(http, system=COMMANDER_SYS,
138
+ user="Triaged findings:\n" + "\n".join(lines),
139
+ json_schema=COMMANDER_SCHEMA, max_tokens=500)
140
+ return {"rollup": r.json or {}, "timing": r.timing}
141
+
142
+ # ---- one finding, full pipeline (analyst -> conditional remediator) ----
143
+ async def _one(self, http: httpx.AsyncClient, f: dict) -> dict:
144
+ res = await self.analyst(http, f)
145
+ a = res["analysis"]
146
+ if float(a.get("severity_cvss") or 0) >= self.remediate_min_cvss:
147
+ res["remediation"] = await self.remediator(http, f, a)
148
+ return res
149
+
150
+ # ---- batch: stream results as they complete; bounded concurrency ----
151
+ async def run_batch(self, findings: list[dict], *, parallel: bool) -> AsyncIterator[dict]:
152
+ t0 = time.perf_counter()
153
+ triaged: list[dict] = []
154
+ calls = 0
155
+ ttfts: list[float] = []
156
+ comp_tokens = 0
157
+
158
+ async with httpx.AsyncClient() as http:
159
+ async def guarded(f):
160
+ async with self.sem:
161
+ return await self._one(http, f)
162
+
163
+ if parallel:
164
+ tasks = [asyncio.create_task(guarded(f)) for f in findings]
165
+ for fut in asyncio.as_completed(tasks):
166
+ res = await fut
167
+ triaged.append(res)
168
+ calls += 1 + (1 if "remediation" in res else 0)
169
+ if res["timing"].ttft_ms:
170
+ ttfts.append(res["timing"].ttft_ms)
171
+ comp_tokens += res["timing"].completion_tokens
172
+ yield {"type": "finding", "data": _wire(res),
173
+ "elapsed_ms": (time.perf_counter() - t0) * 1000}
174
+ else:
175
+ for f in findings:
176
+ res = await self._one(http, f)
177
+ triaged.append(res)
178
+ calls += 1 + (1 if "remediation" in res else 0)
179
+ if res["timing"].ttft_ms:
180
+ ttfts.append(res["timing"].ttft_ms)
181
+ comp_tokens += res["timing"].completion_tokens
182
+ yield {"type": "finding", "data": _wire(res),
183
+ "elapsed_ms": (time.perf_counter() - t0) * 1000}
184
+
185
+ roll = await self.commander(http, triaged)
186
+ calls += 1
187
+ yield {"type": "rollup", "data": roll["rollup"],
188
+ "elapsed_ms": (time.perf_counter() - t0) * 1000}
189
+
190
+ wall = (time.perf_counter() - t0) * 1000
191
+ yield {"type": "summary", "data": {
192
+ "wall_ms": round(wall, 1),
193
+ "calls": calls,
194
+ "findings": len(findings),
195
+ "avg_ttft_ms": round(sum(ttfts) / len(ttfts), 1) if ttfts else None,
196
+ "completion_tokens": comp_tokens,
197
+ "throughput_tps": round(comp_tokens / (wall / 1000), 1) if wall else 0,
198
+ "mode": "parallel" if parallel else "sequential",
199
+ }}
200
+
201
+
202
+ def _wire(res: dict) -> dict:
203
+ """Strip non-serializable timing object down to what the UI needs."""
204
+ a = res["analysis"]
205
+ return {
206
+ "id": res["finding"]["id"],
207
+ "rule": res["finding"].get("rule", ""),
208
+ "scanner": res["finding"].get("scanner", ""),
209
+ "message": res["finding"].get("message", ""),
210
+ "path": res["finding"].get("path", ""),
211
+ "category": a.get("category", ""),
212
+ "cvss": a.get("severity_cvss", 0),
213
+ "label": a.get("severity_label", "info"),
214
+ "exploitability": a.get("exploitability", ""),
215
+ "fp": a.get("false_positive_likelihood", 0),
216
+ "rationale": a.get("rationale", ""),
217
+ "remediation": res.get("remediation"),
218
+ "ttft_ms": round(res["timing"].ttft_ms, 1) if res["timing"].ttft_ms else None,
219
+ }
backend/batch_triage.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Backlog-scale triage via the Cerebras Batch API (Private Preview).
3
+
4
+ The live console is for interactive, real-time triage. This is the other half of the
5
+ enterprise story: point it at a *huge* backlog (up to 50k findings) and let Cerebras
6
+ process it asynchronously, guaranteed within 24h. Same Analyst agent + strict schema.
7
+
8
+ python -m backend.batch_triage huge_scan.json
9
+
10
+ Notes:
11
+ - Batch is Private Preview; your model must be batch-enabled for your org.
12
+ - Minimum 10 requests per batch; this script auto-chunks at 50k.
13
+ - Results are NOT ordered — we match on custom_id.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ import time
21
+
22
+ import httpx
23
+ from dotenv import load_dotenv
24
+
25
+ from .agents import ANALYST_SCHEMA, ANALYST_SYS, _analyst_user
26
+ from .models import normalize
27
+
28
+ load_dotenv()
29
+ BASE = os.getenv("CEREBRAS_BASE_URL", "https://api.cerebras.ai/v1").rstrip("/")
30
+ MODEL = os.getenv("CEREBRAS_MODEL", "gemma-4-31b")
31
+
32
+
33
+ def _headers() -> dict:
34
+ key = os.environ.get("CEREBRAS_API_KEY")
35
+ if not key:
36
+ sys.exit("Set CEREBRAS_API_KEY to run a batch.")
37
+ return {"Authorization": f"Bearer {key}"}
38
+
39
+
40
+ def build_jsonl(findings: list[dict], path: str) -> None:
41
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
42
+ for i, fd in enumerate(findings):
43
+ req = {
44
+ "custom_id": fd["id"] or f"f-{i}",
45
+ "method": "POST",
46
+ "url": "/v1/chat/completions",
47
+ "body": {
48
+ "model": MODEL,
49
+ "max_completion_tokens": 350,
50
+ "messages": [
51
+ {"role": "system", "content": ANALYST_SYS},
52
+ {"role": "user", "content": _analyst_user(fd)},
53
+ ],
54
+ "response_format": {"type": "json_schema", "json_schema":
55
+ {"name": "out", "strict": True, "schema": ANALYST_SCHEMA}},
56
+ },
57
+ }
58
+ f.write(json.dumps(req) + "\n")
59
+
60
+
61
+ def main(src: str) -> None:
62
+ findings = normalize(open(src, encoding="utf-8").read())
63
+ if len(findings) < 10:
64
+ sys.exit("Batch needs >= 10 findings; use the live console for small scans.")
65
+ print(f"{len(findings)} findings -> batch")
66
+ build_jsonl(findings, "/tmp/batch_in.jsonl")
67
+
68
+ with httpx.Client(timeout=120) as c:
69
+ up = c.post(f"{BASE}/files", headers=_headers(),
70
+ files={"purpose": (None, "batch"),
71
+ "file": ("batch_in.jsonl", open("/tmp/batch_in.jsonl", "rb"))})
72
+ up.raise_for_status()
73
+ file_id = up.json()["id"]
74
+ print("uploaded:", file_id)
75
+
76
+ b = c.post(f"{BASE}/batches", headers={**_headers(), "Content-Type": "application/json"},
77
+ json={"input_file_id": file_id, "endpoint": "/v1/chat/completions",
78
+ "completion_window": "24h",
79
+ "metadata": {"app": "flashtriage"}})
80
+ b.raise_for_status()
81
+ bid = b.json()["id"]
82
+ print("batch:", bid)
83
+
84
+ while True:
85
+ st = c.get(f"{BASE}/batches/{bid}", headers=_headers()).json()
86
+ rc = st.get("request_counts", {})
87
+ print(f" {st['status']} {rc.get('completed',0)}/{rc.get('total',0)}")
88
+ if st["status"] in ("completed", "failed", "expired", "cancelled"):
89
+ break
90
+ time.sleep(10)
91
+
92
+ out_id = st.get("output_file_id")
93
+ if not out_id:
94
+ sys.exit(f"no output ({st['status']})")
95
+ res = c.get(f"{BASE}/files/{out_id}/content", headers=_headers())
96
+ open("batch_results.jsonl", "wb").write(res.content)
97
+ print("wrote batch_results.jsonl")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main(sys.argv[1] if len(sys.argv) > 1 else "samples/sample_findings.json")
backend/cerebras_client.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Thin async client for the Cerebras Inference API (OpenAI-compatible Chat Completions).
3
+
4
+ - Captures per-request timing from the `time_info` object the API returns.
5
+ - Supports multimodal image input via base64 data URIs (Gemma 4 vision).
6
+ - Supports strict structured output (json_schema) and reasoning_effort.
7
+ - Ships a MockClient so the whole app runs end-to-end with no key / no network
8
+ (rehearse the UI, then flip in your real key for the demo).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import os
15
+ import random
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Optional
19
+
20
+ import httpx
21
+
22
+
23
+ @dataclass
24
+ class CallTiming:
25
+ """Normalized timing for one model call — drives the on-screen telemetry."""
26
+ wall_ms: float = 0.0 # measured client-side wall clock
27
+ ttft_ms: Optional[float] = None # time-to-first-token, from server time_info if present
28
+ total_ms: Optional[float] = None # server-reported total, if present
29
+ prompt_tokens: int = 0
30
+ completion_tokens: int = 0
31
+
32
+ @property
33
+ def tps(self) -> float:
34
+ secs = (self.total_ms or self.wall_ms) / 1000.0
35
+ return (self.completion_tokens / secs) if secs > 0 else 0.0
36
+
37
+
38
+ @dataclass
39
+ class ChatResult:
40
+ text: str
41
+ json: Optional[dict] = None
42
+ timing: CallTiming = field(default_factory=CallTiming)
43
+ provider: str = "cerebras"
44
+
45
+
46
+ def _content(text: str, image_b64: Optional[str] = None, media_type: str = "image/png") -> Any:
47
+ """Build an OpenAI-style message content (text, optionally + one image)."""
48
+ if not image_b64:
49
+ return text
50
+ return [
51
+ {"type": "text", "text": text},
52
+ {"type": "image_url",
53
+ "image_url": {"url": f"data:{media_type};base64,{image_b64}"}},
54
+ ]
55
+
56
+
57
+ class CerebrasClient:
58
+ """OpenAI-compatible chat client. One class drives both Cerebras and any other
59
+ OpenAI-compatible endpoint (Ollama, vLLM, a Gemini compat shim, ...).
60
+
61
+ profile="cerebras" -> strict json_schema constrained decoding + reasoning_effort
62
+ profile="openai_compat" -> json_object + tolerant parsing, no Cerebras-only params
63
+ (Ollama on your RTX 5090 lands here)
64
+ """
65
+ def __init__(self, *, api_key: str, base_url: str, model: str,
66
+ provider: str = "cerebras", profile: str = "cerebras"):
67
+ self.model = model
68
+ self.provider = provider
69
+ self.profile = profile
70
+ self._url = base_url.rstrip("/") + ("/chat" if profile == "ollama_native" else "/chat/completions")
71
+ self._headers = {"Authorization": f"Bearer {api_key}",
72
+ "Content-Type": "application/json"}
73
+
74
+ def _build_body(self, *, system, user, image_b64, media_type,
75
+ json_schema, reasoning_effort, max_tokens, temperature) -> dict[str, Any]:
76
+ body: dict[str, Any] = {
77
+ "model": self.model,
78
+ "max_tokens": max_tokens,
79
+ "temperature": temperature,
80
+ "messages": [
81
+ {"role": "system", "content": system},
82
+ {"role": "user", "content": _content(user, image_b64, media_type)},
83
+ ],
84
+ }
85
+ if self.profile == "cerebras":
86
+ body["reasoning_effort"] = reasoning_effort # Cerebras-only
87
+ if json_schema is not None:
88
+ body["response_format"] = {
89
+ "type": "json_schema",
90
+ "json_schema": {"name": "out", "strict": True, "schema": json_schema},
91
+ }
92
+ else: # openai_compat / ollama: no reasoning_effort, no strict schema
93
+ if json_schema is not None:
94
+ body["response_format"] = {"type": "json_object"}
95
+ return body
96
+
97
+ async def chat(
98
+ self,
99
+ client: httpx.AsyncClient,
100
+ *,
101
+ system: str,
102
+ user: str,
103
+ image_b64: Optional[str] = None,
104
+ media_type: str = "image/png",
105
+ json_schema: Optional[dict] = None,
106
+ reasoning_effort: str = "none",
107
+ max_tokens: int = 700,
108
+ temperature: float = 0.2,
109
+ ) -> ChatResult:
110
+ body = self._build_body(system=system, user=user, image_b64=image_b64,
111
+ media_type=media_type, json_schema=json_schema,
112
+ reasoning_effort=reasoning_effort,
113
+ max_tokens=max_tokens, temperature=temperature)
114
+
115
+ if self.profile == "ollama_native":
116
+ body = {
117
+ "model": self.model,
118
+ "messages": body["messages"],
119
+ "stream": False,
120
+ "options": {
121
+ "temperature": temperature,
122
+ "num_predict": max_tokens,
123
+ },
124
+ }
125
+ if json_schema is not None:
126
+ body["format"] = "json"
127
+
128
+ t0 = time.perf_counter()
129
+ resp = await client.post(self._url, headers=self._headers, json=body, timeout=120)
130
+ wall_ms = (time.perf_counter() - t0) * 1000.0
131
+ resp.raise_for_status()
132
+ data = resp.json()
133
+
134
+ if self.profile == "ollama_native":
135
+ message = data.get("message") or {}
136
+ text = message.get("content") or ""
137
+ timing = CallTiming(
138
+ wall_ms=wall_ms,
139
+ ttft_ms=_ns_to_ms(data.get("prompt_eval_duration")),
140
+ total_ms=_ns_to_ms(data.get("total_duration")),
141
+ prompt_tokens=data.get("prompt_eval_count", 0) or 0,
142
+ completion_tokens=data.get("eval_count", 0) or 0,
143
+ )
144
+ else:
145
+ text = data["choices"][0]["message"]["content"] or ""
146
+ ti = data.get("time_info") or {}
147
+ usage = data.get("usage") or {}
148
+ timing = CallTiming(
149
+ wall_ms=wall_ms,
150
+ ttft_ms=_sec_to_ms(ti.get("queue_time")) if ti.get("queue_time") else None,
151
+ total_ms=_sec_to_ms(ti.get("total_time")) if ti.get("total_time") else None,
152
+ prompt_tokens=usage.get("prompt_tokens", 0),
153
+ completion_tokens=usage.get("completion_tokens", 0),
154
+ )
155
+ parsed = _safe_json(text) if json_schema is not None else None
156
+ return ChatResult(text=text, json=parsed, timing=timing, provider=self.provider)
157
+
158
+
159
+ class MockClient:
160
+ """Deterministic-ish stand-in so the app runs with no key/network. Adds a small
161
+ simulated latency so the parallel-vs-sequential race still tells the true story."""
162
+ def __init__(self, *, model: str = "gemma-4-31b(mock)", provider: str = "cerebras", per_call_ms: int = 220):
163
+ self.model = model
164
+ self.provider = provider
165
+ self._lat = per_call_ms / 1000.0
166
+
167
+ async def chat(self, client, *, system, user, image_b64=None, media_type="image/png",
168
+ json_schema=None, reasoning_effort="none", max_tokens=700, temperature=0.2) -> ChatResult:
169
+ await asyncio.sleep(self._lat * random.uniform(0.7, 1.3))
170
+ ct = random.randint(90, 240)
171
+ timing = CallTiming(wall_ms=self._lat * 1000, total_ms=self._lat * 1000,
172
+ ttft_ms=random.uniform(20, 60),
173
+ prompt_tokens=random.randint(200, 600), completion_tokens=ct)
174
+ payload = _mock_payload(system, user, image_b64)
175
+ return ChatResult(text=json.dumps(payload), json=payload, timing=timing, provider=self.provider)
176
+
177
+
178
+ # ---------- helpers ----------
179
+
180
+ def _sec_to_ms(v: Any) -> Optional[float]:
181
+ try:
182
+ return float(v) * 1000.0
183
+ except (TypeError, ValueError):
184
+ return None
185
+
186
+
187
+ def _ns_to_ms(v: Any) -> Optional[float]:
188
+ try:
189
+ return float(v) / 1_000_000.0
190
+ except (TypeError, ValueError):
191
+ return None
192
+
193
+
194
+ def _safe_json(text: str) -> Optional[dict]:
195
+ text = text.strip()
196
+ if text.startswith("```"):
197
+ text = text.strip("`")
198
+ text = text[text.find("{"):text.rfind("}") + 1]
199
+ try:
200
+ return json.loads(text)
201
+ except json.JSONDecodeError:
202
+ s, e = text.find("{"), text.rfind("}")
203
+ if s != -1 and e != -1:
204
+ try:
205
+ return json.loads(text[s:e + 1])
206
+ except json.JSONDecodeError:
207
+ return None
208
+ return None
209
+
210
+
211
+ def _mock_payload(system: str, user: str, image_b64: Optional[str]) -> dict:
212
+ s = (system + user).lower()
213
+ if "commander" in s or "rollup" in s:
214
+ return {"headline": "3 criticals dominate risk; secret exposure needs immediate rotation.",
215
+ "risk_paragraph": "A leaked provider token and an unauthenticated RCE are independently "
216
+ "sufficient for full compromise. Prioritize rotation and patching before "
217
+ "the SSRF and dependency CVEs.",
218
+ "top_actions": ["Rotate the exposed token and invalidate sessions",
219
+ "Patch the deserialization RCE in the upload handler",
220
+ "Add SSRF allow-list on the fetch proxy"],
221
+ "counts": {"critical": 3, "high": 4, "medium": 5, "low": 3, "info": 2}}
222
+ if "remediator" in s:
223
+ return {"fix_summary": "Move the secret to an env var and rotate; never pass secrets as Gradio values.",
224
+ "patch_hint": 'gr.Textbox(type="password") # no value=<secret>',
225
+ "effort": "S"}
226
+ if image_b64 or "vision" in s or "root cause" in s:
227
+ return {"root_cause": "Secret is hard-coded as the default value of a password field, so it renders "
228
+ "into client-side HTML.",
229
+ "evidence_from_image": "Visible `value=\"sk-...\"` on the gr.Textbox call in the screenshot.",
230
+ "concrete_patch": 'Remove value=...; load via os.environ["TOKEN"] server-side only.',
231
+ "confidence": 0.92}
232
+ # default: analyst
233
+ sev = random.choice([9.8, 9.1, 8.6, 7.5, 6.4, 5.3, 4.1, 3.2])
234
+ label = ("critical" if sev >= 9 else "high" if sev >= 7 else
235
+ "medium" if sev >= 4 else "low")
236
+ return {"category": random.choice(["Secret Exposure", "RCE", "SSRF", "Dependency CVE",
237
+ "Model Supply-Chain", "Prompt Injection"]),
238
+ "severity_cvss": sev, "severity_label": label,
239
+ "exploitability": random.choice(["trivial", "plausible", "theoretical"]),
240
+ "false_positive_likelihood": round(random.uniform(0.02, 0.35), 2),
241
+ "rationale": "Reachable from unauthenticated input; matches known exploit pattern.",
242
+ "affected_asset": "service/handler"}
backend/main.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FlashTriage — entry point.
3
+
4
+ Run:
5
+ cp .env.example .env # add your CEREBRAS_API_KEY
6
+ pip install -r requirements.txt
7
+ uvicorn backend.main:app --reload --port 8000
8
+ Then open http://localhost:8000
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+ from dotenv import load_dotenv
18
+ from fastapi import FastAPI, Request
19
+ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
20
+
21
+ from agents import Swarm
22
+ from cerebras_client import CerebrasClient, MockClient
23
+ from models import normalize
24
+
25
+ load_dotenv(Path(__file__).resolve().parent.parent / ".env")
26
+
27
+ ROOT = Path(__file__).resolve().parent.parent
28
+ app = FastAPI(title="FlashTriage")
29
+
30
+
31
+ def _cerebras():
32
+ key = os.getenv("CEREBRAS_API_KEY", "").strip()
33
+ if os.getenv("USE_MOCK", "0") == "1" or not key:
34
+ return MockClient(provider="cerebras"), "cerebras (mock)"
35
+ return CerebrasClient(
36
+ api_key=key,
37
+ base_url=os.getenv("CEREBRAS_BASE_URL", "https://api.cerebras.ai/v1"),
38
+ model=os.getenv("CEREBRAS_MODEL", "gemma-4-31b"),
39
+ provider="cerebras",
40
+ ), os.getenv("CEREBRAS_MODEL", "gemma-4-31b")
41
+
42
+
43
+ def _baseline():
44
+ """Real second provider if configured; else None (caller falls back to sequential).
45
+ Point this at Ollama on your RTX 5090 for a real local-GPU-vs-Cerebras race:
46
+ BASELINE_BASE_URL=http://localhost:11434/api BASELINE_API_KEY=ollama BASELINE_MODEL=gemma5090:latest
47
+ """
48
+ url = os.getenv("BASELINE_BASE_URL", "").strip()
49
+ key = os.getenv("BASELINE_API_KEY", "").strip()
50
+ model = os.getenv("BASELINE_MODEL", "").strip()
51
+ if url and model:
52
+ label = os.getenv("BASELINE_LABEL", "").strip() or model
53
+ return CerebrasClient(api_key=key or "none", base_url=url, model=model,
54
+ provider="baseline", profile="ollama_native"), label
55
+ return None, None
56
+
57
+
58
+ def _swarm(client):
59
+ return Swarm(
60
+ client,
61
+ max_concurrency=int(os.getenv("MAX_CONCURRENCY", "10")),
62
+ remediate_min_cvss=float(os.getenv("REMEDIATE_MIN_CVSS", "7.0")),
63
+ )
64
+
65
+
66
+ def _sse(obj: dict) -> str:
67
+ return f"data: {json.dumps(obj)}\n\n"
68
+
69
+
70
+ @app.get("/", response_class=HTMLResponse)
71
+ def index():
72
+ return (ROOT / "frontend" / "index.html").read_text(encoding="utf-8")
73
+
74
+
75
+ @app.get("/api/sample")
76
+ def sample():
77
+ return JSONResponse(json.loads((ROOT / "samples" / "sample_findings.json").read_text()))
78
+
79
+
80
+ @app.post("/api/triage")
81
+ async def triage(req: Request):
82
+ """
83
+ Body: { "payload": <scanner output: SARIF|JSON|text|list>,
84
+ "lane": "cerebras" | "baseline",
85
+ "limit": int }
86
+ Streams SSE: meta -> finding* -> rollup -> summary
87
+ """
88
+ body = await req.json()
89
+ findings = normalize(body.get("payload"))
90
+ limit = int(body.get("limit") or 0)
91
+ if limit > 0:
92
+ findings = findings[:limit]
93
+ lane = body.get("lane", "cerebras")
94
+
95
+ if lane == "baseline":
96
+ bclient, blabel = _baseline()
97
+ if bclient is not None: # real second provider, sequential
98
+ client, model_label, parallel = bclient, blabel, False
99
+ else: # honest fallback: same model, sequential
100
+ client, model_label, parallel = _cerebras()[0], _cerebras()[1] + " (sequential)", False
101
+ else:
102
+ client, model_label = _cerebras()
103
+ parallel = True
104
+
105
+ swarm = _swarm(client)
106
+
107
+ async def gen():
108
+ yield _sse({"type": "meta", "data": {
109
+ "lane": lane, "model": model_label,
110
+ "parallel": parallel, "count": len(findings)}})
111
+ async for ev in swarm.run_batch(findings, parallel=parallel):
112
+ yield _sse(ev)
113
+
114
+ return StreamingResponse(gen(), media_type="text/event-stream",
115
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
116
+
117
+
118
+ @app.post("/api/deepdive")
119
+ async def deepdive(req: Request):
120
+ """
121
+ Body: { "context": str, "image_base64": str (no data: prefix), "media_type": str }
122
+ Multimodal root-cause analysis on a single finding's screenshot.
123
+ """
124
+ body = await req.json()
125
+ client, model_label = _cerebras()
126
+ swarm = _swarm(client)
127
+ async with httpx.AsyncClient() as http:
128
+ out = await swarm.vision_rca(
129
+ http,
130
+ context=body.get("context", "Analyze this security finding."),
131
+ image_b64=body.get("image_base64", ""),
132
+ media_type=body.get("media_type", "image/png"),
133
+ )
134
+ return JSONResponse({"model": model_label, "rca": out["rca"],
135
+ "ttft_ms": out["timing"].ttft_ms,
136
+ "wall_ms": out["timing"].wall_ms})
backend/models.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Normalizes output from *any* scanner into a single Finding shape, so the triage
3
+ swarm is decoupled from your 29 tools. Adapters included:
4
+ - SARIF 2.1.0 (Semgrep, CodeQL, Trivy, Bandit, ...)
5
+ - Generic JSON list (best-effort field mapping — covers most custom scanners)
6
+ - Raw text (one finding per non-empty line)
7
+ Point any of your scanners at this and the rest just works.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from typing import Any
14
+
15
+
16
+ def _fid(*parts: str) -> str:
17
+ return hashlib.sha1("|".join(parts).encode()).hexdigest()[:10]
18
+
19
+
20
+ def finding(rule: str, message: str, *, path: str = "", scanner: str = "",
21
+ raw_severity: str = "", snippet: str = "") -> dict:
22
+ return {
23
+ "id": _fid(scanner, rule, path, message[:80]),
24
+ "rule": rule or "finding",
25
+ "message": message.strip(),
26
+ "path": path,
27
+ "scanner": scanner,
28
+ "raw_severity": raw_severity,
29
+ "snippet": snippet,
30
+ }
31
+
32
+
33
+ def from_sarif(doc: dict) -> list[dict]:
34
+ out: list[dict] = []
35
+ for run in doc.get("runs", []):
36
+ tool = (run.get("tool", {}).get("driver", {}) or {}).get("name", "sarif")
37
+ rules = {r.get("id"): r for r in
38
+ (run.get("tool", {}).get("driver", {}).get("rules", []) or [])}
39
+ for res in run.get("results", []):
40
+ rule_id = res.get("ruleId") or res.get("rule", {}).get("id") or "rule"
41
+ msg = (res.get("message", {}) or {}).get("text", "") or rule_id
42
+ level = res.get("level") or rules.get(rule_id, {}).get("defaultConfiguration", {}).get("level", "")
43
+ path, snippet = "", ""
44
+ locs = res.get("locations") or []
45
+ if locs:
46
+ phys = locs[0].get("physicalLocation", {})
47
+ path = (phys.get("artifactLocation", {}) or {}).get("uri", "")
48
+ snippet = (phys.get("region", {}) or {}).get("snippet", {}).get("text", "")
49
+ out.append(finding(rule_id, msg, path=path, scanner=tool,
50
+ raw_severity=level, snippet=snippet))
51
+ return out
52
+
53
+
54
+ def from_generic(items: list[dict]) -> list[dict]:
55
+ """Best-effort mapping for custom scanner JSON (handles common key names)."""
56
+ def pick(d: dict, *keys: str, default: str = "") -> str:
57
+ for k in keys:
58
+ if k in d and d[k] not in (None, ""):
59
+ return str(d[k])
60
+ return default
61
+ out = []
62
+ for it in items:
63
+ if not isinstance(it, dict):
64
+ out.append(finding("finding", str(it)))
65
+ continue
66
+ out.append(finding(
67
+ pick(it, "rule", "rule_id", "check", "id", "title", "name", default="finding"),
68
+ pick(it, "message", "description", "desc", "detail", "title", default=json.dumps(it)[:200]),
69
+ path=pick(it, "path", "file", "location", "uri", "target"),
70
+ scanner=pick(it, "scanner", "tool", "source", "engine"),
71
+ raw_severity=pick(it, "severity", "level", "risk", "confidence"),
72
+ snippet=pick(it, "snippet", "code", "evidence", "match"),
73
+ ))
74
+ return out
75
+
76
+
77
+ def from_text(text: str, scanner: str = "text") -> list[dict]:
78
+ return [finding("line", ln.strip(), scanner=scanner)
79
+ for ln in text.splitlines() if ln.strip()]
80
+
81
+
82
+ def normalize(payload: Any) -> list[dict]:
83
+ """Auto-detect format and return a list of normalized findings."""
84
+ if isinstance(payload, str):
85
+ s = payload.strip()
86
+ try:
87
+ payload = json.loads(s)
88
+ except json.JSONDecodeError:
89
+ return from_text(s)
90
+ if isinstance(payload, dict):
91
+ if "runs" in payload and "version" in payload: # SARIF
92
+ return from_sarif(payload)
93
+ for key in ("findings", "results", "issues", "vulnerabilities", "alerts"):
94
+ if isinstance(payload.get(key), list):
95
+ return from_generic(payload[key])
96
+ return from_generic([payload])
97
+ if isinstance(payload, list):
98
+ return from_generic(payload)
99
+ return []
frontend/index.html ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>FlashTriage — scanner findings in, triaged in seconds</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root{
12
+ --bg:#0A0E16; --panel:#111726; --panel2:#0D1320; --line:#1E2940;
13
+ --text:#E8EDF7; --muted:#7E8AA3; --faint:#475068;
14
+ --cyan:#2BE0FF; --amber:#FFB020; --baseline:#6B7793;
15
+ --crit:#FF4D5E; --high:#FF8A47; --med:#FFC53D; --low:#4FD17A; --info:#5AA9FF;
16
+ --disp:'Space Grotesk',system-ui,sans-serif;
17
+ --body:'Inter',system-ui,sans-serif;
18
+ --mono:'JetBrains Mono',ui-monospace,monospace;
19
+ }
20
+ *{box-sizing:border-box}
21
+ html,body{margin:0}
22
+ body{background:
23
+ radial-gradient(900px 500px at 85% -10%, rgba(43,224,255,.06), transparent 60%),
24
+ radial-gradient(700px 400px at -5% 110%, rgba(255,176,32,.05), transparent 55%),
25
+ var(--bg);
26
+ color:var(--text); font-family:var(--body); -webkit-font-smoothing:antialiased;
27
+ min-height:100vh;}
28
+ .wrap{max-width:1180px;margin:0 auto;padding:22px 22px 80px}
29
+
30
+ /* ---- top bar ---- */
31
+ header.bar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
32
+ padding-bottom:18px;border-bottom:1px solid var(--line)}
33
+ .logo{font-family:var(--disp);font-weight:700;font-size:22px;letter-spacing:-.02em}
34
+ .logo b{color:var(--cyan)}
35
+ .logo .flash{display:inline-block}
36
+ .tag{color:var(--muted);font-size:13.5px}
37
+ .spacer{flex:1}
38
+ .badge{font-family:var(--mono);font-size:11.5px;color:var(--text);
39
+ border:1px solid var(--line);background:var(--panel2);
40
+ padding:6px 10px;border-radius:999px;display:flex;gap:8px;align-items:center}
41
+ .dot{width:7px;height:7px;border-radius:50%;background:var(--cyan);
42
+ box-shadow:0 0 0 0 rgba(43,224,255,.6);animation:pulse 2s infinite}
43
+ @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(43,224,255,.5)}70%{box-shadow:0 0 0 7px rgba(43,224,255,0)}100%{box-shadow:0 0 0 0 rgba(43,224,255,0)}}
44
+
45
+ /* ---- controls ---- */
46
+ .controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:18px 0 6px}
47
+ .src{display:flex;gap:6px;background:var(--panel2);border:1px solid var(--line);
48
+ border-radius:10px;padding:4px}
49
+ .src button{font-family:var(--body);font-size:13px;color:var(--muted);background:none;
50
+ border:0;padding:7px 12px;border-radius:7px;cursor:pointer}
51
+ .src button.on{background:var(--panel);color:var(--text)}
52
+ .slide{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:12.5px;font-family:var(--mono)}
53
+ input[type=range]{accent-color:var(--cyan);width:120px}
54
+ .btn{font-family:var(--disp);font-weight:600;font-size:14px;border:0;border-radius:10px;
55
+ padding:11px 18px;cursor:pointer;letter-spacing:-.01em}
56
+ .btn.run{background:linear-gradient(180deg,var(--cyan),#15b9d6);color:#04222b;
57
+ box-shadow:0 6px 22px -8px rgba(43,224,255,.7)}
58
+ .btn.run:hover{filter:brightness(1.06)}
59
+ .btn.ghost{background:var(--panel);color:var(--text);border:1px solid var(--line)}
60
+ .btn:disabled{opacity:.45;cursor:not-allowed}
61
+ textarea#paste{display:none;width:100%;height:120px;margin-top:10px;background:var(--panel2);
62
+ color:var(--text);border:1px solid var(--line);border-radius:10px;padding:12px;
63
+ font-family:var(--mono);font-size:12px;resize:vertical}
64
+
65
+ /* ---- race ---- */
66
+ .race{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:18px}
67
+ @media(max-width:760px){.race{grid-template-columns:1fr}}
68
+ .lane{background:var(--panel);border:1px solid var(--line);border-radius:14px;
69
+ padding:14px 14px 12px;min-height:230px;position:relative;overflow:hidden}
70
+ .lane.cb{border-color:rgba(43,224,255,.35)}
71
+ .lane h3{margin:0;font-family:var(--disp);font-size:14px;display:flex;align-items:center;gap:8px}
72
+ .lane .pill{font-family:var(--mono);font-size:10px;padding:2px 7px;border-radius:999px;
73
+ border:1px solid var(--line);color:var(--muted)}
74
+ .lane.cb .pill{color:var(--cyan);border-color:rgba(43,224,255,.4)}
75
+ .clock{font-family:var(--mono);font-size:30px;font-weight:700;letter-spacing:-.02em;margin:8px 0 2px}
76
+ .lane.cb .clock{color:var(--cyan)}
77
+ .lane.bl .clock{color:var(--baseline)}
78
+ .sub{font-family:var(--mono);font-size:11px;color:var(--muted);margin-bottom:10px}
79
+ .track{height:5px;background:var(--panel2);border-radius:3px;overflow:hidden;margin-bottom:11px}
80
+ .track > i{display:block;height:100%;width:0;border-radius:3px;transition:width .25s ease}
81
+ .lane.cb .track > i{background:var(--cyan)}
82
+ .lane.bl .track > i{background:var(--baseline)}
83
+ .chips{display:flex;flex-wrap:wrap;gap:6px;align-content:flex-start}
84
+ .chip{font-family:var(--mono);font-size:10.5px;padding:3px 8px;border-radius:6px;
85
+ border:1px solid var(--line);color:var(--text);background:var(--panel2);
86
+ animation:pop .28s ease both}
87
+ @keyframes pop{from{opacity:0;transform:translateY(6px) scale(.96)}to{opacity:1;transform:none}}
88
+ .chip.crit{border-color:var(--crit);color:var(--crit)}
89
+ .chip.high{border-color:var(--high);color:var(--high)}
90
+ .chip.med{border-color:var(--med);color:var(--med)}
91
+ .chip.low{border-color:var(--low);color:var(--low)}
92
+ .chip.info{border-color:var(--info);color:var(--info)}
93
+
94
+ /* ---- verdict ---- */
95
+ .verdict{margin-top:14px;background:linear-gradient(90deg,rgba(43,224,255,.10),transparent);
96
+ border:1px solid rgba(43,224,255,.3);border-radius:14px;padding:16px 18px;
97
+ display:none;align-items:baseline;gap:14px;flex-wrap:wrap}
98
+ .verdict.show{display:flex;animation:pop .35s ease both}
99
+ .verdict .x{font-family:var(--disp);font-weight:700;font-size:34px;color:var(--cyan);letter-spacing:-.03em}
100
+ .verdict .vtxt{color:var(--text);font-size:14px}
101
+ .verdict .vtxt b{font-family:var(--mono)}
102
+
103
+ /* ---- commander ---- */
104
+ .cmd{display:none;margin-top:18px;background:var(--panel);border:1px solid var(--line);
105
+ border-radius:14px;padding:18px}
106
+ .cmd.show{display:block}
107
+ .cmd .eye{font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;color:var(--amber);text-transform:uppercase}
108
+ .cmd h2{font-family:var(--disp);font-size:19px;margin:6px 0 8px;letter-spacing:-.01em}
109
+ .cmd p{color:var(--muted);font-size:14px;line-height:1.55;margin:0 0 12px;max-width:70ch}
110
+ .counts{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}
111
+ .cnt{font-family:var(--mono);font-size:12px;padding:5px 10px;border-radius:8px;border:1px solid var(--line)}
112
+ .cnt b{font-weight:700}
113
+ .actions{list-style:none;margin:0;padding:0;display:grid;gap:7px}
114
+ .actions li{display:flex;gap:10px;align-items:baseline;font-size:13.5px}
115
+ .actions li::before{content:"→";color:var(--cyan)}
116
+
117
+ /* ---- results board ---- */
118
+ .board{margin-top:22px;display:none}
119
+ .board.show{display:block}
120
+ .board h2{font-family:var(--disp);font-size:15px;margin:0 0 4px}
121
+ .board .hint{color:var(--faint);font-size:12px;margin-bottom:12px}
122
+ .cards{display:grid;gap:9px}
123
+ .card{background:var(--panel);border:1px solid var(--line);border-left-width:3px;
124
+ border-radius:11px;padding:13px 14px;cursor:pointer;transition:border-color .15s, transform .1s}
125
+ .card:hover{transform:translateX(2px)}
126
+ .card.crit{border-left-color:var(--crit)} .card.high{border-left-color:var(--high)}
127
+ .card.med{border-left-color:var(--med)} .card.low{border-left-color:var(--low)}
128
+ .card.info{border-left-color:var(--info)}
129
+ .crow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
130
+ .cvss{font-family:var(--mono);font-weight:700;font-size:15px;min-width:38px}
131
+ .crit .cvss{color:var(--crit)} .high .cvss{color:var(--high)} .med .cvss{color:var(--med)}
132
+ .low .cvss{color:var(--low)} .info .cvss{color:var(--info)}
133
+ .cat{font-family:var(--disp);font-weight:600;font-size:14px}
134
+ .meta{font-family:var(--mono);font-size:10.5px;color:var(--muted)}
135
+ .exploit{font-family:var(--mono);font-size:10px;padding:2px 7px;border-radius:5px;border:1px solid var(--line);color:var(--muted)}
136
+ .exploit.trivial{color:var(--crit);border-color:var(--crit)}
137
+ .exploit.plausible{color:var(--high);border-color:var(--high)}
138
+ .rationale{color:var(--muted);font-size:12.5px;margin-top:7px;line-height:1.5}
139
+ .fix{margin-top:9px;font-size:12.5px;color:var(--text);background:var(--panel2);
140
+ border:1px solid var(--line);border-radius:8px;padding:9px 11px}
141
+ .fix code{font-family:var(--mono);color:var(--cyan);font-size:11.5px}
142
+ .fix .lbl{font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;color:var(--low);text-transform:uppercase;display:block;margin-bottom:4px}
143
+
144
+ /* ---- deep dive drawer ---- */
145
+ .drawer{position:fixed;top:0;right:0;height:100%;width:min(440px,92vw);
146
+ background:var(--panel2);border-left:1px solid var(--line);
147
+ transform:translateX(100%);transition:transform .25s ease;z-index:40;
148
+ padding:22px;overflow:auto}
149
+ .drawer.open{transform:none}
150
+ .drawer .close{position:absolute;top:14px;right:16px;background:none;border:0;color:var(--muted);font-size:22px;cursor:pointer}
151
+ .drawer .eye{font-family:var(--mono);font-size:10px;letter-spacing:.14em;color:var(--cyan);text-transform:uppercase}
152
+ .drawer h3{font-family:var(--disp);font-size:17px;margin:6px 0 14px}
153
+ .drop{border:1.5px dashed var(--line);border-radius:12px;padding:26px 16px;text-align:center;
154
+ color:var(--muted);font-size:13px;cursor:pointer;transition:border-color .15s,background .15s}
155
+ .drop:hover,.drop.over{border-color:var(--cyan);background:rgba(43,224,255,.05)}
156
+ .drop img{max-width:100%;border-radius:8px;margin-top:4px}
157
+ .rca{margin-top:16px;display:none}
158
+ .rca.show{display:block}
159
+ .rca .k{font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--amber);text-transform:uppercase;margin-top:14px}
160
+ .rca .v{font-size:13.5px;line-height:1.55;margin-top:4px}
161
+ .rca code{font-family:var(--mono);color:var(--cyan);font-size:12px}
162
+ .spin{display:inline-block;width:14px;height:14px;border:2px solid var(--line);
163
+ border-top-color:var(--cyan);border-radius:50%;animation:sp .7s linear infinite;vertical-align:-2px}
164
+ @keyframes sp{to{transform:rotate(360deg)}}
165
+ .overlay{position:fixed;inset:0;background:rgba(4,7,12,.55);z-index:35;display:none}
166
+ .overlay.show{display:block}
167
+
168
+ @media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
169
+ </style>
170
+ </head>
171
+ <body>
172
+ <div class="wrap">
173
+
174
+ <header class="bar">
175
+ <div class="logo"><span class="flash">⚡</span> Flash<b>Triage</b></div>
176
+ <div class="tag">Scanner findings in. Triaged in seconds.</div>
177
+ <div class="spacer"></div>
178
+ <div class="badge"><span class="dot"></span> <span id="modelBadge">gemma-4-31b · Cerebras</span></div>
179
+ </header>
180
+
181
+ <div class="controls">
182
+ <div class="src" id="src">
183
+ <button data-mode="sample" class="on">Load sample</button>
184
+ <button data-mode="paste">Paste output</button>
185
+ <button data-mode="upload">Upload file</button>
186
+ </div>
187
+ <div class="slide">batch
188
+ <input type="range" id="limit" min="3" max="14" value="8">
189
+ <span id="limitVal">8</span></div>
190
+ <button class="btn run" id="runRace">Run race ▶</button>
191
+ <button class="btn ghost" id="runCb">Cerebras only</button>
192
+ <input type="file" id="file" accept=".json,.sarif,.txt" style="display:none">
193
+ </div>
194
+ <textarea id="paste" placeholder="Paste SARIF, JSON, or raw scanner text here…"></textarea>
195
+
196
+ <section class="race">
197
+ <div class="lane cb" id="laneCb">
198
+ <h3>Cerebras <span class="pill" id="pillCb">gemma-4-31b · parallel</span></h3>
199
+ <div class="clock" id="clockCb">0.00s</div>
200
+ <div class="sub" id="subCb">idle</div>
201
+ <div class="track"><i id="trackCb"></i></div>
202
+ <div class="chips" id="chipsCb"></div>
203
+ </div>
204
+ <div class="lane bl" id="laneBl">
205
+ <h3>Baseline <span class="pill" id="pillBl">sequential</span></h3>
206
+ <div class="clock" id="clockBl">0.00s</div>
207
+ <div class="sub" id="subBl">idle</div>
208
+ <div class="track"><i id="trackBl"></i></div>
209
+ <div class="chips" id="chipsBl"></div>
210
+ </div>
211
+ </section>
212
+
213
+ <div class="verdict" id="verdict">
214
+ <span class="x" id="vx">—</span>
215
+ <span class="vtxt" id="vtxt"></span>
216
+ </div>
217
+
218
+ <section class="cmd" id="cmd">
219
+ <div class="eye">Incident commander · executive rollup</div>
220
+ <h2 id="cmdHead"></h2>
221
+ <p id="cmdRisk"></p>
222
+ <div class="counts" id="cmdCounts"></div>
223
+ <ul class="actions" id="cmdActions"></ul>
224
+ </section>
225
+
226
+ <section class="board" id="board">
227
+ <h2>Triaged findings</h2>
228
+ <div class="hint">Sorted by real risk, not scanner label. Click any finding for a multimodal deep-dive →</div>
229
+ <div class="cards" id="cards"></div>
230
+ </section>
231
+
232
+ </div>
233
+
234
+ <div class="overlay" id="overlay"></div>
235
+ <aside class="drawer" id="drawer">
236
+ <button class="close" id="dClose">×</button>
237
+ <div class="eye">Vision root-cause · Gemma-4 multimodal</div>
238
+ <h3 id="dTitle">Finding</h3>
239
+ <div class="drop" id="drop">Drop a screenshot of the code / dashboard / alert<br><small>or click to choose</small></div>
240
+ <input type="file" id="dFile" accept="image/*" style="display:none">
241
+ <div class="rca" id="rca"></div>
242
+ </aside>
243
+
244
+ <script>
245
+ const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
246
+ const SEV=['crit','high','med','low','info'];
247
+ const labelToClass=l=>({critical:'crit',high:'high',medium:'med',low:'low',info:'info'}[l]||'info');
248
+ let mode='sample', uploaded=null, lastResults=[], blLabel='baseline';
249
+
250
+ // ---- source toggle ----
251
+ $$('#src button').forEach(b=>b.onclick=()=>{
252
+ $$('#src button').forEach(x=>x.classList.remove('on')); b.classList.add('on');
253
+ mode=b.dataset.mode;
254
+ $('#paste').style.display = mode==='paste'?'block':'none';
255
+ if(mode==='upload') $('#file').click();
256
+ });
257
+ $('#file').onchange=e=>{const f=e.target.files[0]; if(!f)return;
258
+ const r=new FileReader(); r.onload=()=>{uploaded=r.result;}; r.readAsText(f);};
259
+ $('#limit').oninput=e=>$('#limitVal').textContent=e.target.value;
260
+
261
+ async function getPayload(){
262
+ if(mode==='paste') return $('#paste').value;
263
+ if(mode==='upload') return uploaded || '';
264
+ const r=await fetch('/api/sample'); return JSON.stringify(await r.json());
265
+ }
266
+
267
+ // ---- SSE-over-POST reader ----
268
+ async function* sse(url, body){
269
+ const res=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
270
+ const reader=res.body.getReader(), dec=new TextDecoder(); let buf='';
271
+ while(true){
272
+ const {value,done}=await reader.read(); if(done)break;
273
+ buf+=dec.decode(value,{stream:true}); let i;
274
+ while((i=buf.indexOf('\n\n'))>=0){
275
+ const line=buf.slice(0,i).trim(); buf=buf.slice(i+2);
276
+ if(line.startsWith('data:')){ try{ yield JSON.parse(line.slice(5).trim()); }catch{} }
277
+ }
278
+ }
279
+ }
280
+
281
+ // ---- one lane ----
282
+ function lane(which){
283
+ const cl=which==='cb', n=cl?'Cb':'Bl';
284
+ return {
285
+ clock:$('#clock'+n), sub:$('#sub'+n), track:$('#track'+n), chips:$('#chips'+n),
286
+ pill:$('#pill'+n), start:0, raf:0, total:0, count:0, done:false,
287
+ reset(){this.chips.innerHTML='';this.track.style.width='0';this.clock.textContent='0.00s';
288
+ this.sub.textContent='running…';this.done=false;this.count=0;this.start=performance.now();
289
+ const tick=()=>{ if(this.done)return; this.clock.textContent=((performance.now()-this.start)/1000).toFixed(2)+'s';
290
+ this.raf=requestAnimationFrame(tick);}; tick();},
291
+ chip(d){ const c=document.createElement('span');
292
+ c.className='chip '+labelToClass(d.label);
293
+ c.textContent=(d.category||d.rule||'finding')+' '+(d.cvss??'');
294
+ this.chips.appendChild(c); this.count++;
295
+ this.track.style.width=Math.min(100,(this.count/this.total)*100)+'%';
296
+ this.sub.textContent=this.count+' / '+this.total+' triaged';},
297
+ finish(ms){ this.done=true; cancelAnimationFrame(this.raf);
298
+ this.clock.textContent=(ms/1000).toFixed(2)+'s'; this.track.style.width='100%';
299
+ this.sub.textContent=this.count+' findings · '+(ms/1000).toFixed(2)+'s';}
300
+ };
301
+ }
302
+
303
+ async function drive(laneObj, laneName, limit, collect){
304
+ laneObj.reset();
305
+ const payload=await getPayload();
306
+ let summaryMs=0;
307
+ for await(const ev of sse('/api/triage',{payload,lane:laneName,limit})){
308
+ if(ev.type==='meta'){ laneObj.total=ev.data.count||limit;
309
+ if(laneName==='cerebras'){$('#pillCb').textContent=ev.data.model+' · parallel'; $('#modelBadge').textContent=ev.data.model+' · Cerebras';}
310
+ else {blLabel=ev.data.model; $('#pillBl').textContent=ev.data.model+' · sequential';}
311
+ }
312
+ else if(ev.type==='finding'){ laneObj.chip(ev.data); if(collect) lastResults.push(ev.data); }
313
+ else if(ev.type==='rollup'){ if(collect) renderCmd(ev.data); }
314
+ else if(ev.type==='summary'){ summaryMs=ev.data.wall_ms; laneObj.finish(summaryMs);
315
+ if(collect){ laneObj.tps=ev.data.throughput_tps; } }
316
+ }
317
+ return summaryMs;
318
+ }
319
+
320
+ // ---- run handlers ----
321
+ async function runRace(){
322
+ setBusy(true); resetOutputs();
323
+ const limit=+$('#limit').value;
324
+ const [cbMs,blMs]=await Promise.all([
325
+ drive(lane('cb'),'cerebras',limit,true),
326
+ drive(lane('bl'),'baseline',limit,false)
327
+ ]);
328
+ showVerdict(cbMs,blMs,limit);
329
+ renderBoard(); setBusy(false);
330
+ }
331
+ async function runCb(){
332
+ setBusy(true); resetOutputs();
333
+ const limit=+$('#limit').value;
334
+ const cbMs=await drive(lane('cb'),'cerebras',limit,true);
335
+ $('#subBl').textContent='—'; renderBoard(); setBusy(false);
336
+ }
337
+ $('#runRace').onclick=runRace; $('#runCb').onclick=runCb;
338
+
339
+ function setBusy(b){ $('#runRace').disabled=b; $('#runCb').disabled=b; }
340
+ function resetOutputs(){ lastResults=[]; $('#verdict').classList.remove('show');
341
+ $('#cmd').classList.remove('show'); $('#board').classList.remove('show'); $('#cards').innerHTML='';
342
+ $('#chipsBl').innerHTML=''; $('#trackBl').style.width='0'; $('#clockBl').textContent='0.00s'; $('#subBl').textContent='idle';}
343
+
344
+ function showVerdict(cb,bl,n){
345
+ const x=(bl/Math.max(cb,1)).toFixed(1);
346
+ $('#vx').textContent=x+'×';
347
+ $('#vtxt').innerHTML=`faster on Cerebras — <b>${n}</b> findings triaged in <b>${(cb/1000).toFixed(2)}s</b> vs <b>${(bl/1000).toFixed(2)}s</b> on ${esc(blLabel)}. Fast enough to run on every scan.`;
348
+ $('#verdict').classList.add('show');
349
+ }
350
+
351
+ function renderCmd(r){
352
+ if(!r||!r.headline)return;
353
+ $('#cmdHead').textContent=r.headline; $('#cmdRisk').textContent=r.risk_paragraph||'';
354
+ const cc=$('#cmdCounts'); cc.innerHTML='';
355
+ const map={critical:'crit',high:'high',medium:'med',low:'low',info:'info'};
356
+ Object.entries(r.counts||{}).forEach(([k,v])=>{
357
+ const e=document.createElement('span'); e.className='cnt';
358
+ e.style.color=`var(--${map[k]})`; e.innerHTML=`<b>${v}</b> ${k}`; cc.appendChild(e);});
359
+ const ul=$('#cmdActions'); ul.innerHTML='';
360
+ (r.top_actions||[]).forEach(a=>{const li=document.createElement('li');li.textContent=a;ul.appendChild(li);});
361
+ $('#cmd').classList.add('show');
362
+ }
363
+
364
+ function renderBoard(){
365
+ const sorted=[...lastResults].sort((a,b)=>(b.cvss||0)-(a.cvss||0));
366
+ const wrap=$('#cards'); wrap.innerHTML='';
367
+ sorted.forEach(d=>{
368
+ const cls=labelToClass(d.label);
369
+ const card=document.createElement('div'); card.className='card '+cls;
370
+ let fix='';
371
+ if(d.remediation){ fix=`<div class="fix"><span class="lbl">Fix · effort ${d.remediation.effort}</span>${esc(d.remediation.fix_summary)}<br><code>${esc(d.remediation.patch_hint)}</code></div>`; }
372
+ card.innerHTML=`<div class="crow">
373
+ <span class="cvss">${(d.cvss??0)}</span>
374
+ <span class="cat">${esc(d.category||d.rule)}</span>
375
+ <span class="exploit ${d.exploitability}">${d.exploitability}</span>
376
+ <span class="meta">${esc(d.scanner)} · ${esc(d.path||d.rule)} · fp ${Math.round((d.fp||0)*100)}%</span>
377
+ </div>
378
+ <div class="rationale">${esc(d.rationale||'')}</div>${fix}`;
379
+ card.onclick=()=>openDrawer(d);
380
+ wrap.appendChild(card);
381
+ });
382
+ $('#board').classList.add('show');
383
+ }
384
+ const esc=s=>(s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
385
+
386
+ // ---- deep-dive drawer (multimodal) ----
387
+ let current=null;
388
+ function openDrawer(d){ current=d; $('#dTitle').textContent=(d.category||d.rule)+' · CVSS '+(d.cvss??'');
389
+ $('#rca').classList.remove('show'); $('#rca').innerHTML='';
390
+ $('#drop').innerHTML='Drop a screenshot of the code / dashboard / alert<br><small>or click to choose</small>';
391
+ $('#drawer').classList.add('open'); $('#overlay').classList.add('show');}
392
+ function closeDrawer(){ $('#drawer').classList.remove('open'); $('#overlay').classList.remove('show'); }
393
+ $('#dClose').onclick=closeDrawer; $('#overlay').onclick=closeDrawer;
394
+ $('#drop').onclick=()=>$('#dFile').click();
395
+ ['dragover','dragenter'].forEach(e=>$('#drop').addEventListener(e,ev=>{ev.preventDefault();$('#drop').classList.add('over');}));
396
+ ['dragleave','drop'].forEach(e=>$('#drop').addEventListener(e,ev=>{ev.preventDefault();$('#drop').classList.remove('over');}));
397
+ $('#drop').addEventListener('drop',ev=>handleImg(ev.dataTransfer.files[0]));
398
+ $('#dFile').onchange=e=>handleImg(e.target.files[0]);
399
+
400
+ async function handleImg(f){
401
+ if(!f)return;
402
+ const r=new FileReader();
403
+ r.onload=async()=>{
404
+ const dataUrl=r.result, b64=dataUrl.split(',')[1];
405
+ $('#drop').innerHTML=`<img src="${dataUrl}" alt="evidence">`;
406
+ $('#rca').classList.add('show'); $('#rca').innerHTML='<div class="k">analyzing</div><div class="v"><span class="spin"></span> Gemma-4 vision is reading the image…</div>';
407
+ try{
408
+ const res=await fetch('/api/deepdive',{method:'POST',headers:{'Content-Type':'application/json'},
409
+ body:JSON.stringify({context:`Finding: ${current.category} (${current.message||current.rule}). ${current.rationale||''}`,
410
+ image_base64:b64, media_type:f.type||'image/png'})});
411
+ const j=await res.json(), x=j.rca||{};
412
+ $('#rca').innerHTML=
413
+ `<div class="k">root cause</div><div class="v">${esc(x.root_cause)}</div>
414
+ <div class="k">evidence in image</div><div class="v">${esc(x.evidence_from_image)}</div>
415
+ <div class="k">patch</div><div class="v"><code>${esc(x.concrete_patch)}</code></div>
416
+ <div class="k">confidence · latency</div><div class="v">${Math.round((x.confidence||0)*100)}% · ${(j.wall_ms||0).toFixed(0)}ms</div>`;
417
+ }catch(e){ $('#rca').innerHTML='<div class="v">Vision call failed — check the API key / network.</div>'; }
418
+ };
419
+ r.readAsDataURL(f);
420
+ }
421
+ </script>
422
+ </body>
423
+ </html>
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ httpx==0.28.1
4
+ python-dotenv==1.0.1
5
+ pydantic==2.10.4
samples/sample_findings.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "findings": [
3
+ {"scanner": "gradio-secrets", "rule": "GR-PWD-VALUE", "severity": "high",
4
+ "file": "app.py", "message": "gr.Textbox(type=\"password\", value=...) renders a live secret into client HTML",
5
+ "snippet": "token = gr.Textbox(type=\"password\", value=\"hf_xxxxREDACTEDxxxx\")"},
6
+ {"scanner": "semgrep", "rule": "python.lang.security.deserialization.pickle-load", "severity": "critical",
7
+ "file": "ingest/upload_handler.py", "message": "pickle.load on attacker-controlled upload -> unauthenticated RCE",
8
+ "snippet": "data = pickle.load(open(request.files['f'].name,'rb'))"},
9
+ {"scanner": "semgrep", "rule": "python.requests.ssrf", "severity": "high",
10
+ "file": "proxy/fetch.py", "message": "User-supplied URL passed to requests.get with no allow-list (SSRF)",
11
+ "snippet": "requests.get(request.args['url'])"},
12
+ {"scanner": "trivy", "rule": "CVE-2024-3094", "severity": "critical",
13
+ "file": "requirements.txt", "message": "Backdoored xz/liblzma transitive dependency present in lockfile"},
14
+ {"scanner": "trivy", "rule": "CVE-2023-50782", "severity": "high",
15
+ "file": "requirements.txt", "message": "cryptography < 42 vulnerable to Bleichenbacher timing oracle"},
16
+ {"scanner": "modelscan", "rule": "MS-PICKLE-EXEC", "severity": "critical",
17
+ "file": "weights/model.bin", "message": "Serialized model contains a reduce-based code execution payload"},
18
+ {"scanner": "garak", "rule": "promptinject.HijackHateHumans", "severity": "medium",
19
+ "file": "chat/system.py", "message": "System prompt susceptible to instruction-override jailbreak in 41% of probes"},
20
+ {"scanner": "bandit", "rule": "B105", "severity": "medium",
21
+ "file": "config/settings.py", "message": "Possible hardcoded password assigned to DB_PASS",
22
+ "snippet": "DB_PASS = \"changeme123\""},
23
+ {"scanner": "semgrep", "rule": "javascript.express.cors-misconfig", "severity": "medium",
24
+ "file": "server/cors.js", "message": "CORS reflects arbitrary Origin with credentials:true",
25
+ "snippet": "res.header('Access-Control-Allow-Origin', req.headers.origin)"},
26
+ {"scanner": "gitleaks", "rule": "aws-access-token", "severity": "high",
27
+ "file": "scripts/deploy.sh", "message": "AWS access key id committed to repository history"},
28
+ {"scanner": "trivy", "rule": "CVE-2022-40897", "severity": "low",
29
+ "file": "requirements.txt", "message": "setuptools ReDoS in package_index (low real-world impact)"},
30
+ {"scanner": "semgrep", "rule": "python.flask.debug-enabled", "severity": "medium",
31
+ "file": "wsgi.py", "message": "Flask app run with debug=True (Werkzeug console RCE if exposed)",
32
+ "snippet": "app.run(debug=True, host='0.0.0.0')"},
33
+ {"scanner": "bandit", "rule": "B608", "severity": "high",
34
+ "file": "db/query.py", "message": "Possible SQL injection via f-string query construction",
35
+ "snippet": "cur.execute(f\"SELECT * FROM u WHERE id={uid}\")"},
36
+ {"scanner": "garak", "rule": "leakreplay.LiteratureComplete", "severity": "low",
37
+ "file": "chat/system.py", "message": "Minor training-data echo on canary prompts; low confidence"}
38
+ ]
39
+ }