No description
  • Python 90.2%
  • HTML 9.7%
Find a file
marius.baliukonis 28f6b58686 phase 41
2026-09-02 11:34:09 +03:00
.claude Add Claude triage, narrative reports, and PDF export — Phase 3 complete 2026-08-26 23:49:17 +03:00
alembic phase 41 2026-09-02 11:34:09 +03:00
app phase 41 2026-09-02 11:34:09 +03:00
docs/legal phase 7-11 2026-08-31 22:40:30 +03:00
scripts Add admin panel: user management, force-verify, audit log 2026-08-26 20:10:05 +03:00
tests phase 41 2026-09-02 11:34:09 +03:00
.env.example phase 5 2026-08-30 21:15:00 +03:00
.gitignore Phase 1: FastAPI + Postgres skeleton 2026-08-26 17:03:53 +03:00
alembic.ini Phase 1: FastAPI + Postgres skeleton 2026-08-26 17:03:53 +03:00
docker-compose.yml Add OWASP ZAP baseline scan, normalize findings schema across tools 2026-08-26 21:56:55 +03:00
pyproject.toml phase 5 2026-08-30 21:15:00 +03:00
README.md phase 41 2026-09-02 11:34:09 +03:00

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-encoded username/password (OAuth2 password flow), returns a JWT
  • GET /auth/me — protected, requires Authorization: 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 a pending target 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 target verified
  • GET /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, status queued) — it does NOT wait for the scan to finish. Runs in the background worker (app.worker, via Redis/arq); poll GET /scans/{id} for the result. Returns 400 if the target isn't verified yet — scanning an unverified target is blocked at the code level, not just in the UI.
  • GET /targets/{id}/scans — list scans for a target
  • GET /scans/{id} — scan detail; status moves queued → running → completed/failed. Each finding has source, check_id (the tool's identifier for the check — Nuclei template ID or ZAP plugin ID), name, severity (normalized to info/low/medium/high/critical across 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 high finding, a HIGH produces medium. An unconfirmed match must not outrank a confirmed one.
  • The backporting caveat stated in full on every finding's description.
  • A stable check_id per 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:

  1. 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 is host == apex or host.endswith("." + apex) — a naive host.endswith(apex) would accept notexample.com for apex example.com and 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.
  2. Liveness probing (scanners/host_probe.py, pure httpx, 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.
  3. 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.py works 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, all info: 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 with nuclei -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.yml service redis)
  • 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.py is split into create_pending_scan() (called from request handlers — creates a queued Scan row, returns immediately) and run_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 calls run_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_at is 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. verified on 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 and run_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, so Authorization, Cookie, Set-Cookie, X-API-Key and 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 + status
  • GET /admin/targets/{id} — target detail (any owner) + a "Force verify" button that marks it verified without the DNS check
  • GET /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 code
    • app/main.py — app entrypoint, includes routers
    • app/config.py — settings, loaded from .env
    • app/db.py — async SQLAlchemy engine/session setup
    • app/models/ — SQLAlchemy models
    • app/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_*.html for /admin/*)
    • app/security.py — password hashing + JWT issuance/verification
    • app/deps.py — shared FastAPI dependencies (get_current_user for the Bearer API, get_optional_web_user for the cookie-based UI, get_owned_target)
    • app/schemas.py — Pydantic request/response models
    • app/domain_verification.py — domain normalization + DNS TXT ownership check
    • app/scanners/nuclei.py / app/scanners/zap.py — run each tool in Docker, parse its output
    • app/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 development
  • scripts/promote_admin.py — one-off CLI to bootstrap the first admin user

Running tests

.venv/bin/pytest