Spaces:
Sleeping
Sleeping
Upload 19 files
Browse files- .env.example +28 -0
- DEMO_SCRIPT.md +30 -0
- Modelfile +2 -0
- README.md +68 -14
- SUBMISSION.md +67 -0
- backend/__init__.py +0 -0
- backend/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/__pycache__/agents.cpython-313.pyc +0 -0
- backend/__pycache__/cerebras_client.cpython-313.pyc +0 -0
- backend/__pycache__/main.cpython-313.pyc +0 -0
- backend/__pycache__/models.cpython-313.pyc +0 -0
- backend/agents.py +219 -0
- backend/batch_triage.py +101 -0
- backend/cerebras_client.py +242 -0
- backend/main.py +136 -0
- backend/models.py +99 -0
- frontend/index.html +423 -0
- requirements.txt +5 -0
- samples/sample_findings.json +39 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=>({'&':'&','<':'<','>':'>'}[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 |
+
}
|