Harizeon

HARIZEON

Security infrastructure, provisioned like cloud. Harizeon is the security control plane for teams too small to have a security team.

Source of truth: harizeon-master-plan.txt. This repo implements that plan milestone by milestone (roadmap in §16).

Status

W01 — Foundation: done.

Next: W09 — schedules + notifications: cron-driven scans, email/Slack/webhook channels with HMAC signing, digest emails and quota warnings. See §16.

W08 — Web checks + normalization: done (+new/−resolved proven accurate).

W07 — Findings core: done (status workflow + the daily-driver screen).

W06 — Probe + inspect: done (first real findings, proven live).

W05 — Passive discovery: done (real CT + DNS + RDAP, proven live).

W04 — Scan job pipeline: done (queue + worker + live view, proven live).

W03 — Assets + ownership verification: done (API + console, proven live).

W02 — IAM: done (control-plane API + proven auth flow).

Stack (§15)

Layout

db/migrations/     SQL schema, applied in filename order by db/migrate.sh
db/verify.sql        Security invariants self-check (RLS isolation, append-only)
docker-compose.yml Postgres + Redis + one-shot migrator
api/               Fastify control-plane API (auth, orgs, api-keys, assets, scans)
worker/            Python data-plane worker (queue consumer + discovery; no DB creds)
infra/db/          App-role provisioning (non-owner role + RLS grants)
web/               Next.js console (marketing site arrives in W12)
docs/              legal drafts + scanner engine licence audit
.github/workflows/ CI: web build/lint, migration apply, dependency scan

Run locally

cp .env.example .env
docker compose up -d          # starts Postgres + Redis and applies migrations
# API (control plane) — connects as the RLS app role:
cd api && npm install
DATABASE_URL=postgresql://harizeon_app:<pw>@localhost:5432/harizeon npm start
# Console:
cp web/.env.example web/.env.local   # HARIZEON_API_BASE (default http://localhost:8080)
cd web && npm install && npm run dev   # http://localhost:3000  (/login, /signup, /assets)

The app role is provisioned once after migrations: DATABASE_URL=postgresql://harizeon:harizeon@localhost:5432/harizeon HARIZEON_APP_PASSWORD='<strong secret>' sh infra/db/create-app-role.sh.

Migrations are idempotent. To apply them without Docker: DATABASE_URL=postgresql://... sh db/migrate.sh.

Windows note: if a local PostgreSQL (e.g. the EDB installer service) already holds port 5432, host-side tools will reach IT instead of the container — the symptom is password authentication failed with correct credentials. Either stop the local service, or remap once: POSTGRES_PORT=5433 REDIS_PORT=6380 docker compose up -d (then point host-side DATABASE_URL at localhost:5433). Container-to-container traffic always uses the internal ports and is unaffected.

Verify

Sandbox (safe end-to-end demo)

You never need to scan a host you do not own: sandbox/ is an intentionally-vulnerable target that makes the safe-to-demo checks fire (exposed .git/.env/SQL dump, Swagger UI, an unauthenticated Ollama-shaped /api/tags, and a JS bundle carrying a fake-but-shaped AI key).

docker compose --profile sandbox up -d --build     # adds the `sandbox` container
export HARIZEON_SANDBOX_HOSTS=sandbox              # or set it in .env

cd api && API_BASE=http://localhost:8080 \
  DATABASE_URL=postgresql://harizeon_app:<pw>@localhost:5432/harizeon \
  DOMAIN=sandbox node scripts/e2e-demo.mjs

HARIZEON_SANDBOX_HOSTS is the only exception to the §11 egress guard: it accepts just the listed hosts/addresses, and it is ignored entirely when NODE_ENV=production — the worker refuses to start with it set there. The sandbox container must never be deployed outside a local network.

Production deployment (§07.5)

  1. Secrets live in .env (gitignored, copy .env.example). In production the API refuses to boot without HARIZEON_MASTER_KEY; set COOKIE_SECURE=true behind TLS, and HARIZEON_EMAIL_API_KEY (Resend) if alert mail is wanted — unset, email endpoints fail honestly instead of silently dropping.
  2. Transport: terminate TLS with Caddy (infra/proxy/Caddyfile) in front of the console (:3000) and the API (:8080). Nothing else is public.
  3. Private data stores: Postgres and Redis are published on 127.0.0.1 only (compose). The worker needs no database access at all.
  4. Migrations: docker compose up builds and runs the one-shot migrate service; safe to re-run. Then provision the app role once (infra/db/create-app-role.sh).
  5. Demo mode must stay off: the web build refuses HARIZEON_DEMO_MODE=1 when NODE_ENV=production, and the worker refuses HARIZEON_SANDBOX_HOSTS there too.
  6. Backup / restore: infra/db/backup.sh (logical dump to stdout) and infra/db/restore.sh (refuses to overwrite a non-empty database). Restore into a fresh database, then re-provision the app role.
  7. Scale-out: extra API replicas with HARIZEON_RUN_LOOPS=0; workers via docker compose up -d --scale worker=N.

Capacity & scaling

The API is a single Fastify process; the DB pool (default 10) and the absence of a proxy are the first limits. To run more than one API replica:

  1. Start every replica except one with HARIZEON_RUN_LOOPS=0. The ingest, reaper, scheduler and re-verification loops must run in exactly one process, otherwise each replica schedules scans and consumes the same jobs.
  2. Put a reverse proxy (nginx/Caddy) in front and enable trustProxy (already on) so rate limiting sees the real client IP.
  3. Keep API replicas × DB_POOL_MAX under Postgres’ max_connections; add PgBouncer in transaction mode once the total approaches 100.
  4. Scale the scanner independently: docker compose up -d --scale worker=4 (workers use a Redis consumer group, so distributing is safe).

Protections in place: @fastify bodyLimit, per-IP rate limiting (RATE_LIMIT_PER_MIN, tighter RATE_LIMIT_AUTH_PER_MIN for /v1/auth/*), statement_timeout + connect timeout on the pool, a short in-process session cache, and graceful shutdown on SIGTERM. Rate limiting and the loop flag have defaults that keep tests and single-process dev unaffected.

Measure before promising a number:

cd api
node scripts/loadtest.mjs http://localhost:8080 50 15   # url concurrency seconds

Non-negotiables