Most vulnerability scanners are easy to point at a host. You give them a URL, choose a profile, and they start sending traffic. The hard part is proving that the host was allowed to be scanned in the first place.
King Malhare is a self-hosted security assessment platform I built around one rule: no active scan runs without a valid authorization record, and that record has to survive the trip from the API to the scanner worker. The live instance is at https://kingmalhare.mukuvi.tech. The code is proprietary, so this post focuses on the architecture and the decisions behind it.
The stack
The platform runs as four services:
- A Go API built on chi and pgx
- PostgreSQL 16 as the source of truth
- Redis 7 for the job queue, cancellation and progress fan-out
- Python workers that run the scanner adapters
The console is React with Vite and TanStack Query. Caddy handles TLS in the production Compose stack.
I split the API from the workers for a specific reason. Scanners deal with hostile input in both directions. They parse output from arbitrary targets, and some of them wrap binaries that can be tricked. I did not want any of that inside the public API process.
The worker has no database URL, no published port, and no route from the internet. It runs on an internal Docker network with cap_drop: ALL, no-new-privileges, a read-only root filesystem, a writable tmpfs for /tmp, and CPU, memory and PID limits. It talks to the API with a service token. The API is the only service that can read or write the database.
Authorization is enforced in three places
A common mistake in security tooling is to check permission in the UI and trust the result. A button that is hidden is not an authorization control. King Malhare checks scope at three points.
First, the API checks before it queues anything. The asset must belong to the organization. The authorization must belong to the asset, be active and not expired. If the scanner or profile is intrusive, the authorization must explicitly allow intrusive testing. The requested parameters are checked against an allowlist. The target is resolved without scanning and rejected if it points at a metadata address or a private range that the authorization does not cover.
Second, the worker checks again before it runs a binary. Scope is copied into the job envelope. The worker rechecks the expiry, the intrusive flag and the approved target host. It resolves DNS again and checks every returned address. Link-local 169.254.0.0/16 and metadata hostnames are always rejected. Private addresses are allowed only when the authorization says so and the asset is marked as a lab or internal environment.
Third, the scanner itself runs with a fixed argument vector. There is no shell. User supplied strings never become a shell token. The worker uses exec, a per-job timeout, a maximum captured output size, and a clean environment.
The job envelope is small on purpose:
{
"scan_id": "7f2c...",
"org_id": "1a9b...",
"scanner": "nuclei",
"target": "https://app.example.com",
"scope": {
"authorization_id": "d4e1...",
"allow_private": false,
"intrusive_allowed": false,
"expires_at": "2026-12-01T00:00:00Z",
"approved_targets": ["app.example.com"]
}
}
Every field that matters is copied into the job instead of being looked up later from a mutable row. If someone edits the authorization after the job is queued, the job still carries the scope that was valid when it was created.
Findings are normalized and deduplicated
Each scanner produces its own format. Nmap gives ports and services. Nuclei gives JSONL. ZAP gives alerts. The workers map all of it into one result shape before it reaches the database.
The important field is the fingerprint:
sha256(scanner | normalized title | endpoint | cve-or-cwe)
That fingerprint lets a re-run update last_seen without creating a duplicate. first_seen stays intact, and the triage status survives. A finding that was accepted as a false positive stays accepted after the next scan.
The adapters are deliberately conservative. A missing security header becomes a finding only when the header is actually absent. A TLS issue becomes a finding only when the handshake proves the condition. A path is reported only when the response is not a 404. If a binary is not installed, the adapter returns unavailable with the path it checked. It does not invent a result to fill the dashboard.
That last rule matters more than it sounds. A security tool that reports fiction is worse than a tool that reports nothing, because it trains the operator to stop checking.
Queue, progress and cancellation
Redis holds the job queue. The API pushes a JSON job and a worker pops it. Before running anything, the worker claims the job through the API, and the API performs a conditional update from queued to running. Two workers cannot claim the same job.
Progress is sent back through the API and published on Redis. The browser receives it over Server Sent Events. The UI also polls every 15 seconds as a fallback, because SSE connections drop and a progress bar that freezes is not useful.
Cancellation has two levels. A single scan sets a cancel key and the worker checks it between phases. The organization level emergency stop sets a kill flag and marks every queued or running job cancelled before a worker picks up more work.
The API also has a stale job reaper. If a worker dies mid-job, the scan is marked failed with an actionable message. It is not automatically re-queued. Re-running an intrusive scan because a worker crashed is not a safe default.
What I would do differently
The scanner integration was not the hard part. The hard part was making the authorization trail, the evidence and the report line up.
A few lessons from the build:
Put scope enforcement in the API and repeat it in the worker. Do not make the worker trust the queue. The queue is a transport, not a policy engine.
Make adapters conservative. A smaller number of findings that an operator trusts is better than a large number that has to be manually filtered. False positives cost more than missed checks in a tool like this.
Do not add a queue unless you need one. Cameron Bennett wrote about reaching for Postgres before a queue, and it made me recheck the decision. I kept Redis because I needed multiple workers, per-scan cancellation and progress fan-out. If the requirement had only been "run this later", Postgres would have been enough.
Tenant isolation belongs in the query layer. Saint Zero Day's post on multi-tenant self-hosted security platforms covers this well. Hiding another organization's asset in the UI does nothing if the query still returns it. Every business query in King Malhare is scoped by org_id, and the tests include cross-tenant reads and writes.
The report is part of the product. A finding without evidence, impact and remediation guidance is just a line in a table. The report engine embeds the same data the console shows, including screenshots where the scanner captured them.
What is next
The current native cloud posture checks cover AWS. Azure and GCP have the adapter path and documented CLI wrappers, but native SDK checks are still on the list. I also want to keep improving the report templates and add more lab fixtures so adapter changes are tested against known conditions rather than hand written expectations.
King Malhare is live at https://kingmalhare.mukuvi.tech. The scanner is the easy part. The authorization trail, the evidence and the report are what make the result useful.
Related reading
- More Than Just a Scanner: Architecture of a Self-Hosted DAST Engine for SPAs and CI/CD by Arturo Enrique Mata Garcia
- One install, many customers: building airtight multi-tenancy into a self-hosted security platform by Saint Zero Day
- Go, Postgres, and the queue I didn't need by Cameron Bennett
Top comments (0)