Skip to main content

From nothing to an AI verdict, on one host

This page is the written form of the walkthrough recorded for the README. It covers the same ground in the same order, with the detail a three-minute video has no room for: what each command does, what you should see, and what it means when you see something else.

Two minutes fifty-seven seconds, no audio. It also ships in the repository at apps/web/public/demo/demo.mp4, so it is there in a clone, offline.

What is real in the recording. The stack, the images, the threat feed, the event, the alert and the token counts are all real. The images came from ghcr.io, not from a local build. Demo mode was off and nothing was seeded. The event that becomes an alert was authored to be representative — everything downstream of it is the product doing its own work. Terminal waits are shortened and the recording says so on screen; the browser sections run at real speed.

The scenario​

A single host that people reach over the network, addressed by something other than localhost. That is the shape most self-hosted deployments take and the one where the laptop defaults are wrong, so it is worth walking rather than assuming.

1. Configure, in one file​

git clone https://github.com/beenuar/AiSOC && cd AiSOC
cp .env.example .env

Copying the template is optional — make up creates .env if it is missing — but doing it explicitly lets you set two values before anything starts.

# .env
AISOC_CONSOLE_BIND_ADDR=0.0.0.0
AISOC_CONSOLE_URL=http://192.0.2.10:3000 # the address people type

The first publishes the console beyond loopback. Every host port ships bound to 127.0.0.1, which is right for a laptop and useless for a server: the stack comes up healthy and nothing outside the machine can reach it. The console is the only port a browser needs — its Next.js server proxies same-origin paths to the API, the agents service and the realtime gateway over the internal network — so publishing one port exposes one service and the datastores stay private.

Publishing it also hardens authentication rather than weakening it. The stack runs production-class by default, so a request with no bearer token gets a 401; and because compose passes the published addresses to each service as AISOC_PUBLISHED_BIND_ADDRS, a non-loopback value makes every service refuse the development auth shim outright, even if somebody sets AISOC_DEV_AUTH_BYPASS=1 by hand.

That is the opposite of how this page used to read. ENVIRONMENT defaulted to development in the compose file and in .env.example, and in a development-class environment an uncredentialed request resolved to a demo user carrying the admin role, in the same tenant your real administrator was about to be created in. If you want the signed-in-automatically experience on a laptop, that is now make up-dev.

The second decides which address AiSOC tells people to use. make up and make bootstrap both print it when the administrator is created. Leave it at the default and a server deployment prints http://localhost:3000, which resolves on the reader's own machine to something that is not your console.

Put TLS in front before exposing this to a network you do not control

AISOC_CONSOLE_BIND_ADDR=0.0.0.0 publishes plain HTTP. See Single-host deployment for the reverse-proxy setup.

2. Bring it up​

make up

This pulls the published images, starts the 16 CORE services, waits for each one that declares a health check, and then creates the first administrator. On a first run it also downloads the bundled llama3.2:3b-instruct-q4_K_M model — about 2 GB — into a named volume; only make clean makes it fetch again.

make up ends with something like:

Console: http://192.0.2.10:3000
API: http://localhost:8000 (spec: docs/openapi.yaml; interactive
docs are off in this production-class stack)

Prove the pipeline works: make smoke

────────────────────────────────────────────────────────────────
Administrator created. Sign in at http://192.0.2.10:3000
────────────────────────────────────────────────────────────────
Email admin@aisoc.internal
Password ••••••••••••••••

The password is generated on your machine, shown once and stored nowhere. Copy it, or run make bootstrap ARGS=--reset-password to mint another. The API: line stays on loopback deliberately: that is where the API publishes on this host, and a console URL behind a reverse proxy tells you nothing about where the API port ended up.

If the stack does not come up, make doctor checks host tools, memory and disk inside the Docker VM, every port, and each datastore by querying it rather than by asking whether its container is running.

3. Sign in, and look at what is honestly empty​

Open the console at the address it printed. Most of it has nothing in it, because nothing has been connected yet, and it says so rather than filling the space — "No entities are currently being tracked", zeroes that are really zeroes.

One page is different. Threat Intel shows roughly 1,700 rows within a minute or two of boot, each tagged cisa-kev: the CISA Known Exploited Vulnerabilities catalog, which is public, authoritative and needs no API key. It is the one thing in a fresh install that is neither synthetic nor yours.

The feed has a fallback, and you will see it in the logs

