English | 日本語
sakuda is short for 「さくっとDAST」 ("DAST, quickly") — the aim is a useful security scan with as little setup as possible.
A self-hosted DAST (dynamic application security testing) web application security scanner that ships as one Docker image: OWASP ZAP, nuclei, katana, httpx and dalfox behind a Nuxt UI. It covers what the individual tools leave to you — authenticated scanning of single-page apps (header injection plus browser storage seeding), OpenAPI-driven active scans (documented endpoints and a spec generated from the crawl), XSS / SQL injection / command injection checks gated behind an explicit opt-in, and honest per-engine reports with a diff against the previous scan, history charts and Markdown export. Point it at a target you own — an OWASP Juice Shop demo is one command away — and it runs a bounded, time-budgeted scan and tells you exactly what it did and did not cover.
Register a site, run scans against it with nuclei, ZAP API (active scan against an OpenAPI spec), ZAP frontend (spider + baseline against a seed page) and/or Dalfox (bounded reflected/DOM XSS), and browse the results from the UI. Discovery is separate from scanning: ZAP's spider and katana crawl the site, you review the URLs they found and save the ones you want as target paths, and nuclei scans that saved list on every run — no need to crawl again until the site changes.
MVP scope: sites → scans → per-engine reports. See Not in this MVP for what's deliberately out.
Scan report — severity summary, the diff against the previous scan, and a per-engine panel with its status, run time and time limit:
Site page — findings history over time (by severity, by engine, and new / persisting / resolved), the saved target list, and the discovery panel:
True to the name, sakuda tries to diagnose as much as it can with little effort — but automated DAST cannot find every vulnerability, and a clean scan is not proof that a target is secure. It is strong on the classes its engines actively test (injection, misconfiguration, exposure, redirects) and blind to what needs human reasoning:
- Business logic and authorization-workflow flaws — price tampering, broken object-level access, multi-step abuse. Payload scanners don't reason about intent.
- Anything OSINT- or context-dependent — guessable credentials, leaked secrets, information only meaningful to someone who knows the app.
- Classes that need a custom nuclei template or a hand-written OpenAPI spec to be reached at all, and endpoints the crawl never discovers.
- Partial runs — an engine that hits its time limit is reported as stopped at limit; the absence of a finding there is not the absence of the bug.
Read a clean result as "nothing these engines found within this budget", and pair sakuda with manual testing — use it to surface issues fast, not as a completeness guarantee.
Everything — the app, nuclei + templates, and ZAP — ships in one image.
docker-compose.yml + Makefile wrap the commands; every port and secret is
read from .env.
make env # .env from .env.example with a fresh SAKUDA_ENCRYPTION_KEY
make build # build the image (10–20 min the first time)
make up # start → http://localhost:3001
make logs # follow logs · make restart · make ps
make down && make up # recreate sakuda (e.g. after make build)
make down # stops sakuda only — the data volume and Juice Shop are untouched
make juice-up && make juice-seed # optional: OWASP Juice Shop as a ready-to-scan demo site (auth included)make help lists every target. Without make:
docker compose up -d sakuda # same thing, reads .env
# or plain docker:
docker build -t sakuda .
docker run -d --name sakuda -p 127.0.0.1:3001:3000 --shm-size=1g \
--add-host=host.docker.internal:host-gateway \
-e SAKUDA_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
-v sakuda-data:/data \
sakudaThe container always listens on 3000; the host-side ports come from
.env so sakuda can coexist with other local stacks:
.env variable |
Default | What |
|---|---|---|
SAKUDA_PORT |
3001 |
sakuda UI/API (make up, make dev) |
JUICESHOP_PORT |
4001 |
OWASP Juice Shop dry-run target (make juice-up, own compose project) |
SAKUDA_BIND |
127.0.0.1 |
Bind address for published ports — keep it loopback-only |
Override per invocation with make up SAKUDA_PORT=3005.
- Trust model: sakuda has no authentication. Anyone who can reach the
port can read/create/modify sites (including their configured headers) and
start scans against arbitrary hosts. Bind it to loopback only (the default
SAKUDA_BIND=127.0.0.1) or put it behind an authenticating reverse proxy — never publish it on0.0.0.0on a shared or internet-facing host. - Keep the key.
SAKUDA_ENCRYPTION_KEYencrypts stored request headers (Cookie/Bearer/etc.) at rest. If you lose it, those headers become permanently unreadable — write it down somewhere safe (e.g. a password manager), it is not recoverable from the data volume. - Memory: give Docker Desktop 6–8 GB of RAM — the ZAP frontend engine
(
zap-fe) is the heaviest consumer and will fail or be OOM-killed on less. - Target URLs on your host machine: when a site's base URL is
http://localhost:PORT(or127.0.0.1), sakuda automatically rewrites it tohost.docker.internalfor engines running inside the container — no extra configuration needed, as long as the container was started with--add-host=host.docker.internal:host-gateway(as above). The Firefox that ZAP's Ajax spider drives is also told to treat that alias as a secure context (dom.securecontext.allowlist): a plain-http, non-localhost origin has nocrypto.randomUUID/crypto.subtle/ service workers, and an SPA that touches one of them while bootstrapping auth would otherwise fail silently and crawl as an anonymous visitor.
| Variable | Default | Notes |
|---|---|---|
SAKUDA_ENCRYPTION_KEY |
(required) | base64 of 32 random bytes. Generate with pnpm keygen or openssl rand -base64 32. |
SAKUDA_DATA_DIR |
./data (/data in the image) |
SQLite DB + per-scan work dirs live here. |
SAKUDA_MIGRATIONS_DIR |
./server/db/migrations (/app/migrations in the image) |
Drizzle migrations applied on boot. |
SAKUDA_NUCLEI_BIN |
nuclei (/usr/local/bin/nuclei in the image) |
Path to the nuclei binary. |
SAKUDA_NUCLEI_TEMPLATES |
/opt/nuclei-templates/http |
Pinned nuclei-templates checkout. |
SAKUDA_NUCLEI_DAST_TEMPLATES |
/opt/nuclei-templates/dast |
DAST (fuzzing) templates, loaded only for sites with "Active injection checks" on. |
SAKUDA_NUCLEI_MAX_MINUTES |
60 |
Hard timeout for a nuclei run, shared across its phases (DAST / signature / OpenAPI). |
SAKUDA_NUCLEI_CONCURRENCY |
25 |
nuclei parallelism (-c). Lower it for a slow or fragile target so a run wastes less time waiting on slow responses. |
SAKUDA_KATANA_BIN |
katana (/usr/local/bin/katana in the image) |
Path to the katana binary (discovery's second URL source). Its time budget is the site's spider minutes; no separate knob. |
SAKUDA_DALFOX_BIN |
dalfox (/usr/local/bin/dalfox in the image) |
Path to the dalfox binary (the reflected/DOM XSS engine). Runs only under active injection checks. |
SAKUDA_DALFOX_MAX_MINUTES |
10 |
Wall-clock budget for one dalfox run. |
SAKUDA_DALFOX_CONCURRENCY |
10 |
dalfox parallelism (workers / concurrent targets). Kept small — it scans a curated target list, not a recon dump. |
SAKUDA_DALFOX_MAX_TARGETS |
50 |
Cap on how many saved GET targets one dalfox run consumes. |
SAKUDA_HTTPX_BIN |
httpx (/usr/local/bin/httpx in the image) |
Path to the httpx binary — nuclei's pre-scan liveness probe. Missing → every target passes through, with a warning. |
SAKUDA_HTTPX_MAX_MINUTES |
5 |
Cap for the whole probe, counted inside the nuclei run's time budget. |
SAKUDA_HTTPX_PRUNE_STATUS_CODES |
(empty = off) | Opt-in: 4xx codes (never 405/429) whose targets are dropped from the nuclei list, e.g. 404,410. Off by default — see below. |
SAKUDA_ZAP_CMD |
zap.sh (/zap/zap.sh in the image) |
ZAP entrypoint. Dev on macOS: ./scripts/zap-docker.sh. |
SAKUDA_ZAP_WORKDIR |
(unset) | Container-side path when ZAP sees the scan work dir at a different path than the host (the dev wrapper mounts it at /zap/wrk). |
SAKUDA_ZAP_MAX_HEAP |
1024m |
-Xmx passed to ZAP via JAVA_TOOL_OPTIONS. |
SAKUDA_LOCALHOST_ALIAS |
(unset) | Rewrites localhost/127.0.0.1 in target URLs for all engines. |
SAKUDA_ZAP_LOCALHOST_ALIAS |
host.docker.internal |
Same, for ZAP only (falls back to SAKUDA_LOCALHOST_ALIAS). ZAP's Firefox treats this host as a secure context. |
SAKUDA_ENGINE_GRACE_MINUTES |
10 |
Grace period before an orphaned engine process is treated as failed. |
SAKUDA_JOB_RUNNER |
on |
Set off to disable the in-process scan queue (e.g. in tests). |
LOG_LEVEL |
info |
debug | info | warn | error. |
Full defaults and validation: server/config/env.ts. See .env.example for
a copyable local .env.
pnpm install
make env # .env with a fresh key (or: cp .env.example .env && pnpm keygen)nuclei, katana, dalfox and httpx run as native binaries and ZAP runs via Docker in dev:
- Install nuclei locally (e.g.
go install github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest) and clone nuclei-templates, then point.envat them:SAKUDA_NUCLEI_BIN,SAKUDA_NUCLEI_TEMPLATES. - Install katana locally (
go install github.com/projectdiscovery/katana/cmd/katana@v1.7.0, the version pinned in the Dockerfile) and pointSAKUDA_KATANA_BINat it. Without it, discovery still works on ZAP alone and reports the missing binary as a warning. - Install dalfox locally (
brew install dalfox, or download the pinned v3.2.2 release) and pointSAKUDA_DALFOX_BINat it. Without it, a scan that selects the XSS engine fails that engine run with a clear "binary not found" error; the other engines are unaffected. - Install httpx locally (
go install github.com/projectdiscovery/httpx/cmd/httpx@v1.12.0, the version pinned in the Dockerfile;brew install httpxalso works) and pointSAKUDA_HTTPX_BINat it. Without it, nuclei scans the full saved list as before and the scan carries a "probe did not run" warning. - ZAP runs inside Docker via
scripts/zap-docker.sh(no local ZAP install needed). Set in.env:Note: sending SIGKILL to theSAKUDA_ZAP_CMD=./scripts/zap-docker.sh SAKUDA_ZAP_WORKDIR=/zap/wrk SAKUDA_ZAP_LOCALHOST_ALIAS=host.docker.internaldocker runclient does not stop the ZAP container — SIGTERM is proxied through to it, and sakuda's engine runner sends SIGTERM first, so a graceful stop/timeout works as expected.
Then:
make dev # nuxt dev on SAKUDA_PORT → http://localhost:3001
make test # unit + component tests
make e2e # API e2e tests
make lint · make typecheck · make check (all three)
make juice-up # Juice Shop on JUICESHOP_PORT as a scan target · make juice-down
make juice-seed # register it in sakuda as a logged-in demo site (idempotent; re-run after juice-down/up)Juice Shop is a separate compose project (targets/juice-shop/compose.yml,
project sakuda-juice-shop) that reads the same .env, so make down /
make reset-data never touch it. Without make:
docker compose --env-file .env -f targets/juice-shop/compose.yml up -d.
Recreating the container (make juice-down) resets its accounts and tokens.
Upgrading from a checkout where Juice Shop lived in docker-compose.yml:
remove the old container once with docker rm -f sakuda-juice-shop, then
make juice-up.
(pnpm dev --port 3001, pnpm test, pnpm test:e2e, pnpm run lint,
pnpm run typecheck underneath.)
data/scans/<scanId>/<engine>/ keeps each engine's raw logs and reports
(stdout/stderr, ZAP's plan/report JSON, nuclei's JSONL output, the httpx
probe's JSONL and argv under nuclei/httpx/). The site's request headers
reach nuclei, katana and httpx through a headers.json written next to the
engine's files for the duration of the run only (0600, removed afterwards),
never on the command line. This can
contain target application data (response fragments, discovered paths) —
treat the data/ directory as sensitive, do not commit it or share it
outside the team that owns the scanned target.
Retention model: there is no per-scan expiry — a site's scan artifacts live on disk for as long as the site exists, and are deleted (best-effort) when the site itself is deleted.
A scan runs one or more engines against a site; when all of them are selected they run in this fixed order, each with its own time budget:
- ZAP API (active) — drives ZAP's active scan from the site's OpenAPI
spec (
openapiUrl/openapiJson, plus a spec sakuda generates from saved non-GET targets when active checks are on). This is where documented endpoints get injection / redirect / SQLi coverage; the image pins ZAP'sascanrulesBetaadd-on for NoSQL (MongoDB) and LDAP injection rules. - ZAP Frontend (baseline) — traditional + Ajax spiders from the seed page, a passive baseline scan, and a DOM-XSS probe over hash routes. The heaviest engine (it drives a real Firefox); size Docker's RAM as noted above.
- Dalfox (XSS) — a bounded reflected + DOM(AST) cross-site-scripting
pass over the saved GET targets. It runs only when active injection
checks are on; otherwise its engine run is recorded as skipped (not
failed), and it is off by default in the engine picker. Mining, deep scan,
stored/blind XSS, remote payload sources and external-JS fetching are all
disabled — it consumes the curated target list, not a recon dump, and only
a verified finding is reported
high(a reflection-only signal never is). It does not replace the ZAP frontend DOM-XSS probe over hash routes. - Nuclei — template scanning against the saved target paths (see Discovery).
sakuda sends attack traffic — only scan targets you own or are authorized to test. The ZAP API engine active-scans the documented endpoints on every run by design, independent of the toggle below.
Active injection checks (per site). This toggle broadens what the engines
are allowed to attack: mutating-method requests to saved targets, nuclei's
DAST fuzzing templates (GET and generated non-GET), ZAP's frontend active
scan, the Dalfox XSS engine, and form-submitting discovery (katana -aff,
ZAP postForm). With it off, those are held back. For a target that is not on your own machine, sakuda
additionally requires you to confirm you are authorized (nonLocalConfirmed)
before active checks take effect. Individual high-risk nuclei template groups
(known-CVE exploits, command-injection / RCE, denial-of-service) are each
opted in separately on the site form.
How nuclei spends its time. One nuclei run has up to three phases sharing
SAKUDA_NUCLEI_MAX_MINUTES:
- DAST — fuzzing templates on the GET targets (active checks only). Runs first: it is short and catches the high-signal cases (SQLi, command injection).
- Signature — the
httptemplate tree on the GET targets. The long phase. - OpenAPI — fuzzing the saved non-GET endpoints via a generated spec (active checks only).
Each phase reserves a floor of time for the phases still to come, so a slow
target can't let one phase consume the whole budget and leave the rest at 0%.
Lower SAKUDA_NUCLEI_CONCURRENCY for a slow target.
Liveness probe (httpx). Right before the target list is handed to
nuclei, sakuda probes it once with httpx
— one GET per target, no redirects followed, the saved scheme kept, with the
site's headers — and records what it saw under nuclei/httpx/ and in the
engine's meta.httpx (inputs, observed, kept, dropped, status counts). By
default nothing is dropped: a 404 is not proof that nuclei would find
nothing there (error pages can carry XSS, auth can hide a resource, templates
request paths of their own), so the probe only annotates. Dropping targets by
status is an opt-in via SAKUDA_HTTPX_PRUNE_STATUS_CODES (e.g. 404,410),
and even then a target is removed only on positive evidence — a target with
no line, a transport failure, or a 405 / 429 / 5xx is "unknown" and stays.
If httpx is missing, fails, or hits SAKUDA_HTTPX_MAX_MINUTES, the full list
goes to nuclei and the scan says so in a warning.
Partial runs are reported honestly. If an engine hits its time limit it is marked stopped at limit rather than shown as a clean finish, and it keeps whatever it found up to that point. The scan page shows, per engine, its status, elapsed / total time and the limit it ran under, alongside the findings, a diff against the previous scan, and a Markdown export.
Nuclei does not crawl; it scans exactly the target paths saved on the
site (/path or api:/path, relative to the base URLs). On the site page:
- Discover URLs runs ZAP's traditional + Ajax spiders from the seed path
(each for up to
zapFeSpiderMaxMinutes, with the site's headers) and dumps ZAP's site tree — every URL the crawl requested, not only the ones that raised an alert. In parallel, katana crawls the same seeds in static mode with JS-bundle parsing (-jc, depth 3, same time budget, same headers): it picks up the API paths that only exist as string literals in the app's bundles (/api/Feedbacks,/rest/user/whoami, …), which no spider ever clicks its way to. The two lists are merged (ZAP wins a tie) and each row shows itssource—spider,ajaxorkatana. katana is scoped to the front host (-fs fqdn), so anapiBaseUrlon another origin is left to ZAP; and if katana fails or is missing, the ZAP result is kept and the failure is shown as a warning. Off-origin URLs, static assets, socket.io transports, stack-trace pseudo-paths,excludePathsmatches and katana's regex artifacts (strings it scraped but never requested,%5C%22fragments) are dropped; the panel shows how many and why. - Tick the URLs you want scanned (everything not yet saved is pre-selected), add paths by hand if you like, and Save to targets. Saving appends to the list and never duplicates a path.
- Start a scan. Nuclei uses the saved list; ZAP frontend still crawls from the seed path itself.
Non-GET endpoints. With active checks on, discovery also observes the
value-free shape of the non-GET requests the app makes — a badge like
form {email, password} or json {email, password}, never the captured
values — and you approve those alongside the URLs. sakuda turns the approved
shapes into a generated OpenAPI spec so nuclei and ZAP API can fuzz those
endpoints, which is how an authenticated POST /rest/user/login becomes
reachable to injection checks without you hand-writing a spec.
Single-page apps behind a login. Header injection authenticates the
requests, but an SPA decides whether it is logged in from what it finds in
localStorage / sessionStorage / cookies — so with headers alone ZAP's
browser renders the anonymous UI and never reaches the basket, profile, …
pages or the APIs behind them. Register those values as Browser storage
on the site (kind + name + value; stored encrypted like headers, write-only
in the API) and sakuda seeds them into ZAP's browser before every Ajax
spider run (discovery and ZAP frontend) via a Selenium browserLaunched
script that is written 0600 for the run and deleted afterwards. For OWASP
Juice Shop that is localStorage token = <JWT> plus sessionStorage bid = <basket id> from POST /rest/user/login.
Discovery seeds. Discovery seed paths (one per line, hash routes such
as /#/search?q=apple are fine) start one Ajax spider each; empty falls
back to the ZAP frontend seed path.
"The app made no client-side API calls" warning. A discovery whose Ajax
spider ran but produced no app-initiated API call (only assets and HTML
pages) usually means the SPA never booted or stayed anonymous — which
otherwise looks like a clean crawl with no warnings. Discovery flags it so
repeated empty runs are not mistaken for success. Check, in order: the
secure-context alias (SAKUDA_ZAP_LOCALHOST_ALIAS, needed so
crypto.randomUUID and friends work over a loopback origin), the site's
Browser storage login values, and — importantly — that the app's own
JavaScript is not being excluded. Do not put /_nuxt/* (or your
framework's asset path) in excludePaths on a dev server: ZAP will refuse
to fetch the bundle, the SPA cannot start, and every discovery comes back
empty. excludePaths is for endpoints that end the session or change data,
not for static assets (those are dropped from the target list automatically).
A site runs one job at a time: a discovery is refused while a scan is
queued/running and vice versa. Discovery artifacts (ZAP plan, logs, the
site-tree dump; katana's seeds, logs and JSONL under katana/) live under
<data dir>/discoveries/<id> and are removed with the site.
- Target ownership verification (file/DNS challenge)
- Login automation / authenticated crawling flows
- Scheduled/recurring scans
- Notifications (email/Slack/etc.)
- Multi-user accounts or access control
MIT. The scan engines sakuda drives are separate projects under their own licenses: nuclei, katana, httpx, dalfox and nuclei-templates (MIT) are downloaded, checksum-verified, at image build time, and ZAP (Apache-2.0) is the image's base layer. None of them is vendored into this repository.

