A real-time network intrusion detection system built as four
independently authored, independently testable modules, wired together
into one running system in docker-compose.yml. All six containers
(kafka, ingestion, ml, tier2-reasoner, dashboard-api, dashboard-frontend)
have been verified running together for real: docker compose up -d --build built all five application images and started the full stack,
integration/test_e2e.py passed against it (pcap replay -> ingestion ->
Kafka -> ml -> Kafka -> dashboard-api -> REST), and dashboard-api's
/healthz showed both network.ids.alerts and network.ids.explanations
being consumed live (alerts_processed/explanations_processed both >0)
-- i.e. Tier 2 actually escalated, reasoned, and published back through
the whole chain, not just in isolated unit tests. A human has since
also driven the live dashboard UI itself (a headless-browser session
against the running stack: signed in, opened the live feed and Triage
view, clicked into an escalated alert, confirmed the SHAP explainability
panel and Tier 2 analysis section both render) -- see "What integration
found" below for the three real bugs (one Docker/OneDrive integration
issue, one stale-schema-volume crash, one frontend crash on live
WebSocket data) that surfaced doing all of this, and Known limitations
for the host-resource caveat that verification also surfaced.
| Module | Folder | What it does |
|---|---|---|
| Ingestion | ingestion/ |
Live/pcap-replay packet capture -> sliding-window flow feature extraction -> publishes to Kafka |
| ML detection | ml/ |
Consumes flow features -> two-stage detector (Isolation Forest + XGBoost) + open-set escalation gate -> SHAP explanations -> publishes alerts to Kafka |
| Dashboard/API | dashboard-api/ |
Consumes alerts + Tier 2 explanations -> FastAPI REST/WebSocket API -> React triage dashboard (severity, escalation, Tier 2 analysis) |
| Tier 2 reasoning | tier2_reasoner/ |
Consumes ml's escalated alerts -> RAG over MITRE ATT&CK -> LLM explanation -> publishes to its own topic |
Each module folder has its own README with that module's design decisions and a "known limitations" section written from inside that module. This file is the integration layer on top: how the four actually run together, the true (verified, not assumed) schema they agree on, and what broke and got fixed only once they were wired up for real.
docker-compose network
┌──────────────────────────────────────────────────────────────────┐
│ │
│ ┌────────────┐ network.flow.features ┌────────────┐ │
│ │ ingestion │ ─────────────────────────► │ │ │
│ │ (pcap │ │ │ │
│ │ replay, │ │ kafka │ │
│ │ looping) │ │ (1 broker, │ │
│ └────────────┘ │ KRaft) │ │
│ │ │ │
│ ┌────────────┐ network.flow.features │ │ │
│ │ │ ◄────────────────────────── │ │ │
│ │ ml │ │ │ │
│ │ (two-stage │ network.ids.alerts │ │ │
│ │ detector) │ ─────────────────────────► │ │ │
│ └────────────┘ └─────┬──────┘ │
│ │ │
│ ┌────────────────────┐ network.ids.alerts │ │
│ │ dashboard-api │ ◄──────────────────────────┘ │
│ │ (FastAPI: REST + │ │
│ │ WebSocket, SQLite) │ │
│ └──────────┬──────────┘ │
│ │ REST (8000) + WebSocket │
└──────────────┼──────────────────────────────────────────────────┘
│
┌──────────────┴──────────────┐
│ dashboard-frontend (5173) │ <- served to your browser (host machine)
│ React dashboard │
└───────────────────────────────┘
All four application services (ingestion, ml, dashboard-api,
dashboard-frontend) point at the one kafka service defined in
docker-compose.yml -- none of them runs or connects to a broker of its
own. dashboard-frontend is the only piece your browser talks to directly
(over the published 8000/5173 ports); everything else communicates
over the compose-internal network by service name.
docker compose up -d --build
Then open http://localhost:5173 and sign in with the demo credentials
below (or docker-compose.yml's IDS_DASHBOARD_USERNAME/PASSWORD, if
you changed them). The REST API is at http://localhost:8000 (see
dashboard-api/README.md for the full endpoint list); Kafka itself is not
published to the host, since nothing outside the compose network needs it.
| Dashboard | http://localhost:5173 |
| API | http://localhost:8000 (docs at /docs) |
| Demo credentials | analyst / changeme123 (set in docker-compose.yml) |
ingestion replays ingestion/tests/fixtures/sample_tcp.pcap (the same
fixture that module's own tests assert exact feature values against) on a
loop, so the dashboard keeps receiving fresh alerts rather than a single
one-shot burst. ml trains its demo model at Docker build time from the ML
module's own synthetic test fixture -- not real CICIDS2017 data -- see
Known limitations.
To verify the whole pipeline programmatically instead of watching the UI:
docker compose up -d --build
python integration/test_e2e.py
This polls the dashboard API until an alert traceable back to the demo pcap's actual flows appears, checks it carries a SHAP explanation, and checks it's individually retrievable -- i.e. it proves the full chain (replay -> ingestion -> Kafka -> ml -> Kafka -> dashboard-api -> REST) actually ran, not just that the containers started.
Tear down with docker compose down (add -v to also drop the Kafka and
alert-history volumes).
Exactly two Kafka topics, both JSON, both documented field-by-field with their true (verified) schema in SCHEMA.md:
network.flow.features-- ingestion -> mlnetwork.ids.alerts-- ml -> dashboard-api
No module imports another's code. Each module maintains its own copy of
the schema it depends on (see each module's schema.py); SCHEMA.md is
the reconciled, cross-checked source of truth for what those copies
actually agree on, superseding the schema tables in each module's own
README where the two differ.
Independently-built modules that had never actually talked to a real broker (or, for the two bugs below, never actually run inside real Docker containers against each other) surfaced four real bugs that no module's own test suite (which stub out Kafka, or stub out Docker entirely) could have caught:
- Single-broker Kafka defaults break consumer groups silently.
offsets.topic.replication.factor(and the transaction-log equivalents) default to values that assume a 3-broker cluster. With one broker, the internal__consumer_offsetstopic gets stuck retrying its own creation forever, with no client-visible error -- bothmlanddashboard-apiwould connect fine and then simply never receive a message. Fixed indocker-compose.ymlby setting these to1for the single-broker demo cluster; see the comment there. Nothing in any module's code was wrong -- this is purely a broker configuration issue that only exists in a single-node setup like this demo's. - Docs vs. actual schema drift, not breaking mismatches -- see
SCHEMA.md's "Reconciliation findings" section for the two minor documentation gaps found (and why neither needed an adapter). - OneDrive Files On-Demand corrupts Docker Desktop's build-context
transfer, on a machine where the repo lives inside a OneDrive-synced
folder.
docker compose buildfailed for 3-5 of the 5 services with a crypticERROR: invalid file request Dockerfile(or the same error against other context files, unpredictably, on a later attempt) -- nothing to do with the Dockerfiles themselves. Root cause: files under OneDrive's Files On-Demand feature carry aReparsePointattribute (Windows' cloud-placeholder mechanism) even once fully downloaded/ pinned locally, and Docker Desktop's Windows file-sharing layer cannot reliably read reparse-point-tagged files -- confirmed by comparingGet-Item'sAttributeson the failing vs. succeeding services' Dockerfiles (Archive, ReparsePointvs. plainArchive), and by the failure following files unpredictably between build attempts as OneDrive re-tagged them. Neither pinning the files (attrib +P -U) nor restarting the OneDrive sync host resolved it. Fix: copy each service's build context to a location outside any OneDrive-synced folder (this session used the scratch directory) and build from there via adocker-compose.override.yml-style context override; the resulting images are identical anddocker compose upafterward needs no further workaround. This is an environment/OS-integration issue, not a bug in this repo's Dockerfiles or compose config -- it will only affect a machine where the repo sits inside a OneDrive (or similar cloud-sync) folder with Files On-Demand enabled. - A stale
dashboard-apiSQLite volume from before Tier 2 existed crashed the container on startup withsqlite3.OperationalError: no such column: escalated.store.py's schema usedCREATE TABLE IF NOT EXISTS, which is a no-op against a database file that already has analertstable -- so adashboard_dataDocker volume created by an earlier partialdocker compose upattempt (beforeunknown_mass/escalated/escalation_triggerwere added) kept missing those columns forever, and theCREATE INDEX ... ON alerts(escalated)statement right after it then failed outright. Fixed with a real migration step inAlertStore.__init__(PRAGMA table_info+ALTER TABLE ... ADD COLUMNfor whatever's missing, before the index-creation statements run) -- seedashboard-api/src/ids_dashboard/store.pyand its regression test (test_dash_store.py::test_opening_a_pre_tier2_database_migrates_the_alerts_table). This is a genuine gap the demo's own test suite couldn't catch, since every test there starts from a fresh:memory:database -- only a real persistent volume that outlived a schema change surfaced it. - The dashboard frontend crashed on a live-pushed alert with
Cannot read properties of undefined (reading 'replace').AlertRow.tsxcalledalert.triage_status.replace(...)unconditionally, buttriage_statusis adashboard-api/DB-only concept thatml's Kafka alert schema never includes -- a REST-fetched alert always has it (the store adds it on insert), a live WebSocket-pushed alert (broadcast as received from Kafka, before any DB round-trip) never does. Found by actually opening the dashboard in a headless browser against the live stack, not by any of the 56 backend tests ornpm run build-- every fixture those use already hastriage_statusset, so nothing exercised the field's absence until a real alert arrived over a real WebSocket connection. Fixed by defaulting a missingtriage_statusto"new"at render time and correctingtypes.tsto mark it (andtriage_note/triage_updated_at) optional, matching what's actually guaranteed on each path -- seedashboard-api/README.md's known- limitations section andAlertRow.tsx.
ingestion/ packet capture, flow extraction, Kafka producer (+ its own README/tests)
ml/ two-stage detector, SHAP, open-set escalation gate, Kafka consumer+producer (+ its own README/tests)
dashboard-api/ FastAPI backend + React frontend (+ its own README/tests)
tier2_reasoner/ RAG + LLM escalation reasoning (+ its own README/tests)
integration/ test_e2e.py -- the full-stack check described above (covers the first three modules only -- see Known limitations)
docker-compose.yml the shared broker + all four services
SCHEMA.md reconciled, verified Kafka topic schemas, including network.ids.explanations
Unlike the original three-module integration (where no module's own code
needed changing -- see "What integration found" above), wiring Tier 2 in
did require real application-code changes, not just compose/environment
glue: ml/src gained the ability to fit, calibrate, save, and load an
open-set gate (ids_ml.train --gate / ids_ml.serve auto-loading it) so
the demo model actually produces escalated: true alerts instead of only
being capable of it in principle; dashboard-api/src and
dashboard-api/frontend/src gained a second Kafka consumer, storage
table, REST endpoint, and UI panel to actually consume and display
network.ids.explanations. This is called out explicitly because it's a
deliberate departure from the "integration never touches module code"
principle the first three modules established -- Tier 2's existence
is new capability those modules didn't have before, not a
config-only wiring exercise.
Each module's own README has a fuller "known limitations" section written from inside that module; this is the consolidated, cross-cutting view.
Model quality (ml): the demo model is trained at Docker build time on
ml's own synthetic test fixture, not real CICIDS2017/2018 data -- expect
plausible-looking but not meaningful predictions (e.g. the demo pcap's
plain TCP handshake sometimes gets classified as "Heartbleed" by the toy
model). Stage 1's flag threshold is tuned for that synthetic fixture's ~35%
attack prevalence, nowhere close to real traffic's <<1%. See ml/README.md
for the honest per-category evaluation results this claim is based on, and
for the training-side split-leakage/concept-drift/adversarial-robustness
findings.
Auth (dashboard-api): a single shared Basic-auth credential pair, no
TLS, no per-user accounts, no rate limiting. WebSocket auth is a short-lived
token issued over that same Basic-auth REST call. Fine for this demo;
not for anything internet-facing. See dashboard-api/README.md.
Single points of failure (this integration layer): one Kafka broker,
one dashboard-api process (its in-memory WebSocket broadcaster and
WebSocket-token store don't survive a restart or scale past one replica),
one SQLite file for alert history. None of this is hidden behind retry
logic that would mask data loss -- see dashboard-api/README.md's
known-limitations section for specifics.
Ingestion (live capture): this demo only exercises pcap replay.
ingestion also supports live packet capture, but that needs
Npcap/libpcap and elevated privileges neither of which are set up in these
containers -- see ingestion/README.md.
No detection-quality guarantee, adversarial or otherwise: see
ml/README.md's adversarial-robustness section for why "low-and-slow"
evasion's effect on this specific model went the opposite direction from
the usual intuition, and why that result doesn't generalize on its own.
This demo's pcap is tiny (2 flows). It's enough to prove the pipeline
wiring end-to-end (which is what integration/test_e2e.py checks), not
enough to say anything about detection quality or the dashboard's
volume/severity views at any real scale.
Tier 2 reasoning (tier2_reasoner/) is wired into this system and
verified running end-to-end for real -- docker-compose.yml includes it,
ml's demo model is trained with the openset gate so it actually
produces escalated: true alerts, dashboard-api consumes
network.ids.explanations and surfaces unknown_mass/escalated/Tier 2
explanations in the dashboard (REST + live WebSocket), and a live
docker compose up -d --build run showed dashboard-api's /healthz
reporting both alerts_processed and explanations_processed > 0 -- but
its own live testing found it is not currently reliable or fast enough to
call real-time. Its LLM path has been verified live against two real
API keys: 9/9 completed RAG-enabled calls correctly named the right MITRE
technique (a real, consistent, positive signal), but a real batch run
also surfaced the honest problem -- 2 of 6 calls in one batch failed
outright even after retries, all 6 no-RAG calls in that same batch failed
too, and the calls that did succeed took 23.5-60.4 seconds each (vs. 2.6s
for an earlier isolated call) -- see tier2_reasoner/README.md's
"Latency (live-verified -- and a reliability problem)" section. The
batch-measured amortized latency is ~4,056ms/flow (~420x Tier 1's own
latency), not the ~584ms an earlier single fast call suggested.
tier2_reasoner has retry/backoff logic (RetryingLLMClient), built
directly from that failure and since live-retested with a real 36-call
paired RAG-ablation batch: it correctly recovered from transient
per-minute rate limiting, but 16 of 36 calls still failed once a daily
quota ceiling was hit partway through -- a different failure mode
retry/backoff can't fix by construction. The completed calls produced a
real, statistically significant H3 result: RAG-grounded calls named the
correct MITRE technique in 10/10 completed cases vs. 0/10 for no-RAG,
paired McNemar p=0.0039 -- see tier2_reasoner/README.md's H3 section
for the full breakdown. In this
session's docker compose run, tier2_reasoner had no API key configured
(so it ran on the deterministic stub LLM client, not a real one) -- the
end-to-end wiring verification above is real; the live-LLM latency/
reliability numbers above are still from the isolated tests described in
tier2_reasoner/README.md, not from this compose run.
What "wired in" does and doesn't mean here: every piece above was
built, unit-tested (260 tests passing across all four modules combined as
of this update: 115 ml + 70 tier2_reasoner + 56 dashboard-api + 19
ingestion -- the frontend crash below was caught by the live browser
check, not by an automated test, since this frontend has no test runner
set up; see dashboard-api/README.md's known-limitations section), and
docker compose up -d --build was run for real: all
five application images built, all six containers started, and
integration/test_e2e.py passed against the live stack (see "What
integration found" above for all five real bugs -- one Docker/OneDrive
integration issue, one stale-schema-volume crash, and one live-alert
frontend crash among them -- that surfaced doing this and how each was
fixed). A human has since also driven the live dashboard UI itself:
a headless-browser session (Playwright against the system's installed
Edge, run against the actual running compose stack, not a mock) signed
in, opened the live feed (WebSocket connects and shows "OPEN"), opened
the Triage view (rendered 100 real, DB-backed escalated alerts with
correct severity/class/ESC badges), and clicked into one to confirm the
SHAP explainability panel and the "Tier 2 analysis (LLM/RAG)" section
both render real backend data -- exactly the check flagged as missing in
an earlier version of this README, and the one that found bug #5 above.
This machine's RAM constraint (below) made getting a stable enough
window to run this check take several Docker Desktop restarts, but it
did get done, not skipped.
This demo's docker compose stack needs more RAM than this
development machine reliably has free. The host has 7.7GB total RAM;
Docker Desktop's backend was observed crashing outright (not just losing
the daemon connection -- the process disappeared entirely) multiple times
while this stack was running, correlating with free memory dropping to
under 1GB. The full stack (a JVM-based Kafka broker, three Python
services each pulling in numpy/pandas/scikit-learn/xgboost, and a Node
dev server, all running simultaneously) is a meaningfully heavier
footprint than most of this demo's design decisions accounted for. This
is a host-resource limitation, not a code defect -- the stack itself ran
and passed integration verification every time Docker Desktop's backend
was actually up. If running this continuously (not just for one-off
verification) on similarly constrained hardware, consider: lowering
Kafka's JVM heap (KAFKA_HEAP_OPTS, unset in docker-compose.yml
today), adding mem_limits per service, or running on a machine/VM with
more headroom.