The catalog's primary URL on cisa.gov answers 403 Forbidden to non-browser clients. The service logs that at warning and fetches the same catalog from the cisagov/kev-data mirror, which is what the count comes from. A warning line followed by CISA KEV IOCs ingested is the healthy path, not a failure.

4. Send it one real event​

Ingest is authenticated, and the credential carries its own tenant — the request body never names one, and no header is trusted to.

export AISOC_INGEST_TOKEN=$(make -s ingest-token ARGS=--quiet | tail -1)

curl -X POST http://localhost:8081/v1/ingest/batch \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $AISOC_INGEST_TOKEN" \
-d '{"connector_id":"edr-1","connector_type":"crowdstrike","source_format":"json",
"events":[{"severity":"high","title":"Encoded PowerShell from Office",
"host":"WIN-FIN-01","process_name":"powershell.exe",
"user":"j.reyes","src_ip":"203.0.113.44"}]}'

A successful push answers {"accepted":1,"rejected":0,"request_id":"…"}. The event is normalized to a common shape, published to Kafka, evaluated against the executable detection corpus, correlated, and written to Postgres as an alert — in about a second on an idle stack.

Then run the built-in proof:

make smoke

It pushes its own event and follows it the whole way, reporting ten stages as PASS or FAIL — including that an unauthenticated push is refused, that severity survives normalization, that source attribution is not duplicated, and that the description is prose rather than a serialized payload. A clean run ends PASS: 10/10 stages.

5. The alert, and what answered it​

The alert appears in the queue attributed to the connector that fed it. Opening it gives you the confidence score with its contributing factors named and weighted, the entities involved, and the verdict triage reached.

Triage runs automatically on every fused alert. It is a real model call: the Cost Dashboard reports measured prompt and completion tokens against the resolved model name, and the per-run record carries the same figures.

The bundled model is small, and the recording predates a fix

llama3.2:3b-instruct-q4_K_M is chosen to run on CPU in 8 GB of RAM, not to be accurate. When its reply cannot be validated, triage falls back to a deterministic path and labels which one answered: the run's model_used reads kafka:auto_triage:deterministic rather than naming a model. The token counts are still real, because the model really was called; what failed was the shape of its reply.

Both triage runs in the recorded walkthrough took that fallback, and that is no longer representative. Triage now asks the provider to constrain its reply to a JSON object. Measured over 50 alerts through the gateway, replies triage could use went from 44 of 50 to 50 of 50; every one of the six failures carried a correct verdict and confidence with a malformed rationale, and none was truncated. Reproduce it with python3 scripts/measure_triage_reliability.py --attempts 50 --json-mode.

The video is kept as recorded rather than re-cut to flatter the product, so expect it to show the fallback the older build took. Set OPENAI_API_KEY with AISOC_LLM_MODEL_FAST / AISOC_LLM_MODEL_DEEP to route to a hosted model instead — no hosted provider has been exercised here.

Measured spend reads $0.00 because the model runs beside the stack and costs nothing per call. That is a real zero, not an unmeasured one — the dashboard distinguishes the two and never shows an unpriced call as free.

Running a second stack on the same host​

Every service in docker-compose.yml pins a container_name, so a distinct COMPOSE_PROJECT_NAME alone is not enough to run two stacks side by side. You need a docker-compose.override.yml that overrides both the names and the host ports:

services:
web:
container_name: other-web
ports: !override
- "0.0.0.0:13000:3000"

ports: !override matters: a plain override appends, leaving the original binding in place and the conflict unresolved. Note also that make up's port pre-flight reads docker compose port, which only knows about containers that already exist — on a cold start it cannot see a remap that has not run yet and will report the default port as taken. Start that stack with docker compose up -d once and make up works normally from then on.

Reproducing the recording​

The walkthrough was recorded from a real session, not reconstructed:

  • Terminal segments were captured with asciinema in a real PTY and rendered with agg. The commands genuinely ran; the only edit is that idle gaps are capped, which the on-screen badge discloses.
  • Browser segments are Playwright screen recordings of the live console at the host's LAN address, at real speed.
  • The generated administrator password was filtered out of the terminal stream as it was printed, and the browser only ever shows a masked password field.

Assets live in apps/web/public/demo/: demo.mp4 (the full recording), hero.gif (the README preview loop, cut from the same file) and demo-poster.png.