- Python 90.2%
- HTML 9.7%
| .claude | ||
| alembic | ||
| app | ||
| docs/legal | ||
| scripts | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| alembic.ini | ||
| docker-compose.yml | ||
| pyproject.toml | ||
| README.md | ||
PTaaS
Pentest-as-a-Service: scan client web assets with open-source security tools,
use Claude to triage findings and write a report. See
/Users/marius/.claude/plans/i-want-to-create-wiggly-dragon.md for the full
project plan and phased roadmap.
Local development
Requirements: Python 3.11+, Docker.
# 1. Create a virtualenv and install dependencies
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
# 2. Copy env config, then set a real SECRET_KEY (used to sign JWTs)
cp .env.example .env
python3 -c "import secrets; print(secrets.token_hex(32))" # paste into SECRET_KEY in .env
# 3. Start Postgres + Redis
docker compose up -d postgres redis
# 4. Apply database migrations
.venv/bin/alembic upgrade head
# 5. Run the API
.venv/bin/uvicorn app.main:app --reload
# 6. Run the background worker (separate terminal — scans won't run without this)
.venv/bin/arq app.worker.WorkerSettings
Check it's alive: curl http://localhost:8000/health should return {"status":"ok"}.
Open http://localhost:8000/ in a browser for the UI (register/login, add + verify a target, run a scan, view findings) — see "Browser UI" below.
Auth
POST /auth/register— JSON body{"email": ..., "password": ...}POST /auth/login— form-encodedusername/password(OAuth2 password flow), returns a JWTGET /auth/me— protected, requiresAuthorization: Bearer <token>
Targets & domain ownership verification
Before we're allowed to scan a domain, the client must prove they control it by
adding a DNS TXT record (this is the authorization workflow — see the plan's
"Legal / safety guardrails" section). All endpoints require Authorization: Bearer <token>.
POST /targets— body{"domain": "example.com"}; creates apendingtarget and returns the TXT record name/value to add (verification_record_name,verification_record_value)POST /targets/{id}/verify— checks DNS for that TXT record; on match, marks the targetverifiedGET /targets/GET /targets/{id}— list/get targets, scoped to the authenticated user
A target only reaches verified once its exact TXT record is found — this is the gate that
scan orchestration checks before touching a domain.
Scans (Nuclei + ZAP baseline, async)
Each scan runs five tools always — Nuclei
(template-based checks), OWASP ZAP's baseline scan (passive-only:
spider + passive analysis, no attack payloads), Subfinder
(passive subdomain discovery — never touches the target itself),
testssl.sh (passive TLS analysis) and DNS & email posture
(SPF/DMARC/DKIM/DNSSEC/CAA — see below) — plus two more behind per-target opt-ins:
Nmap (Target.nmap_scan_enabled) and
Schemathesis (Target.openapi_spec_url). All results merge
into one findings list per scan, distinguished by Finding.source.
They run concurrently, so a scan's wall-clock time is roughly its slowest tool rather than the sum of all of them. Measured on the same real target and depth: 254s sequentially → 155s concurrently, with the scanning phase itself dropping from ~165s to ~105s.
Concurrency is bounded by a worker-process-wide semaphore (settings.scan_tool_concurrency,
default 4) rather than a per-scan one — otherwise N simultaneous scans would multiply into
N × tools containers on a shared host. Defaulting it to worker_max_jobs keeps the peak container
count identical to the old sequential behaviour (which was max_jobs scans × 1 container each)
while letting a single scan finish far faster. The DNS/email check doesn't consume a slot, since it
runs no container.
Because several tools now run at once, scan.current_step reports progress
("scanning (3/5 tools done)") rather than naming a single tool. Which tool failed is still
recorded in scan.error, and the per-tool breakdown is on the scan page.
All database writes stay in the coordinating coroutine — SQLAlchemy's AsyncSession is not safe
for concurrent use, so the per-tool tasks never touch it.
POST /targets/{id}/scans— enqueues a scan and returns immediately (202, statusqueued) — it does NOT wait for the scan to finish. Runs in the background worker (app.worker, via Redis/arq); pollGET /scans/{id}for the result. Returns400if the target isn'tverifiedyet — scanning an unverified target is blocked at the code level, not just in the UI.GET /targets/{id}/scans— list scans for a targetGET /scans/{id}— scan detail;statusmovesqueued→running→completed/failed. Each finding hassource,check_id(the tool's identifier for the check — Nuclei template ID or ZAP plugin ID),name,severity(normalized toinfo/low/medium/high/criticalacross both tools),matched_at,description.
If one tool fails, the scan still completes with whatever the other tool found — a Nuclei or
ZAP failure doesn't hide the other tool's findings. scan.error records which tool failed and why;
status only becomes failed if both tools fail.
The browser UI's scan pages auto-refresh every 3s while a scan is queued/running (plain
<meta http-equiv="refresh">, no JS) and stop once it reaches a terminal state.
This is a breaking change from earlier in the project: scans used to block the request and
return the finished result at 201. They now return 202 immediately with an empty findings list.
Any script/tool calling the API needs to poll instead of expecting results in the same response.
Requires Docker (scans run via docker run projectdiscovery/nuclei / docker run zaproxy/zap-stable
— both pulled automatically on first use) and the worker process running
(.venv/bin/arq app.worker.WorkerSettings) — without it, scans sit in queued forever. Nuclei's
templates are cached in a docker volume (ptaas_nuclei_templates) so only the first scan pays the
one-time template download cost.
Finding volume and triage cost
Scanners report one finding per location, so a single issue arrives many times over — ZAP reports "Cookie Without Secure Flag" once per URL — and since subdomain scanning a scan can span many hosts, multiplying that again. Left alone this drives AI triage cost linearly and makes reports unreadable.
Before triage, findings are grouped deterministically on (source, check_id, name) and only one
representative per group is sent to Claude. The verdict is then copied back onto every finding in
the group, so no data is lost: every finding row survives with its own matched_at, and the report
lists all affected locations under one issue.
Measured across four real scans in the dev database:
| Findings | Deterministic groups | Collapse | Claude triage batches |
|---|---|---|---|
| 255 | 33 | 7.7× | 7 → 1 |
| 250 | 28 | 8.9× | 7 → 1 |
| 236 | 37 | 6.4× | 6 → 1 |
| 200 | 31 | 6.5× | 5 → 1 |
Every one of them drops to a single triage batch. This also relieves report generation, which has historically timed out past roughly 100 triaged groups.
The grouping is deterministic and free; Claude's own cross-issue deduplication then composes on top of it (a verified end-to-end scan produced 77 findings → 25 deterministic groups → 23 final triage groups, with all 77 findings carrying a verdict).
Version → CVE correlation
Every scan builds a small software inventory from data the other tools already
collect, then asks NVD which CVEs are publicly known to affect those releases. No new
dependency and no paid feed — it's httpx against NVD's free API, which needs no key.
Where the versions come from. Two sources, both measured rather than assumed:
| Source | Coverage | Reliability |
|---|---|---|
Nmap -sV service banners |
opt-in per target | structured product+version; 16 of 19 real findings carried one |
HTTP Server / X-Powered-By headers |
always on | parsed out of nuclei's captured response |
Nuclei's tech-detect template is deliberately not a source. A real run returns
"matcher-name": "nginx" — the technology name with no version at all. The version is
only present in the response headers that come alongside it.
Parsing handles the shapes that actually occur in the data, not idealised ones:
product='nginx/1.18.0 (Ubuntu)' with an empty version field, OpenSSH's
8.9p1 Ubuntu 3ubuntu0.16 packaging suffix, and MariaDB's 5.5.5-10.6.22 — where the
5.5.5- is a fake prefix MariaDB sends so old MySQL clients still connect. The real
version is 10.6.22, and looking up CVEs for 5.5.5 would describe entirely different
software.
These findings are unconfirmed, and say so. The version is read from a banner, and
Linux distributions routinely backport security fixes without changing it — Ubuntu's
nginx still reports 1.18.0 long after a flaw is patched. So a match means "this
release is publicly known to be affected", not "this host is vulnerable". That's
handled by:
- Their own source (
cve_correlation), never mixed in with checks that proved something. - Severity capped one step below the CVE's own CVSS rating — a CRITICAL CVE produces
a
highfinding, a HIGH producesmedium. An unconfirmed match must not outrank a confirmed one. - The backporting caveat stated in full on every finding's description.
- A stable
check_idper product+version, so one check-level mute silences a release permanently once the customer has confirmed it's patched.
Results are cached in Postgres (cve_lookups), keyed on (product, version). This
isn't an optimisation — NVD's keyless rate limit is 5 requests per 30 seconds, so each
uncached lookup costs ~6.5s of deliberate waiting. Measured end to end: a cold run of
two products took 14.7s, the same run against a warm cache took 0.00s. The cache is
shared across all targets and users on purpose — "which CVEs affect nginx 1.18.0" is a
fact about the world, not about a customer.
A lookup failure is reported as a scan error and never cached, since caching it would turn one bad minute at NVD into a permanent "no CVEs" for that release.
Cache entries expire after 7 days, and that number is measured rather than picked. Against NVD's own data for the two cached products:
| nginx 1.18.0 | openssh 8.9p1 | |
|---|---|---|
| Median gap between new CVEs | 355 days | 151 days |
| Entries revised within 90 days | 6/6 | 20/20 |
New CVEs land on a fixed release rarely, so almost any cache age avoids missing one. But existing entries are revised constantly — every entry for both products was re-touched within 90 days, one of them the same day it was checked — and revisions change CVSS, which drives our finding severity. Seven days is short enough to pick up rescoring, far shorter than the gap between new CVEs, and matches the weekly monitoring cadence so a weekly scan never sees data older than its own interval.
Refreshes are stale-while-revalidate. If NVD is unreachable when a stale entry is refreshed, the stale data is served anyway and the scan records a warning naming its age. Reporting no CVEs because a lookup failed would be a silent false negative — strictly worse than a slightly old list. An entry that has never been cached is different: there's nothing to fall back to, so that stays a hard error.
Rows nothing has needed for 90 days are pruned by a daily cron job. No "last used" column is
needed for this, and that's worth explaining: the refresh above runs on use, so a row in active
use gets its fetched_at bumped at least every 7 days, while a row nothing has observed since it
was cached keeps its original timestamp forever. Age separates the two exactly. Deleting a row is
also not data loss — it's re-fetched the next time that software is seen, costing one lookup — which
makes the failure modes asymmetric: pruning too eagerly costs one re-fetch once, never pruning grows
without bound.
Failed refreshes are recorded in refresh_failed_at and retried no more than hourly. Without that
backoff, a stale entry plus an unreachable NVD would pay a full request timeout on every scan, for
every product — up to ~160s added to a scan that normally takes ~155s.
Muting findings (false positives & accepted risk)
A finding can be muted from any scan page, which silences it on every future scan of that target. This matters most for the recurring-monitoring product: the same false positive nagging every week is what makes people cancel.
Two reasons are offered, and the distinction is kept deliberately:
- False positive — the scanner is wrong.
- Accepted risk — the scanner is right and the customer has decided to live with it.
A compliance reviewer reading the history cares a great deal about which one it was.
Scope: check-level by default, location-level on request. This choice came from measurement, not taste. Both candidate keys were tested against five real repeat scans of one target (200-255 findings each):
| Mute scope | Key | Distinct keys/scan | Still matches in later scans | Findings silenced per mute |
|---|---|---|---|---|
| check (default) | (source, check_id) |
24-33 | 92-100% | 7.2 |
| location | (source, check_id, matched_at) |
~190 | 71-81% | 1.2 |
A location-level mute silently stops working about one time in four, because ZAP's spider crawls
a different subset of URLs on each run — /my-account/ one week, /en/my-account/ the next. For a
feature whose entire job is to stop the nagging, a mute that quietly lapses is worse than no mute:
the customer believes it's handled when it isn't. Location scope is still offered for genuinely
per-URL false positives, but it isn't the default.
Mutes are target-scoped — a false positive on one app says nothing about the same check on a different app, whose stack and risk appetite differ.
What muting affects:
- Excluded from the risk score, the narrative report and PDF, the CSV/JSON exports, and the JSON API — so all four agree with each other rather than giving four different answers.
- Excluded from AI triage, which happens before Claude is called. A dismissed finding shouldn't be paid for in tokens on every future scan.
- Excluded from the scan-completed email's headline count — reporting "40 findings" when 35 are already dismissed is the nagging in another form.
- Still shown on the scan page, in a separate collapsed "Muted" section. Hiding them entirely would leave no way to notice a mute that was applied too broadly.
Mutes are listed and removed on the target page. Removing one restores its findings and the risk score immediately (verified end to end: muting three of four findings moved a scan from 30/100 (F) to 75/100 (B), and unmuting put it back at 30/100 (F)).
Cloud findings can be muted too — but the default scope is deliberately the opposite.
For a web target the UI defaults to check-level, because ZAP's spider crawls different URLs each run and a location-level mute would keep lapsing. For a cloud account the UI defaults to resource-level, for two reasons:
- Cloud locations are stable by construction. A Prowler finding's location is
provider:account:region:resource— an S3 bucket's identity, not an artifact of how a crawler wandered. A resource-scoped mute keeps matching indefinitely. - A check-level cloud mute is dangerous. "Never tell me about public buckets again" silences every future bucket that fails the check — and catching the newly-misconfigured resource is the entire purpose of cloud posture monitoring. Mute it because one bucket is a deliberate static site, and next month's genuinely accidental public bucket is hidden before anyone sees it.
Check-level is still offered for cloud, labelled with what it actually does, and shown in the account's mute list as "whole account — hides future resources".
One finding_mutes row belongs to exactly one asset — a target or a cloud account, never both and
never neither. That's enforced by a database CHECK constraint rather than by convention, since a
malformed mute would silently match the wrong rows.
Subdomain discovery and scanning
Subfinder discovers subdomains passively (certificate-transparency logs and public APIs — it never sends a packet to the target). Those discovered hosts are then scope-checked, probed, and scanned, so discovery turns into actual coverage rather than just a list.
The pipeline is deliberately ordered discover → authorise → probe → scan:
- Scope guard (
domain_verification.is_subdomain_of) — an authorization control, not a filter. Ownership is proven by a DNS TXT record on the apex, so the apex and its true subdomains are the only hosts this service may ever contact. Subfinder reads third-party sources that anyone can put entries into, so its output is untrusted input. The check ishost == apex or host.endswith("." + apex)— a naivehost.endswith(apex)would acceptnotexample.comfor apexexample.comand send scan traffic to a domain nobody authorised. Nothing is probed before this check, so an out-of-scope host never receives even one request. - Liveness probing (
scanners/host_probe.py, purehttpx, no container) — one bounded request per host, concurrently. Any response counts as alive, including 401/403/500; only a transport-level failure means dead. Most passively-discovered hosts are dead, and scanning them would burn the whole time budget. - Bounded sweep — the surviving hosts are capped at
subdomain_scan_max_hosts(default 10) and scanned by a single multi-target nuclei run.
Only nuclei fans out across subdomains. ZAP (spider + passive analysis, minutes per host) and testssl (~2.5 min per host) are far too slow to run per-host, and the CVE/takeover/misconfiguration value lives in nuclei anyway.
The cap is reported, never silent. Every scan that discovers subdomains emits an informational
subdomain-scan-coverage finding recording how many were discovered, how many responded, how many
were scanned, how many were out of scope, and how many were dropped by the cap — so a client can
see when their attack surface wasn't fully covered. The cap matters: a real passive run against
github.com returned 40,676 subdomains.
Subfinder still runs inside the concurrent batch; only the sweep waits on its result (awaiting the same task, not re-running discovery). Serialising discovery ahead of the batch was measured to cost ~45s of wall-clock on a domain with no subdomains at all.
Subdomain takeover detection
Takeover is checked at two layers, deliberately, because neither catches what the other does:
- Nuclei's
http/takeovers/templates (in the standard depth set, run against the apex and every swept subdomain) match fingerprints in an HTTP response — "There isn't a GitHub Pages site here", "NoSuchBucket". They need the host to answer. scanners/cname_takeover.pyworks at the DNS layer and finds the case nuclei structurally cannot: a CNAME whose target has ceased to exist (NXDOMAIN). Nothing responds, so there is no fingerprint to match — and the liveness probe drops the host as dead before the sweep even runs. That's the most dangerous case: an attacker who re-registers the now-unclaimed resource serves content from the customer's own subdomain.
Because of that, the CNAME check runs across all in-scope discovered hosts (capped at
cname_takeover_max_hosts, default 200 — DNS is cheap), not just the live ones. A confirmed dangling
CNAME is reported high.
False positives are the main risk here, since "possible subdomain takeover" prompts real work. Detection fires only on NXDOMAIN:
- a CNAME that still resolves → not reported (normal CDN/hosting setup)
- a host with no CNAME at all → not reported (nothing to dangle)
- a host that doesn't exist at all → not reported (no delegation to claim)
- a resolver timeout or SERVFAIL → not reported — DNS failing to answer is not evidence, and treating it as one would fire a high-severity alarm on any transient hiccup
- CNAME target exists but has no A/AAAA → not reported (still registered, so not claimable)
Provider attribution (S3, Heroku, GitHub Pages, …) only enriches the remediation advice; detection keys off NXDOMAIN, so a dangling CNAME to an unlisted provider is still reported.
Note on wildcard DNS. Some domains (including
ptaas.dev) answer every subdomain from a wildcard record, so "does it resolve in DNS" is useless as a liveness test — it would mark every enumerated name live and fill the sweep's host cap with garbage. This is why liveness is probed over HTTP, not DNS. Wildcards don't defeat the CNAME check, since an explicit CNAME record takes precedence over a wildcard and the dangling target lives in another zone.
DNS & email posture checks
app/scanners/dns_email.py is the only scanner that runs no Docker container and sends nothing
to the target — it makes DNS queries and nothing else, the same lookups any mail server makes
routinely. That makes it the cheapest check here (seconds) and safe at every depth, so it always
runs. It answers the questions that appear on essentially every vendor security questionnaire:
| Check | Flagged when | Severity |
|---|---|---|
| SPF | missing | medium |
| SPF | +all (authorises the entire internet — worse than no SPF) |
high |
| SPF | multiple records (RFC 7208 permerror; can invalidate SPF entirely) | medium |
| SPF | ?all neutral, or no all/redirect mechanism at all |
low |
| DMARC | missing | medium |
| DMARC | p=none (monitoring only — enforces nothing) |
low |
| DMARC | record present but no parseable p= tag (inert) |
medium |
| DNSSEC | no DS record published | low |
| CAA | no CAA record (any CA may issue for the domain) | low |
| DKIM | not found on common selectors | info |
Two deliberate correctness choices:
- DKIM "not found" is
info, not a real finding. DKIM selectors are chosen freely and cannot be enumerated from DNS, so the check probes a list of selectors real providers use. A miss proves nothing, and the finding text says so explicitly. (AI triage independently agreed — it marks this finding as a false positive after reading that description.) - A total DNS failure raises an error instead of reporting everything as missing. If the resolver is unreachable, the domain isn't misconfigured — we just can't tell. Reporting "missing SPF/DMARC/DNSSEC/CAA" from one broken resolver would be four false findings.
Note there is some intentional overlap with testssl.sh, which also reports on CAA. AI triage groups the duplicate rather than showing it twice.
Scan depths
A scan runs at one of three depths (Scan.depth), all costing the same single credit:
| Depth | Nuclei templates | ZAP | Nmap ports | Typical runtime |
|---|---|---|---|---|
quick |
~374 (curated: exposed files/configs, tech + WAF fingerprinting) | baseline, 1-min spider | top 100 | under a minute |
standard (default) |
~6,700 — CVEs, vulnerabilities, misconfigurations, takeovers, default logins, SSL | baseline, 3-min spider | top 1,000 | minutes |
full |
entire upstream library (13k+) | active scan (sends attack payloads) | all 65,535 | up to hours |
standard is the default for the API, the browser UI's primary button, and all scheduled
re-scans. The reason is measured, not assumed — running both depths against ptaas.dev:
quick→ 45s, 4 findings, allinfo: three tech-detect hits and a WAF detection. It answers "what do you run," not "are you exposed." It loads zero CVE templates.standard→ 462s, 40 findings, 3,784 CVE templates loaded (verified withnuclei -tl).
quick remains available for a fast re-check, but it is not a meaningful security scan on its own
and shouldn't be sold as one.
The safety posture is identical at every depth — the potentially disruptive template categories
(dos, intrusive, fuzz) are always excluded, and only full ever sends attack payloads (via
ZAP's active scan). quick and standard are both passive on the ZAP side: spider plus passive
analysis, differing only in how long the spider runs. Depth changes coverage, never how
aggressively the target is touched.
Only full is routed to the dedicated single-concurrency queue (FullScanWorkerSettings);
standard shares the normal multi-concurrency queue with quick, so a long full scan can't block
standard scans behind it. WorkerSettings.job_timeout is sized from standard's per-tool
timeouts for that reason.
The ZAP image is pinned to zaproxy/zap-stable:2.16.1 rather than :latest — the :latest tag had
a stalled/broken layer on the registry when this was set up (an external registry issue, not
specific to this app), and a pinned version avoided it while also being more reproducible anyway.
Background jobs (arq)
- Broker: Redis (
REDIS_URL,docker-compose.ymlserviceredis) - Worker:
app/worker.py(WorkerSettings), started with.venv/bin/arq app.worker.WorkerSettings - Chose arq over RQ/Dramatiq specifically because it's async-native — the rest of this codebase
(SQLAlchemy async engine, async Nuclei subprocess calls) is async-first, and RQ/Dramatiq are
sync-first libraries that would need every job wrapped in
asyncio.run(). Arq avoids that. app/services/scanning.pyis split intocreate_pending_scan()(called from request handlers — creates aqueuedScan row, returns immediately) andrun_scan()(called by the worker — does the actual Nuclei run + persists findings). The worker's job function (app.worker.run_scan_job) just loads the Scan/Target and callsrun_scan().
Recurring scans
An hourly cron job (app.worker.run_due_scans_job → app.services.scheduling.run_due_scans) picks
up everything whose next occurrence has passed. Both web targets and cloud accounts can be put
on a schedule of daily / weekly / monthly — set it on /ui/targets/{id} or
/ui/cloud-accounts/{id}, or clear it with none.
Setting a frequency schedules the next occurrence (now + interval); it does not scan immediately —
that's what the "Run scan" button is for. Scheduled target scans always run at standard depth: a
monitoring product whose recurring scans only do technology detection has nothing to report between
runs.
Each occurrence costs one scan credit, and the same three guards apply to targets and cloud accounts alike:
- Already in flight → skipped, and
next_scheduled_scan_atis not advanced. A slow full-depth scan (or one stuck after a worker crash) must not get a second concurrent run, and must not be billed twice for what is still one overdue occurrence. Leaving it due means it fires as soon as the running scan clears, rather than silently losing a cycle. - Owner out of credits → skipped the same way, left due, retried next tick.
- Targets only: must be
verified. Cloud accounts have no equivalent check — a target's authorisation to be scanned comes from its DNS TXT proof, whereas a cloud account's authorisation is the read-only credentials the owner supplied.verifiedon a cloud account only records that a scan has already succeeded once, so requiring it would make a newly-added account permanently unschedulable.
Intervals are fixed offsets (monthly = 30 days, not calendar-month-exact) — a deliberate MVP
simplification.
A skipped scheduled scan is never silent. When a due scan can't run, the reason and timestamp are recorded on the target/cloud account and shown on its page, then cleared the moment a scheduled scan actually runs. The two cases are handled differently on purpose:
| Reason | Recorded in UI | Emails the owner |
|---|---|---|
| Out of scan credits | yes | yes — persistent, needs action, and the mail doubles as a purchase prompt |
| Previous scan still running | yes | no — transient and self-correcting |
The email fires only when the reason changes, not once per hourly tick — otherwise an unresolved problem would mail every hour and become the nagging the product is trying to avoid.
Scan credits and refunds
A credit is consumed when a scan is enqueued, so a scan that later fails would otherwise bill for nothing. A failed scan that produced zero findings is refunded automatically, and the failure email says so — a silent refund is nearly as bad as none, since the customer assumes they were charged and writes in.
Three conditions, each deliberate:
failed, not merely empty. A completed scan with zero findings is the product working correctly; "we looked and found nothing" is what was paid for. (Measured: 9 of 19 real scans completed with zero findings — none of them should be refunded.) A failed scan that still produced some findings isn't refunded either, since partial delivery is delivery.- Only what was actually charged (
Scan.credit_consumed). Admin-triggered diagnostic scans deliberately skip the charge, so refunding one would create a credit out of nothing. - Idempotent (
Scan.credit_refunded).run_scan's own failure path andrun_scan_job's crash safety net can both mark the same scan failed. This is real money, so the guard is a stored flag rather than an assumption about control flow.
Cloud scans cost the same credit and follow exactly the same rules.
Authenticated scanning
Most real application vulnerabilities live behind a login, so a target can store form-login credentials and have scans run as a signed-in user.
Credentials are encrypted at rest with the same Fernet mechanism that already protects the
Azure client secret (app.security.encrypt_secret, keyed by SECRET_ENCRYPTION_KEY) — one
mechanism, not two. The password is write-only in the UI: it is never displayed again after entry.
The login URL must be in scope for the verified target. A pre-flight sends the customer's own
credentials to that URL, so it obeys the same authorization rule as every scanner — the verified
apex or a true subdomain of it, checked with is_subdomain_of. Without that, the feature would be a
credential-exfiltration tool pointed at any host the form accepted.
Why a "logged-in indicator" is required
Because the login response cannot be trusted. Measured against this application's own login form:
| Correct password | Wrong password | |
|---|---|---|
| Login POST status | 303 | 303 |
| Session cookie set | yes | none |
| Protected page afterwards | 200, "Logout" present | redirect to login |
Both return 303. Anything that concluded "the login POST worked, so we're authenticated" would run the whole scan logged out, crawl only public pages, find little, and report that as the authenticated result — a customer paying for authenticated coverage receiving a silently unauthenticated scan. That is worse than not offering the feature.
So success is decided by fetching a page that requires a session and checking for a string that only appears when signed in ("Logout", an account name). That indicator is required, not optional.
What happens when the login stops working
Every scan re-checks the login first. If it fails, the scan still runs, but the failure is
recorded in scan.error as an auth: entry, so the result is visibly unauthenticated rather than
quietly passing for a clean authenticated scan. The target page shows the current state — login verified with a timestamp, or login not working with the reason — and offers a "Re-check login"
button, so a customer learns their credentials expired from the page rather than from a thin report.
Three authentication methods
| Method | How ZAP is told | Verified by |
|---|---|---|
| Form login | context XML, <type>2</type> |
posting the login, then finding the logged-in indicator on a protected page |
| HTTP basic | context XML, <type>3</type> with <http> |
fetching a page with the credential and without it |
| Bearer token | not an auth method — a replacer rule injecting an Authorization header |
same with/without comparison |
A bearer token is not one of ZAP's authentication methods at all; it's injected via the replacer
addon as a request-header rule. The exact -config keys were verified by starting ZAP with them and
reading the resulting rule back through its API, because a wrong key would silently inject nothing
while the scan reported itself authenticated.
Bearer and basic are verified in both directions, and that matters. A credential sent on every request can't be checked by looking at one response: a page returning 200 whether or not the header is present proves nothing about the credential — only that the page is public. So the verification page is fetched twice, and the check passes only if it is reachable with the credential and not without. If it's reachable either way, that's reported as its own diagnostic ("pick a page that actually requires authentication") rather than a pass. This is deliberately stricter than the form path, which has no credential to omit from a later request.
For that reason a verification URL is required for bearer and basic — defaulting to the site root would usually pick a public page and make the comparison meaningless.
The authenticated crawl
Once the login is verified, ZAP receives a generated context file describing the form login, the
credentials, and the logged-in indicator, plus -U naming the user to scan as. The context is only
built when the pre-flight succeeded — handing ZAP credentials already known to be broken would
produce exactly the silently-unauthenticated scan the pre-flight exists to prevent.
The XML format was obtained from ZAP itself (start the pinned image as a daemon, build a context through its API, export it) rather than from documentation, and a minimal version was round-tripped back through ZAP's importer to confirm what is actually required.
<strategy>EACH_RESP</strategy> makes ZAP re-check the logged-in indicator on every response and
re-authenticate when it stops matching, which is what keeps a long crawl signed in. The optional
logout URL is excluded from the crawl — ZAP recovers from logging itself out, but excluding it
avoids wasting the crawl budget on a logout/login cycle.
Measured against a local instance of this application, authenticated vs anonymous:
| Authenticated | Anonymous | |
|---|---|---|
| Distinct URLs crawled | 16 | 6 |
| Protected pages reached | /ui/targets, /ui/billing, /ui/settings, /ui/cloud-accounts, /ui/dashboard |
none (redirected to login) |
The context file contains the password (base64, not encrypted), so it is written into the scan's temporary directory and deleted with it when the scan finishes.
See "Crawl safety" below — the crawl no longer submits forms by default, which is what previously made it create data.
Three authentication methods
| Method | How ZAP is told | Verified by |
|---|---|---|
| Form login | context XML, <type>2</type> |
posting the login, then finding the logged-in indicator on a protected page |
| HTTP basic | context XML, <type>3</type> with <http> |
fetching a page with the credential and without it |
| Bearer token | not an auth method — a replacer rule injecting an Authorization header |
same with/without comparison |
A bearer token is not one of ZAP's authentication methods at all; it's injected via the replacer
addon as a request-header rule. The exact -config keys were verified by starting ZAP with them and
reading the resulting rule back through its API, because a wrong key would silently inject nothing
while the scan reported itself authenticated.
Bearer and basic are verified in both directions, and that matters. A credential sent on every request can't be checked by looking at one response: a page returning 200 whether or not the header is present proves nothing about the credential — only that the page is public. So the verification page is fetched twice, and the check passes only if it is reachable with the credential and not without. If it's reachable either way, that's reported as its own diagnostic ("pick a page that actually requires authentication") rather than a pass. This is deliberately stricter than the form path, which has no credential to omit from a later request.
For that reason a verification URL is required for bearer and basic — defaulting to the site root would usually pick a public page and make the comparison meaningless.
Evidence
Findings carry the proof that produced them, so a report reads as a deliverable rather than tool
output. No new capture was needed — measured across all 1,148 real ZAP findings, the proof was
already in Finding.raw and simply unsurfaced:
| ZAP instance field | Present |
|---|---|
evidence (the matched snippet) |
721 / 1148 |
param |
604 / 1148 |
otherinfo |
462 / 1148 |
request-* / response-* |
0 |
ZAP's baseline JSON report contains no request or response at all. What it does give is evidence
— the exact snippet that triggered the alert, e.g. the literal
<form class="woocommerce-form ... method="post"> behind a missing-CSRF-token finding. That is a
better artefact than a full response body: precise, already bounded, and directly quotable.
Sources that describe themselves completely (DNS/email posture, testssl, subfinder) produce no evidence block, because a synthesised one would add nothing the finding's description doesn't already say. Nmap contributes its service banner; CVE correlation the release plus the CVE list.
Evidence appears in a collapsible block beside each finding, and is passed to the report generator,
which quotes it in a fenced code block. Verified end to end: a real report from 12 real issues
produced 11 fenced evidence blocks, and the rendered 5-page PDF contains the actual form tags
and the nginx/1.18.0 banner.
Credentials are redacted before anything is rendered. Nuclei captures full requests, and since authenticated scanning landed this scanner can send
Authorization: Bearer <your token>or a session cookie on every request. A report is designed to be exported as a PDF and emailed onward, soAuthorization,Cookie,Set-Cookie,X-API-Keyand similar header values are replaced with[redacted by PTaaS]— the header name is kept, since "we sent an Authorization header" is useful while its value is not. Evidence is also truncated (600 characters by default) with the cut marked, so silently-shortened evidence can't read as though that was all there was.
Crawl safety
ZAP's spider submits the forms it finds. Verified against a local instance of this application, that meant it registered a user account and created six records in a single scan — and it did so on the anonymous crawl as well as the authenticated one, so this was never an authenticated-only concern.
Form submission is therefore off by default for every scan (spider.processform=false,
spider.postform=false). Measured on the same target, with the same login:
| Forms submitted (old default) | Forms off (new default) | |
|---|---|---|
| Records created by the scan | 1 user + 6 records | 0 |
| Authenticated pages reached | 5 | 5 |
| Distinct URLs crawled | 16 | 11 |
The coverage cost is real but modest — the authenticated pages are all still reached, and 11 URLs is still well above the 6 an anonymous crawl sees. A target can opt back in ("Allow the crawl to submit forms") when it points at staging or the forms are known to be safe.
URL exclusions are separate and cover what the form setting cannot: a plain GET can change
state on a badly-designed site (/items/5/delete). A per-target list of URL prefixes is never
requested. Exclusions apply to unauthenticated scans too — they're carried in a scope-only ZAP
context, since zap-baseline.py offers no other way to exclude URLs.
This does not make a
full-depth scan safe. Full depth runs ZAP's active scanner, which deliberately sends attack payloads, including to forms. That is what an active scan is. Use exclusions, a staging environment, and a low-privilege account.
Browser UI
Server-rendered pages (Jinja2 templates, no separate frontend build) under /ui/*:
/ui/login, /ui/register, /ui/targets, /ui/targets/{id}, /ui/scans/{id}. GET /
redirects to /ui/targets.
Auth here is a separate mechanism from the JSON API: logging in through /ui/login sets an
httpOnly session cookie (same JWT under the hood), rather than requiring a client to carry a
Bearer token. The Authorization: Bearer JSON API documented above is untouched and works
exactly as before — the two auth paths don't interfere with each other.
The UI and the JSON API call the same underlying logic (app/services/targets.py,
app/services/scanning.py) — creating/verifying a target or running a scan behaves identically
whether it's triggered from a browser form or an API call.
Admin panel
/ui/* is the client-facing self-serve flow. /admin/* is separate: an admin-only
view across all users' targets/scans, plus support actions a client can't do
themselves. Same cookie session as /ui/* — an admin just logs in at /ui/login
like anyone else and gets an "Admin" link in the nav.
GET /admin— every user's targets, with owner + statusGET /admin/targets/{id}— target detail (any owner) + a "Force verify" button that marks itverifiedwithout the DNS checkGET /admin/scans/{id}— scan detail (any owner)GET /admin/users— every user, with set password and grant/revoke admin actions
Bootstrapping the first admin (nothing can grant admin before an admin exists):
.venv/bin/python scripts/promote_admin.py you@example.com
After that, existing admins can promote others from /admin/users — the script is
only needed once, for the first one.
Security notes (see the plan's "Legal / safety guardrails" for the full reasoning):
- Force-verify, password resets, and admin grants/revokes are all recorded in
admin_audit_logs(which admin, on what/whom, when) — these bypass normal self-serve flows, so they're logged, not silent. - An admin can't revoke their own admin status (guards against locking yourself out).
- Password resets are admin-typed, not a generated one-time password — flagged as a trade-off to revisit before onboarding real paying clients (see the plan).
Project layout
app/— FastAPI application codeapp/main.py— app entrypoint, includes routersapp/config.py— settings, loaded from.envapp/db.py— async SQLAlchemy engine/session setupapp/models/— SQLAlchemy modelsapp/routers/— route modules:auth.py,targets.py,scans.py(JSON API),web.py(browser UI under/ui/*),admin.py(admin panel under/admin/*)app/services/— shared logic used by both the JSON API and the browser UI (targets.py: create/verify a target,scanning.py: run + persist a scan,admin_users.py: password reset + admin grant/revoke,admin_audit.py: audit log)app/templates/— Jinja2 templates for the browser UI (admin_*.htmlfor/admin/*)app/security.py— password hashing + JWT issuance/verificationapp/deps.py— shared FastAPI dependencies (get_current_userfor the Bearer API,get_optional_web_userfor the cookie-based UI,get_owned_target)app/schemas.py— Pydantic request/response modelsapp/domain_verification.py— domain normalization + DNS TXT ownership checkapp/scanners/nuclei.py/app/scanners/zap.py— run each tool in Docker, parse its outputapp/queue.py— arq Redis connection pool (for enqueueing jobs from request handlers)app/worker.py— arq worker entrypoint (WorkerSettings, the scan job function)
alembic/— database migrations (alembic revision --autogenerate -m "..."to create one after changing models)docker-compose.yml— local Postgres + Redis for developmentscripts/promote_admin.py— one-off CLI to bootstrap the first admin user
Running tests
.venv/bin/pytest