SANN is a research and forensics platform for cybersecurity exercises. You give it a red-team / blue-team session — screen recording, terminal casts, syslog, auth logs, Suricata alerts, Zeek flows, sensor packets, keylogger output, behavior-tracker events — and it does two things with it:
- Mission Control — an analysis-first dashboard that reads the whole session and shows you the attack story: kill-chain progression, detection efficacy (what the blue team caught vs missed), MITRE/TTP depth, per-subject profiles, alerts and network — all in one surface, with drill-down, live slicing, and a downloadable analysis bundle.
- Threat View — a forensic HUD that puts a video, a terminal cast, and six event panels on one synchronized cursor. Scrub and everything moves together.
It runs on a laptop. SQLite for storage, FastAPI for the backend, plain HTML/JS for the UI (no build step, no CDN dependencies in Mission Control — air-gap friendly).
- What it does
- Data model — one dataset = one participant
- The analysis engine
- How it works
- Tech stack
- Install
- Running it
- Loading data
- Mission Control
- Threat View
- Curation — reclassifying data
- Exporting analysis
- Multiple projects
- Dataset layout
- Configuration
- API reference
- Testing
- Security notes
- Troubleshooting
- Project structure
A red-team or CTF session produces a pile of artifacts: a screen recording of the attacker's workstation, asciinema casts of every terminal, kernel-level syslog from each victim host, auth logs, Suricata IDS alerts, Zeek connection logs, raw packet captures, a keylogger TSV, behavior-tracker events, and so on. Reviewing that by hand means jumping between a video player, a SIEM, and a stack of text files while lining up timestamps in your head.
SANN parses every artifact into one SQLite schema, classifies it under MITRE ATT&CK, and then analyses it — not just displays it. A single analysis engine turns the raw events into a structured picture (data quality, kill-chain progression, attack narrative, detection efficacy, technique depth, host roles, per-subject profiles, sessions, alerts, network) that feeds both the dashboard and the downloadable export. What you see on screen is exactly what you download.
Understanding the data is the point, so the model matters:
- A dataset (e.g.
P032,P003,P017) is one participant's study — one red-teamer, captured across several exercise runs. Each dataset becomes one project with its own isolated database. The cohort code (P032) is derived from the dataset path; it's the real subject identity (the project name is just a label you pick). - A scenario (
training,ckc1,ckc2,ckc2c, …) is one exercise run. Each run has a subject identity — theuserNNNNwho holds the attacker box and the keystrokes for that run. So withinP032the idsuser0032(training),user2016(ckc1),user3017(ckc2c) are the subject's per-scenario identities, not three unrelated people. Mission Control resolves and presents this for you (the "Subject & Scenario Runs" panel). - Within a run, hosts have roles — attacker (the kali box), pivot (compromised jumpboxes), sensor (zeek/suricata/pcap), defense (blue-team infra), target. The engine classifies these so the red-vs-blue picture (what the attacker did vs where they were detected) is legible.
analysis/ is a pure Python package (no web imports) that is the single source of truth for
every derived metric. It takes a scoped SQLite connection plus an optional filter and returns a
digest document. The dashboard renders it; the export downloads it; they never disagree.
Sections of the digest:
| Section | What it answers |
|---|---|
data_quality |
Completeness score, missing timestamps, unknown-phase %, sources present/absent — how much to trust this dataset |
identities |
Scenario → subject identity resolution (+ other actors) |
scenarios |
Per-scenario event/participant/phase breakdown |
kill_chain |
14 MITRE tactics in canonical order, volume + first/last seen + coverage |
narrative |
Ordered attack milestones per scenario (the story) |
detection_efficacy |
Per-phase attacker activity vs alerts, time-to-detect, blind spots |
ttp |
Technique frequency + dwell, coverage matrix, attack tempo/transitions |
host_roles |
attacker / pivot / sensor / defense / target + per-subject attacker vs detected-on hosts |
participants |
Per-identity profile: events, sessions, commands, tools, alerts, off-hours %, anomaly flags |
sessions |
Activity sessions reconstructed from idle gaps |
alerts, network, commands, alert_correlation, activity_timeline |
Rollups, top talkers/ports, command intelligence (attacker vs system), ±60s alert correlation, time histogram |
Heuristic outputs (suspicious commands, anomaly flags, host roles) are labelled advisory — a research aid, not ground truth.
There are three moving pieces.
A single Python script that walks a dataset directory and turns raw files into rows in SQLite. Each supported format has its own parser:
| File | Parser | What it gives you |
|---|---|---|
*.cast |
parse_cast_file |
Terminal output + extracted commands + cwd from prompts |
recording.{ogv,webm} |
parse_video_file (metadata only) |
Start time, duration |
auth.log |
parse_auth_log |
SSH / sudo / PAM events |
syslog |
parse_syslog |
Kernel and systemd events |
eve.json |
parse_suricata_eve |
IDS alerts, HTTP, DNS, flows |
conn.log |
parse_zeek_conn |
TCP/UDP connection records |
bt.jsonl |
parse_bt_jsonl |
Honeytrap behavior events |
sensor*.log |
parse_sensor_log |
Raw sensor_packet records |
UAT-*.tsv |
parse_uat_log |
Keylogger / typed text |
hacktools.log |
parse_hacktools |
Tool invocations |
apt.log |
parse_apt_log |
Repo activity |
All events land in the same events table — 35 columns covering timestamp, source, host, user, command, src/dst IP and port, action category, MITRE tactic/technique, alert info, HTTP fields, etc. Anything the parser can't fit into a column gets dumped into raw_data as JSON so you can recover it later.
A few details that took some iteration to get right:
- Suricata timezones. Suricata writes ISO timestamps with an explicit offset (
2025-08-15T11:25:23.826914-0400). The parser normalizes the compact offset, parses with the offset preserved, converts to UTC, then stores naive UTC. Earlier versions stripped the offset and stored local time, which made suricata events appear several hours before the video window for any EDT host. sensor_packetlines. These look like JSON but use single quotes ({'time': ..., 'data': {...}}). They are Python dict literals, not JSON. The parser triesast.literal_evalfirst and falls back to a JSON-with-quote-swap heuristic. Using only the swap heuristic corrupts payloads containing apostrophes.- Syslog years. The classic syslog format (
Aug 15 19:07:17 host kernel: ...) has no year. The ingester guesses from file mtime, and a post-processing pass cross-checks against the cast header epoch (which carries the real year) and shifts the rows if the guess was wrong. - C2 attribution.
sensor_packetevents whose src or dst IP matches the project's configured attacker IPs get reclassified ascommand_and_control. The attacker IP set is project-scoped.
MITRE classification uses two stages. First, a dict lookup on action_name handles the cases the source itself labels (e.g. sudo_execution → privilege_escalation). Then a list of (regex, phase, tactic, technique) rules runs against the command text — nmap → reconnaissance, crontab → persistence, sudo → privilege_escalation, and so on. First match wins. Anything that doesn't match falls to unknown.
When the ingester is invoked with --main-db and --project-id, it dual-writes every event: once into the project's own database, once into the combined corpus with project_id stamped on the row. That means you can query a single project in isolation, or run cross-project queries against the corpus, without picking up front.
Runs after ingest and fixes things in place. All steps are idempotent — safe to re-run with --skip-ingest.
dedupe_orig_participants— drops events and media rows from*.origparticipant folders, which are re-runs of the same session.fix_recon_taxonomy— rewrites the legacyreconlabel toreconnaissance(MITRE TA0043).fix_suricata_timestamps— for each participant, detects the offset from the firsteve.jsonand SQL-updates all stored suricata timestamps. This catches DBs ingested before the parser fix.rebuild_media_registry— clears and rebuilds the table of video / cast files by scanning disk. Cast duration comes from the last frame's offset; video duration fromffprobe.fix_syslog_years— same idea as fixing suricata, but for the inferred-year problem in syslog/auth.validate— for each participant with a video, logs whether the panels have events inside the video window and whether the first terminal event lands near video start. Validation is advisory: it reports data-quality findings but never fails the import. A dataset with no video, sparse panels, or no data at all (an empty/skeleton archive) ends asemptywith a precise reason, noterror— the project's status comes from the real event count.
A pure package (see The analysis engine) that computes the digest from a
scoped connection. build_digest() returns the full document; build_bundle() packages it as a
downloadable .zip. Both accept an optional SQL filter, so the same engine serves a whole
project or any slice (one scenario, one subject, one phase) without duplicated logic.
4. API + frontend — api/main.py, frontend/index.html (dashboard), frontend/palantir.html (Threat View)
The API is FastAPI. Query helpers route by scope: q() hits the main corpus, qp(project_id, …)
routes to the project's own DB (404 on an unknown id — a typo never silently leaks combined data).
Analysis endpoints (/digest, /export/bundle, /explore) build a scoped connection and hand it
to the engine, composing optional sub-scope filters (scenario, participant, phase, host,
tool, technique) via a column allowlist with parameterized values — injection-safe.
Two frontends, both single HTML files, no bundler/framework/node_modules:
index.html→/dashboard— Mission Control. Renders the digest in one fetch as inline SVG (no CDN, air-gap safe). Root/redirects here.palantir.html→/threat— the synchronized forensic HUD. A master cursor (S.cursorEpoch) drives a video, a terminal cast, and six event panels; a 250ms tick keeps them aligned; panels refetch when the cursor moves >5s. OGV/Theora is transcoded to MP4 on a background thread (cached, real progress reported); in-flight transcodes are reaped on server shutdown so ffmpeg is never orphaned.
- Python 3.11+
- FastAPI + uvicorn
- SQLite
- ffmpeg / ffprobe (system binaries)
- Vanilla HTML / CSS / JS — Mission Control is fully self-contained (inline SVG, no CDN, air-gap friendly); Threat View loads the asciinema player from a CDN for terminal replay
Pinned Python deps in requirements.txt. No JS package manager.
git clone https://github.com/vidzza/SANN.git
cd SANN
pip install -r requirements.txt
sudo apt install ffmpegOn macOS:
brew install ffmpegOn Windows / WSL, the same apt install works inside WSL. Native Windows installs would need an ffmpeg binary on PATH.
Copy the env template:
cp .env.example .envYou can leave the defaults if you plan to upload data through the UI. If you have a dataset on disk you want to ingest from a local path, edit SANN_DATA_ROOT to point at it.
python3 -m uvicorn api.main:app --host 0.0.0.0 --port 8000Open http://localhost:8000/ — it redirects to Mission Control (/dashboard). The Threat
View HUD lives at /threat. Everything loads against an empty database on a fresh install —
endpoints that need the events table return empty results rather than 500'ing, so you can
confirm the server is up before you have any data.
To run on a different port, change --port. To bind only locally, use --host 127.0.0.1.
Three ways. Pick whichever fits your workflow.
This is the easiest path and the one I'd hand to someone testing the platform for the first time.
- Pack your dataset folder into a
.zip,.tar,.tar.gz(or.tgz/.tar.bz2). The archive root should contain one or moreuser<id>/...subdirectories matching the Dataset layout section below. - Open http://localhost:8000/threat and click ⊕ IMPORT in the top bar.
- Fill in the project name (e.g.
P032), optionally the attacker IPs (comma-separated), pick the archive, hit Import. - The dialog polls every 3s. The status line walks through
Uploading…→Ingesting… <N> events so far→Done — <N> events. Time depends on size; a typical session takes 30 seconds to a few minutes. - The new project appears in the PROJECT dropdown and you can start scrubbing.
Behind the scenes the API extracts the archive into data/uploads/dataset_<id>/, runs the full
pipeline against it in a background thread (ingest plus all the post-processing steps), then syncs
media so the video and casts show up. A pipeline crash marks the project error; an archive with
no data files ends empty with a precise reason (see Troubleshooting).
If your dataset is already extracted somewhere on disk (or it's a big archive you don't want to re-upload), use the dashboard:
- Open http://localhost:8000/ (the dashboard, not
/threat). - In the Create Project card, choose dataset, fill in the project name, and paste the absolute path into Data directory path. The path can point at either:
- a directory laid out like the Dataset layout section below, or
- a
.zip/.tar/.tar.gzarchive — the server extracts it intodata/uploads/dataset_<id>/for you.
- Click Create, then hit Ingest on the new project card to run the pipeline.
The path you paste must resolve under one of the allowed roots (configurable via SANN_ALLOWED_DATA_ROOTS, defaults to SANN_DATA_ROOT's parent, $HOME, /mnt, /tmp, and data/uploads/). WSL-style paths copied from Windows Explorer (\\wsl.localhost\<distro>\... or C:\...) are translated automatically.
Faster for large datasets in scripted workflows.
# Edit .env so SANN_DATA_ROOT points at your dataset
python3 import_manager.pyThis processes the default dataset and writes to data/sann.db.
For an isolated project (same as what the dashboard does internally):
python3 import_manager.py \
--data-root /path/to/P032 \
--db data/project_9c4cee20.db \
--project-id 9c4cee20 \
--main-db data/sann.db \
--attacker-ips "128.16.11.9,114.0.194.2"If you've already ingested and just want to re-apply the post-processing passes (after editing classifier rules, for example):
python3 import_manager.py --skip-ingest/dashboard is where you start. Pick a project in the scope selector (top left) and the whole
analysis renders in one surface. Three tabs: Mission Control, Explore, Projects.
Top to bottom, Mission Control shows:
- Dataset header — the cohort (
P032), the subjects, how many scenario runs, how many corrections applied. - Sub-scope bar — scenario / identity / phase dropdowns that re-scope every panel below to a slice, live. A "Compare slices" button puts two scenarios or two subjects side by side (events, kill-chain coverage, detection coverage, MTTD, tempo, techniques, blind spots).
- KPIs + data quality — event/participant/scenario/host counts, a completeness score, and the two coverage figures: kill-chain (N/14 tactics) and detection (N/M phases).
- Kill-chain progression — the 14 tactics as a segmented bar, width ∝ volume, undetected phases flagged. Click a phase to drill in (its techniques, its commands, detection status).
- Subject & Scenario Runs — each run with its subject identity; click to focus the analysis.
- Attack narrative and detection efficacy (per-phase time-to-detect + the blind-spot list).
- Activity timeline (stacked by phase), host roles (red vs blue), participant cards (click for the full dossier: profile, sessions, top commands/tools, phases, link to Threat View), top ATT&CK techniques, alerts, network.
Click any participant card or kill-chain phase to open a drill-down drawer. The Explore tab is a form over the research API — group by any dimension (phase, tool, host, technique, …), pick metrics, filter, and download CSV for pandas/Jupyter.
/threat is the synchronized forensic HUD — the right tool when you want to watch a session
rather than analyse it in aggregate. Four areas:
Top bar. PROJECT picks the dataset, PARTICIPANT picks the identity, ⊕ IMPORT opens the upload modal, and the row of pills on the right is the MITRE ribbon — the dominant tactic at the cursor lights up.
Media zone. Switchable between VIDEO (screen recording) and TERMINAL CAST (asciinema replay), kept in sync. OGV is transcoded to MP4 on a background thread and cached, so seeking is native once it's ready; a progress bar shows the transcode advancing.
Master scrubber. A heatmap of event density across the session, colored by dominant tactic per bucket. Drag anywhere to jump. Dark bars mark where video and casts cover the timeline.
Six event panels, each a ±120s window around the cursor:
| Panel | Sources |
|---|---|
| TERMINAL | terminal_recording |
| AUTH | auth |
| SYSLOG | syslog |
| NETWORK | suricata, bt_jsonl |
| KEYLOGGER | uat |
| PCAP | zeek, sensor |
Panels show X of Y (last 200) when the window has more, and preserve your scroll position across
refetches. The PCAP panel drops L2 sensor_packet noise. Errors appear as a toast, never breaking
the UI.
Automated MITRE classification isn't perfect. If a command lands in the wrong phase — say
POST /login gets tagged initial_access when you'd call it credential_access — you can fix
it, without touching the raw ingested data.
In Mission Control, click a phase (or open a participant dossier), find the command, and hit ⚑ reclassify: pick the correct phase, add a note, apply. Every panel updates immediately, because the engine reads the corrected value. It's an auditable, reversible overlay:
- The original value is snapshotted (
attack_phase_original) before the first change — ground truth is never lost. - Every correction is recorded in an
annotationstable (what, when, how many events, your note). - The ⚑ Corrections button shows the audit log; each entry has a Revert that restores the original exactly.
Corrections are by command (one fix reclassifies all its events) or by single event, and are only available on dataset projects (each has its own isolated DB; filter projects share the corpus, so editing there is blocked to prevent cross-project leakage).
From the Projects tab or the Mission Control header:
- ⬇ Digest (JSON) — the full analysis document. If you've sub-scoped in Mission Control, the download is that slice.
- ⬇ Bundle (.zip) — a grouped, analyst-facing pack:
digest.json— the full engine documentsummary.md— a human-readable report (overview, data quality, kill-chain story, detection efficacy + blind spots, top techniques, per-subject highlights)attack_navigator_layer.json— load it in the MITRE ATT&CK Navigator to see observed techniques scored on the matrixparticipants/<id>.{json,csv}— per-subject dossier + flat event logphases/NN_<phase>.csv— events grouped by kill-chain phaseincident_timeline.csv— cross-subject chronology of significant eventsscenarios/<name>/…— for a multi-scenario dataset, each scenario as its own sub-analysis
- Raw event export —
CSV/JSON/SQLitefor the flateventsrows.
All exports honour the same sub-scope filters as the dashboard, so what you download matches what you saw.
Every project has its own SQLite at data/project_<id>.db. Switching projects in the dropdown tears down the current state — pauses the video, disposes the cast player, clears the panels, resets the cursor — and reloads everything from the new project.
Every event also gets written into the main corpus data/sann.db with project_id stamped. That means:
- Queries scoped to a project (
?project_id=da2c86c9) hit the project DB directly. - Queries without a
project_idhit the corpus and see all projects combined. - Cross-project analysis is one SQL query.
- Sharing a single project's data with someone means handing them one
.dbfile.
Creating a project from a folder path via API:
curl -X POST http://localhost:8000/api/projects \
-H "Content-Type: application/json" \
-d '{
"name": "P032",
"project_type": "dataset",
"data_path": "/path/to/P032",
"attacker_ips": "128.16.11.9,114.0.194.2"
}'
# then kick off ingest
curl -X POST http://localhost:8000/api/projects/<project_id>/ingestDeleting a project (DELETE /api/projects/<id>) removes the project row, all events for that project in the main corpus, all media registry rows for that project, and the standalone project DB file.
The ingester scans for files anywhere under user<id>/, so the layout in between is flexible. What it looks for:
dataset/
└── user<id>/
└── <scenario>/<run>/<host>/
├── *.cast # asciinema terminal recording
├── UAT-*.tsv # keylogger / typed-text
├── auth.log # SSH / sudo / PAM
├── syslog # systemd / kernel
├── eve.json # Suricata events
├── bt.jsonl # honeytrap behavior
├── conn.log # Zeek connections (JSONL)
├── sensor*.log # raw sensor_packet
├── recording.ogv # screen recording (or .webm)
├── *.pcap # raw capture (metadata only)
├── hacktools.log
└── apt.log
Nothing is mandatory. Missing files just leave their panel empty. The validation pass logs which panels have data for each participant.
The directory immediately under user<id>/ becomes the scenario_name — common names are training, ckc1, ckc2, ckc2c (kill-chain stages), but anything works.
.env (loaded by the API, the ingester, and import_manager.py):
| Variable | Default | Purpose |
|---|---|---|
SANN_DATA_ROOT |
/tmp/obsidian_full/P003 |
Root of the default dataset on disk |
SANN_DB_PATH |
data/sann.db |
Main corpus SQLite path |
SANN_ATTACKER_IPS |
(P003 defaults) | Comma-separated attacker IPs for C2 classification |
SANN_CORS_ORIGINS |
localhost:8000,127.0.0.1:8000,localhost:3000 |
CORS allowlist |
SANN_ALLOWED_DATA_ROOTS |
(auto: SANN_DATA_ROOT parent, $HOME, /mnt, /tmp, data/uploads) |
Extra root paths under which dashboard-supplied data_path values are accepted |
Everything accepts ?project_id=<id> to scope to a single project. Without it, you query the combined corpus.
Health and projects
| Method | Path |
|---|---|
| GET | /api/health |
| GET | /api/projects |
| POST | /api/projects |
| POST | /api/projects/upload |
| GET | /api/projects/{id}/status |
| POST | /api/projects/{id}/ingest |
| POST | /api/projects/{id}/sync_media |
| DELETE | /api/projects/{id} |
| GET | /api/projects/{id}/tree |
Analysis, export and curation (the analysis surface — all accept sub-scope filters
?scenario=&participant=&phase=&host=&tool=&technique=)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/projects/{id}/digest |
Full analysis document (JSON). ?download=true to save. |
| GET | /api/projects/{id}/export/bundle |
Grouped analyst .zip (digest + summary + dossiers + per-phase CSVs + Navigator layer + per-scenario) |
| GET | /api/projects/{id}/explore |
Group-by/pivot: ?dimensions=&metrics=&filters=&format=json|csv |
| GET | /api/projects/{id}/export/{csv,json,sqlite} |
Raw event export |
| GET | /api/projects/{id}/annotations |
List reclassification corrections (audit log) |
| POST | /api/projects/{id}/annotations |
Reclassify a command/event's phase |
| DELETE | /api/projects/{id}/annotations/{ann_id} |
Revert a correction |
Events and stats
| Method | Path |
|---|---|
| GET | /api/participants |
| GET | /api/participants/{pid} |
| GET | /api/participants/{pid}/phases |
| GET | /api/participants/{pid}/commands |
| GET | /api/participants/{pid}/timeline |
| GET | /api/phases |
| GET | /api/phases/{phase} |
| GET | /api/timeline |
| GET | /api/alerts |
| GET | /api/network |
| GET | /api/commands |
| GET | /api/commands/top |
| GET | /api/users |
| GET | /api/hosts |
| GET | /api/relationships |
| GET | /api/behavior/{pid} |
| GET | /api/search?q=<term> |
| GET | /api/events/stream |
| GET | /api/events/{event_id} |
| GET | /api/stats |
| GET | /api/stats/participant |
| GET | /api/analysis/overview |
Media and timeline
| Method | Path |
|---|---|
| GET | /api/media |
| GET | /api/media/list |
| GET | /api/media/cast_list |
| GET | /api/media/cast_raw/{media_id} |
| GET | /api/media/cast/{media_id} |
| GET | /api/media/video/{participant_id}?t=<seek> |
| GET | /api/media/keylogger |
| GET | /api/timeline/sync |
| GET | /api/timeline/playback |
| GET | /api/timeline/events |
| GET | /api/timeline/cast/{media_id} |
| GET | /api/timeline/uat/{media_id} |
| GET | /api/timeline/pcap/{media_id} |
There's a dependency-free smoke/regression harness that exercises the engine and the API against whatever real project data you have loaded, and reconciles the digest against direct SQL:
# engine tests only (no server needed)
python3 tests/smoke.py
# engine + live API tests
python3 tests/smoke.py --url http://localhost:8000It checks that kill-chain / host-role / scenario-sub-scope numbers match SQL, that identities resolve, that the bundle carries every promised file, that sub-scope filters are injection-safe, and that empty projects are handled honestly. Exit code 0 = all passed. Run it after any change to the engine, the endpoints, or the ingester.
The server is meant for localhost use. Even so:
- CORS is restricted to the allowlist in
SANN_CORS_ORIGINS. There is no wildcard. - Project creation validates that
data_pathresolves under the configured data root, so an API consumer can't ask the ingester to read/etc/passwd. attacker_ipsis regex-checked before it hits the subprocess argv, so shell-injection attempts get rejected with 400.- Every
limitparameter is clamped at 10000. - Filesystem paths are stripped from API responses; only filenames and stable media IDs are exposed.
- Invalid
project_idreturns 404 instead of silently falling back to the main corpus. - Project deletion validates the project DB path resolves under
data/before unlinking, so a tampereddb_pathfield can't delete arbitrary files. - Subprocesses use
sys.executableinstead of a hardcodedpython3, so the active venv is honored.
If you need remote access, tunnel over SSH. Don't expose the port directly.
The video pane shows "TRANSCODING…" forever.
ffmpeg isn't installed or isn't on PATH. Install it (sudo apt install ffmpeg) and reload. After 30 seconds without a frame, the overlay times out and shows an error toast.
A panel is empty when I expect data.
Make sure the cursor is inside the video window. Panels filter to ±120s of the cursor. The import_manager.py validation pass logs the panel-coverage matrix for every participant — rerun with --skip-ingest to see it again.
Suricata events look hours off.
DB ingested before the timezone fix. Run python3 import_manager.py --skip-ingest to rewrite the stored timestamps. The detection works off the offset in the raw eve.json.
ZIP uploaded but no media shows up.
Project status is ready but the media registry is empty. Hit POST /api/projects/{id}/sync_media manually. The upload flow does this automatically; if it fails, the project still gets marked ready since the events are there.
CORS errors in the browser console.
Add your origin to SANN_CORS_ORIGINS in .env and restart.
HTTP 404 from /api/participants?project_id=....
The project ID doesn't exist. List with GET /api/projects. The 404 is intentional — a typo doesn't silently leak combined-corpus data.
Ingest seems to do nothing.
The API runs ingest as a subprocess and writes its log to data/logs/ingest_<id>.log. Read that,
or run the same import_manager.py command from the CLI to see the output live.
My project imported but shows empty / no analysis.
The archive has no ingestable data. Mission Control (and the project card) will say exactly why —
e.g. "Archive contains only empty folders (20 directories, 0 data files)." This usually means a
broken export: a healthy dataset archive is hundreds of MB to several GB, so if yours is only a
few KB it's just a folder skeleton. Re-export the dataset with its contents and create the project
again.
SANN/
├── api/
│ └── main.py # FastAPI server (analysis, media, projects, curation)
├── analysis/ # the analysis engine (pure, no web imports)
│ ├── __init__.py # build_digest() / build_bundle() orchestration
│ ├── common.py # shared helpers + reference data (kill-chain, tools)
│ ├── metrics.py # summary, scenarios, identities, kill_chain, participants…
│ ├── narrative.py # attack narrative + session reconstruction
│ ├── detection.py # detection efficacy (blue-team) + blind spots
│ ├── ttp.py # MITRE/TTP depth + ATT&CK Navigator layer
│ ├── hosts.py # host-role classification (attacker/pivot/sensor/…)
│ └── exporters.py # dossier / phase / timeline / summary.md / zip builders
├── frontend/
│ ├── index.html # Mission Control dashboard (served at /dashboard, / redirects here)
│ ├── palantir.html # Threat View HUD (served at /threat)
│ └── timeline.html # legacy (unused)
├── tests/
│ └── smoke.py # engine + API regression harness
├── data/ # SQLite DBs + uploads land here (gitignored)
├── ingest_v2.py # parsers + MITRE classifier
├── import_manager.py # post-processing + advisory validation
├── requirements.txt
├── .env.example
└── README.md
Internal tool. Not for redistribution without permission.
