Nullhawk

The evidence-first offensive security workbench. Nullhawk is a web and API security testing platform for authorized penetration testing and security research: an intercepting proxy, a repeater, an intruder, a passive and an evidence-driven active scanner, authorization and whole-app access-control testing, a scope-checked crawler, an out-of-band collaborator, recon and offense tooling, and premium mobile / cloud / desktop-binary analysis — with a command-line interface and a desktop app over the same engine.

⚠️ Authorized use only. Point Nullhawk at systems you own or have explicit written permission to test. The proxy decrypts TLS, the scanner and intruder send traffic, and the collaborator receives callbacks — all hostile acts against a system you are not authorized to test. You are responsible for staying inside your engagement's scope and rules.

What makes it different

Most scanners optimize for finding more. Nullhawk optimizes for findings you can trust:

  • Evidence over assertion. A finding carries the request and response behind it. A report quotes the traffic that proves each claim, or prints the citation as missing — never a bare severity label.
  • Separate "saw" from "proved". Passive analysis produces leads; only an active experiment turns a suspicion into a verified finding. A passive check cannot overclaim, which is enforced by the type system, not by discipline.
  • Zero false positives as a design goal. Every detector refuses cleanly on a non-vulnerable target. There is a benchmark harness and a CI proof behind that claim.
  • Manual-first. Automation surfaces high-signal leads with a one-step confirm path; the judgment stays with the tester. Nullhawk assists a human, it does not replace one.
  • Safe by construction. Nothing is sent that you did not ask for. Scope is enforced at the transport chokepoint. The plan is shown before anything goes out. Credentials are redacted the moment traffic is stored.

How this documentation is organized

  • Getting started — install, set up a machine, and understand the free/Pro split.
  • Tutorials — hands-on, start-to-finish walkthroughs of the real workflows: a first engagement, finding an IDOR, evidence-first scanning, recon, the premium modules, and CI.
  • User guide — topic-by-topic reference for every capability, from capture to reporting.
  • Reference — the full CLI command list, the licensing tiers, and pointers into the developer/internals docs.

New to Nullhawk? Start with Install, then walk through Tutorial 1.


Nullhawk is licensed under AGPL-3.0-or-later. Test only what you are authorized to test.

Install

Nullhawk ships two front ends over one engine: a desktop app for interactive work and a CLI for the terminal and CI. Install either or both.

Download the installer for your OS from the project's releases:

  • Windows — Nullhawk_<version>_x64-setup.exe (NSIS installer) or the .msi.
  • macOS — Nullhawk_<version>_universal.dmg (Intel + Apple Silicon).
  • Linux — .deb, .rpm, or the .AppImage.

Unsigned builds. 1.0.0 installers are unsigned, so the OS will warn ("unknown publisher" / Gatekeeper). On Windows choose More info → Run anyway; on macOS right-click the app → Open the first time. Code signing is planned for a later build.

CLI

Download the nullhawk binary for your OS from the same release, or build from source.

cargo build --release -p nullhawk-cli
# binary at target/release/nullhawk  (nullhawk.exe on Windows)

The CLI is a terminal tool — run it from a shell, not by double-clicking. Verify the install:

nullhawk --version
nullhawk --help

Building from source

You need a recent Rust toolchain (MSRV 1.88) and, for the desktop app, Node + pnpm for the frontend.

# 1. Rust
winget install --id Rustlang.Rustup -e                         # Windows
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh  # macOS/Linux

# 2. Windows only: MSVC C++ build tools, including the Windows SDK
winget install --id Microsoft.VisualStudio.2022.BuildTools -e --override \
  "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"

# 3. Frontend (desktop app only)
corepack enable && pnpm -C frontend install

Then build the CLI with cargo build --release -p nullhawk-cli, or the desktop app with pnpm -C frontend build followed by cargo build --release -p nullhawk-desktop --features custom-protocol.

Windows build traps. Don't build from Git Bash — its /usr/bin/link.exe shadows MSVC's linker. Build from PowerShell or a Developer Command Prompt. And install the VCTools workload with --includeRecommended so you get the Windows SDK. Both are written up in the developer docs.

Next: set up a machine for testing.

First run & setup

Everything in Nullhawk happens inside a project — a directory holding captured traffic, scope, identities, findings, and settings. The whole engagement travels as one folder.

The one-command path

nullhawk setup ./engagement

setup creates the project, generates the interception certificate authority (CA), and installs it into your user trust store so HTTPS can be decrypted. It asks before touching your trust store — installing the CA is the most consequential thing Nullhawk does, and it never happens as a side effect. To set everything up but leave trust alone, add --no-trust.

Or, a command at a time

nullhawk project init ./engagement            # create the project
nullhawk ca --dir ./engagement --install      # create + trust the interception CA (asks first)
nullhawk scope add ./engagement example.com   # declare what you're allowed to touch

In the desktop app

Open the app and use the Setup tab: choose or create a project folder, install the CA (a button, with the same confirmation), and declare scope and identities. The rest of the app unlocks once a project is open.

The CA, explained

The CA is a per-installation certificate authority. When the proxy intercepts an HTTPS connection it mints a leaf certificate for the target host on the fly, signed by this CA — so your browser trusts the connection and Nullhawk can read it. Manage it with:

nullhawk ca --dir ./engagement             # show the CA, its fingerprint, and trust status
nullhawk ca --dir ./engagement --install   # trust it (asks first)
nullhawk ca --dir ./engagement --export ca.pem   # write the cert + trust instructions
nullhawk ca --dir ./engagement --uninstall       # untrust and delete

Firefox keeps its own trust store and ignores the system one — Nullhawk detects this and tells you. Import the exported CA under Firefox's Settings → Privacy & Security → Certificates.

Windows note. The CA installs into the user store (no admin needed) and is verified by asking the platform, not by trusting an exit code. It removes cleanly with --uninstall.

With a project created, scope declared, and the CA trusted, you're ready to capture traffic — or jump straight into Tutorial 1.

Licensing: free and Pro

Nullhawk runs at the free tier with no licence. A Pro licence unlocks the automation that sends significant traffic and the premium analysis modules; Enterprise adds team features. The free tier is a complete manual workbench, not a crippled demo.

What each tier includes

CapabilityFreePro
Intercepting proxy, CA, send✅✅
History, repeater, match & replace, decoder, WebSocket✅✅
Passive scanner + all recon (fingerprint, surface, secrets, externals, retire)✅✅
Identities, identifiers, single-request authorization replay (authz)✅✅
Crawler, authenticated browse, API import, sequencer, DOM-XSS, extensions✅✅
Findings, PoC, Markdown/HTML/JSON reports✅✅
Active scanner — sends its own traffic (scan active)—✅
Whole-app access-control sweep (authz-sweep / Access Control view)—✅
Offense probes — discover, xxe, upload, header-OOB, graphql, llm—✅
Intruder without a throttle (fuzz, race)—✅
SARIF export (for CI)—✅
Snapshots and retest comparison—✅
Premium modules — mobile, cloud, desktop-binary analysis—✅
Shared team projects, audit log, SSO (Enterprise)—Enterprise

The crawler is deliberately free: discovery behind a paywall would push people toward running an unknown crawler against a target they are testing.

Activating a licence

nullhawk license show                     # current tier, licensee, expiry
nullhawk license activate your.license     # verify and install a licence file
nullhawk license trial                     # start a one-time Pro trial on this machine

In the desktop app, use the Licence tab to activate a file or start a trial, and to see the active tier.

Without a valid licence, a Pro-gated command stops with a message naming the feature and the tier it needs — nothing is silently degraded or quietly does nothing. In the desktop app, a gated view shows a "needs the Pro tier" callout and disables its send control, so the boundary is visible before you click.

See the Licensing & tiers reference for exactly which command maps to which feature.

Tutorial 1 · Your first engagement, end to end

This walkthrough takes you from an empty folder to a handed-over report, touching the core loop every engagement uses: set up → capture → review → scan → report. It uses the CLI; the desktop app does the same loop with tabs.

Point this only at a target you are authorized to test. A good practice target is one of the vendor-published deliberately-vulnerable apps (e.g. PortSwigger's ginandjuice.shop).

1. Set up the engagement

nullhawk setup ./engagement
nullhawk scope add ./engagement ginandjuice.shop
nullhawk scope add ./engagement '*.ginandjuice.shop'

setup creates the project and installs the interception CA (it asks first). Scope is the safety rail: the scanner, crawler, and automated senders refuse any host you haven't declared.

2. Capture some traffic

Start the proxy, pointed at the project:

nullhawk proxy --project ./engagement --listen 127.0.0.1:8080

Set your browser's HTTP/HTTPS proxy to 127.0.0.1:8080 and browse the target as a user would — log in, click around, submit a search. Every exchange lands in the project. Prefer to decrypt only the target and tunnel everything else:

nullhawk proxy --project ./engagement --listen 127.0.0.1:8080 --only ginandjuice.shop

Stop the proxy with Ctrl-C when you've exercised the app. Don't have a browser handy? Drive a headless one instead:

nullhawk browse ./engagement --url https://ginandjuice.shop/ --max-pages 40

3. Review what you captured

nullhawk history ./engagement
nullhawk sitemap ./engagement          # host → path coverage tree

Open one exchange, or resend it with edits in the repeater:

nullhawk history ./engagement --body <request-id>     # dump a response body
nullhawk repeat  ./engagement <request-id> --edit     # edit in $EDITOR and resend

4. Build the passive picture

Passive analysis reads only what you captured and sends nothing, so it's safe any time:

nullhawk recon ./engagement            # stack, surface, third-party code, secrets, vuln libs
nullhawk scan passive ./engagement     # security-header / cookie / TLS observations + hypotheses

Passive output is observations (facts, filed as leads) and hypotheses (suspicions a passive pass can't settle). A lead is something to check, never a declared vulnerability.

5. Settle suspicions with the active scanner (Pro)

The active scanner runs experiments to settle the hypotheses the passive pass raised — the only scan pass that sends traffic. Always dry-run first:

nullhawk scan active ./engagement --dry-run   # show exactly what would be sent, to which hosts
nullhawk scan active ./engagement             # run it (asks to confirm)

Nothing is invented: an active run only tests hypotheses a passive pass already raised, so run scan passive first.

6. Read the findings and hand over a report

nullhawk findings ./engagement                       # list with severity, confidence, status
nullhawk poc      ./engagement <finding-id>          # compile a finding into runnable steps
nullhawk report   ./engagement --format html --output engagement.html

The report quotes the request and response behind every claim, states coverage honestly (what was tested, not a clean bill of health), and keeps credentials redacted.

Where to go next

Tutorial 2 · Finding broken access control (IDOR)

Broken object-level authorization (IDOR/BOLA) is the vulnerability class where one user can reach another user's data. Nullhawk tests it two ways, and this tutorial covers both: the single-request check (authz) and the whole-app sweep (authz-sweep, a Pro feature, the "Access Control" view in the desktop app).

The key idea: Nullhawk establishes ownership from real traffic — whose session carried a value — and then has every other identity try to reach it. Nothing is guessed, and an anonymous control tells "User B read User A's record" apart from "that page is public".

Setup: capture two accounts

You need at least two identities, each with some captured, authenticated traffic. Capture each user's own session through the proxy:

nullhawk setup ./engagement
nullhawk scope add ./engagement app.example.com

# Proxy running on :8080 — log in as alice in the browser, use the app, then as bob.
nullhawk proxy --project ./engagement --listen 127.0.0.1:8080

Then declare the two identities, naming the cookie that identifies a session so attribution is exact:

# The credential is the full Cookie header value; pass it via env, never on the command line.
export ALICE='session=<alice-session-cookie>'
export BOB='session=<bob-session-cookie>'
nullhawk identity add ./engagement alice --kind cookie --session-cookie session --from-env ALICE
nullhawk identity add ./engagement bob   --kind cookie --session-cookie session --from-env BOB
nullhawk identity list ./engagement

You do not need to declare which objects each user owns. Ownership is inferred from whose session carried each value — that's the point. (You can still declare it with --owns for extra evidence.)

Option A — one request, every identity (authz, free)

When you have a specific request to test — say GET /order?id=1001 captured as alice — replay it as everyone and compare:

nullhawk authz ./engagement <request-id>                 # replay as every other identity + anon
nullhawk authz ./engagement <request-id> --as-identity alice --verify

Nullhawk sends the request as each identity plus an anonymous control, compares the responses structurally, and files a finding only when a non-owner reaches the owner's resource and the anonymous control was refused. A 200 on its own is never a finding.

Option B — sweep the whole app (authz-sweep, Pro)

This is the automation no tester has time to do by hand: across the whole project, infer which identity owns each object identifier, then have every other identity try to reach each object it does not own.

nullhawk authz-sweep ./engagement --budget 40 --verify

A clean run against a two-account target with a real IDOR looks like this:

Ownership
     2 suggested identifier(s) considered
     2 attributed to a single identity (owned — the sweep's input)
     0 seen in more than one identity's traffic (shared or public — not swept)
     0 owner unknown
  across 2 identit(ies)

Sweep
  2/2 object-bearing endpoint(s) swept
  0 constructed attempt(s) sent (budget 40)
  6 cross-identity replay request(s) sent (the owner's own request as each other
    identity, with an anonymous control)

2 lead(s), most serious first:

  [medium/confirmed] Broken object-level authorization in GET /order?id=1001
  http://app.example.com/order?id=1001 was replayed as bob, an identity that should
  not be able to reach alice's object, and the application served it anyway.
  1. Send ... as alice — recorded as req_...
  2. Send the same request as bob — recorded as req_...
  3. Compare: nullhawk repeat <project> req_a --diff req_b

Each lead cites two real, replayable requests. Useful flags:

  • --host <host> — limit the sweep to one host.
  • --budget <n> — total request ceiling, spread across endpoints (default 200).
  • --verify — send each lead a second time (Firm → Confirmed) before reporting it.
  • --no-save — preview without writing leads into the project.

How the sweep decides

The sweep runs two cross-identity tests per object-bearing endpoint:

  • Replay — send the owner's own captured request as each non-owner. This catches the classic IDOR where the id is in the URL and each user fetches their own object.
  • Construction — substitute one identity's object into a different endpoint. This catches the case where a non-owner's request can be made to ask for someone else's object.

Both use the same scope-guarded send path as the rest of Nullhawk, and both carry an anonymous control.

In the desktop app

Open Testing → Access Control (the shield-with-keyhole icon). With a project open and two identities declared, set a budget, tick Verify, and click Run sweep. You'll see the Ownership panel, the coverage line, and the ranked leads — each with repro steps. The view is Pro; on Free it shows a "needs Pro" callout.

Read it back

nullhawk findings ./engagement
nullhawk poc      ./engagement <finding-id>
nullhawk report   ./engagement --format html --output access-control.html

Tutorial 3 · Evidence-first active scanning

Nullhawk's scanner is built around one discipline: a check that merely suspects something cannot file a finding — only an experiment can. This tutorial walks the full ladder from a passive observation to a verified, reproducible finding, and shows why each step exists.

The ladder

observation ──► lead            (passive: a fact, capped at "reported" confidence)
hypothesis  ──► (waits)         (passive: a suspicion it cannot settle without sending)
experiment  ──► verified finding (active: a hypothesis an experiment established)
  • Passive reads captured traffic and sends nothing. It emits observations (filed as leads) and hypotheses (suspicions).
  • Active sends experiments to settle those hypotheses. It invents nothing — it can only test what a passive pass already raised.

1. Capture, then run passive

nullhawk scan passive ./engagement

Say the target reflects a query parameter. Passive can see the reflection in captured traffic and raise a hypothesis like input.reflection — but it won't claim XSS, because whether the reflection is exploitable depends on sending a crafted probe and seeing what survives.

List what this build can look for, and which checks send traffic:

nullhawk detectors

2. Dry-run the active plan

Plan::prepare answers "what would this do?" without sending — a dry run is literally the sending function not being called:

nullhawk scan active ./engagement --dry-run

You'll see each planned experiment, which host it targets, how many requests per host, and any hypothesis that will be skipped (out of scope, lost its traffic, etc.). Read this before every real run.

3. Run it

nullhawk scan active ./engagement           # asks to confirm before sending
nullhawk scan active ./engagement --yes      # non-interactive (CI)

The scheduler sends one host's requests sequentially with a pause between them, runs different hosts concurrently, and enforces a request ceiling. A run that stops early (ceiling hit, or a session expired) says so above its results — an incomplete run is never dressed up as a clean one.

Useful flags:

  • --host <host> / --detector <id> — scope the run to one host or one check.
  • --max-requests <n> — raise or lower the ceiling.
  • --quiet — drop the "loud" checks (an OPSEC noise control).
  • --renew / --refresh — keep a session alive across a long run (see Scope, programme & identities).

4. Read the result as evidence

nullhawk findings ./engagement
nullhawk findings ./engagement --show <finding-id>

A verified finding tells you which character came back unencoded and where, or which field differed between two identities — a sentence you can go and check, not a percentage. Checks report refutations too: "tested, and what came back does not support the suspicion" is a result, and it's why a clean scan of a deliberately-vulnerable site is either right or a bug worth investigating.

5. Turn a finding into proof

nullhawk poc ./engagement <finding-id>

The PoC is built from the exchanges the finding already cites — curl where curl can express the request, a stated reason where it cannot. A reader supplies two values and runs the whole thing.

Backing the zero-FP claim

Nullhawk ships a benchmark harness that scores the scanner against labelled targets and fails on any false positive:

nullhawk benchmark suite.json      # TP / FP / misses; any FP → non-zero exit (CI-gateable)

A deterministic CI proof (core/active/tests/zero_false_positive.rs) runs the whole response-settled suite against a safe mock app and asserts it files nothing — with a companion test that flips one behaviour so the zero result isn't vacuous.

Next: automate all of this in a pipeline → Tutorial 6: CI.

Tutorial 4 · Reconnaissance: mapping a target

Before you attack anything, you map it. Nullhawk's recon reads the traffic you've already captured and tells you what the target is built from, what it takes as input, what third-party code it trusts, and what it leaked — all passively (no traffic sent), so it's free and safe at any point. One active discovery step finds what the crawler can't.

One pass for the whole picture

nullhawk recon ./engagement

recon runs the full passive picture in one go: the technology stack per host, the input surface, third-party code, secrets, and known-vulnerable libraries. Scope it to a host with --host api.example.com. Each sub-view is also its own command when you want just one:

Stack fingerprinting

nullhawk fingerprint ./engagement

Names the server, language/framework, CMS, and edge/CDN behind each host — and cites the header, cookie, or <meta generator> that named it. Evidence, not a guess.

The input surface

nullhawk surface ./engagement

Inventories the parameters, body fields, cookies, and dynamic path segments seen per endpoint (collapsing /users/1 and /users/2 into /users/{id}). Every name here is a place the application read input you can influence — the checklist a test aims at.

Third-party code & supply chain

nullhawk externals ./engagement      # cross-origin scripts/styles loaded without integrity=
nullhawk retire    ./engagement      # known-vulnerable JS libraries, by version

externals flags Subresource-Integrity gaps (a supply-chain exposure); retire flags JavaScript libraries whose version is below a known fix.

Secrets left in responses

nullhawk secrets  ./engagement       # keys/tokens/private keys in any captured response body
nullhawk jsminer  ./engagement       # mine captured JS for secrets AND undiscovered endpoints

secrets scans every response body; jsminer focuses on JavaScript and also extracts endpoint paths referenced in the code that you haven't captured yet — feed those back into the crawler to widen coverage.

Finding the unlinked (active — Pro)

The crawler only sees what's linked. discover probes a curated list of high-signal paths nothing links to — .git, .env, backups, admin panels, swagger.json:

nullhawk discover https://app.example.com/        # Pro; sends GETs; confirm before sending

It learns the host's "not here" shape from decoy requests first, then reports only the paths whose answer departs from it — so a server that 200s everything doesn't drown you in false hits.

Put it together

A typical recon flow: capture or crawl to get traffic in → recon for the full passive picture → feed jsminer --endpoints and discover hits back into the project → re-run surface to see the widened input surface → move on to scanning and access control.

Tutorial 5 · Mobile, cloud & desktop-binary analysis

Beyond the web, three premium modules extend Nullhawk to the artifacts and infrastructure behind an app. They live in the desktop app (Pro tier) and share the philosophy of the rest of the tool: read what you give them, report with evidence, send nothing you didn't ask for.

All three are Pro (Feature::PremiumModules). On Free, the view shows a "needs the Pro tier" callout and the analysis action is disabled.

Mobile (Android & iOS)

Open the Mobile tab. A phone's traffic is just traffic, so the first job is pointing the device at the proxy:

  1. Point the app at the proxy. The Mobile tab shows your machine's LAN IP and runs a small on-LAN server so the phone can fetch the CA and setup steps from one URL — no adb push or emailing certificates. Set the device's Wi-Fi proxy to your machine, trust the CA, and the app's API requests land in History like any website's. (Certificate-pinned apps need the pin defeated off-tool.)
  2. Static analysis. Drop an APK or IPA. Nullhawk unpacks it and runs the shared secret & endpoint miner over everything inside — DEX, resources, plists — surfacing hardcoded keys, URLs, and versioned libraries without a manual decompile.

The capture plumbing (the proxy and the on-LAN onboarding server) is free; the static-analysis deliverable is the Pro part.

Desktop / thick-client apps

Open the Desktop tab. Like mobile, a desktop app talks to a backend — point its HTTP proxy at Nullhawk and its API flows into History, where the repeater, scanner, and authz all work on it.

What's specific to desktop is the artifact:

  • Electron — choose the app's app.asar (usually under resources/). Nullhawk unpacks it, runs the secret/endpoint miner, and decodes the Electron security posture: nodeIntegration, contextIsolation, webSecurity, sandbox — the BrowserWindow settings that decide whether an XSS in the app's web content becomes code execution on the host.
  • Native binaries — choose a .exe, .dll, ELF, or Mach-O. Nullhawk identifies the format, bits, and architecture and mines strings from the raw binary (high-signal, noisier than an asar).

Cloud key validation & blast radius

Open Toolkit → Cloud key check. Found a cloud credential in a response, a mobile package, or an asar? This tells you whether it's live and what it can reach, across AWS, GCP, and Azure:

  • Validate — confirm the key is active and resolve the identity behind it (who it is, which account).
  • Blast radius (AWS, GCP) — map what that identity can actually do, so a leaked key becomes a scoped finding ("this key can read these buckets") rather than a vague "a key leaked".

These send authenticated requests to the cloud provider's own APIs — only run them on credentials you are authorized to test.

Where the output goes

Mined secrets, endpoints, and library versions feed the same workflow as the rest of the tool: endpoints widen your coverage, secrets become leads, and a validated cloud key with its blast radius is evidence you can put straight into a report.

Tutorial 6 · Automating in CI

Nullhawk runs a whole engagement from one declarative YAML file, non-interactively, and can fail the build when findings cross a severity you name. Pair it with SARIF export and the findings show up in your CI's security tab.

The plan file

A plan declares a project, the scope to set before anything runs, an ordered list of steps, and an optional fail_on gate:

project: ./engagement
scope: ["*.target.com"]
steps:
  - { step: import, openapi: ./openapi.yaml, base: https://api.target.com, send: true }
  - { step: crawl,  seeds: ["https://target.com/"], max_requests: 500 }
  - { step: scan }
  - { step: report, format: sarif, output: ./findings.sarif }
fail_on: high

Run it:

nullhawk run plan.yaml

The plan is what consents

Every step that sends traffic runs as though --yes was given — writing the step into the plan is the decision the interactive prompt would otherwise ask for. Paths resolve relative to the plan file, so a plan checked into a repo works wherever it runs.

The steps

StepFieldsDoes
importopenapi / graphql, base, url, send, include_writes, insecureTurn an API spec into traffic; send: true fetches it
crawlseeds, max_requests, max_depth, insecureWiden coverage from seed URLs (scope-checked)
scanhost, limitRun the active scanner over what's captured
reportformat (markdown/html/json/sarif), output, severityWrite the report

fail_on: high makes the run exit non-zero if any finding at or above high exists — the build-breaking gate. SARIF export and the active scan step are Pro.

In a GitHub Actions workflow

name: Security scan
on: [workflow_dispatch]
jobs:
  nullhawk:
    runs-on: ubuntu-latest
    permissions:
      security-events: write   # to upload SARIF
    steps:
      - uses: actions/checkout@v4
      - name: Run Nullhawk
        run: nullhawk run plan.yaml
        env:
          NULLHAWK_LICENSE: ${{ secrets.NULLHAWK_LICENSE }}   # Pro, for active scan + SARIF
      - name: Upload findings
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: findings.sarif

A reusable GitHub Action and more detail live in the repo's ci/github-action and docs/ci-integration.md.

Why SARIF

Each SARIF result's partialFingerprints is the finding's stable id, so a re-run correlates against the same finding and a "fail only on new findings" gate needs no fuzzy matching. A lead is emitted at note level and never turns a build red — only verified findings do.

Retest gating with snapshots (Pro)

Because findings carry the detector and version that produced them, a snapshot diff can tell a regression apart from a finding that only "disappeared" because a check was rewritten:

nullhawk snapshot take ./engagement --label "release-1.2"
# ... later ...
nullhawk snapshot diff ./engagement --since "release-1.2"

See Findings, proof & reporting for the snapshot workflow.

Core concepts

A handful of ideas run through every part of Nullhawk. Understanding them makes the rest of the tool predictable.

Project

A project is a directory holding captured traffic, scope, identities, declared objects, findings, settings, and extensions. It is portable — the whole engagement travels as one folder. Every command that reads or writes engagement data takes the project path as an argument.

Scope

Scope is the set of hosts you are authorized to touch. In-scope declarations gate the scanner, the crawler, and every automated sender; exclusions always win over inclusions. Scope is enforced at the transport chokepoint, not sprinkled through the code — a request to an undeclared host is refused before it goes out. Scoping is a safety rail, not a convenience.

The confidence ladder

Nullhawk keeps what it saw separate from what it proved:

  • Observation → lead — a fact from passive analysis (e.g. "no HSTS on an HTTPS response"). Capped at reported confidence: a lead to check, never a declared vulnerability.
  • Hypothesis — a suspicion a passive pass raised but cannot settle without sending something (e.g. a possibly-reflected parameter). It waits for a verifier.
  • Verified finding — a hypothesis an active experiment established, with the evidence attached.

This is enforced by the type system: a detector is handed a value that only a completed verification can produce, so a merely-suspicious check cannot file a finding — the code doesn't compile. Confidence is derived from what the experiment showed, not chosen by the detector.

Evidence and citations

A finding carries the exchanges behind it. Reports quote the request and response that prove each claim; a citation the project can no longer resolve is printed as missing, with the reason, rather than as a dead link. "Identical" is a result, not an empty output; a refutation ("tested, and it does not support the suspicion") is a result too.

Credentials are redacted at rest

When an exchange is stored, credential request headers and Set-Cookie values are replaced with a redaction that records the length of what was removed. Checks, reports, and extensions therefore cannot leak a session token — and a reader can tell a hidden credential apart from one that was never sent. The Secret<T> type has no serializer, so leaking a credential through serialization is a compile error.

Identities

An identity is a persona the project tests as — admin, user-a, anonymous — with a credential (bearer token, cookie, or header). Identities drive authorization testing: Nullhawk replays requests as each one and compares what came back. See Authorization & access control.

One engine, two front ends

The CLI and the desktop app are the same crates — there is no second engine. Anything the window does, the CLI does, with the same scope guard, the same evidence model, and the same results. The interface and engine negotiate a version on start; if they disagree the app says so rather than risk showing you a request decoded under the wrong contract.

Nothing is sent that you didn't ask for

A scan happens because you invoked one. The active plan is shown before anything goes out (--dry-run is literally the sending function not being called). A non-interactive stdin answers no. Raw requests still pass the scope guard. This sentence is a design invariant, not a feature.

Capturing traffic

Everything Nullhawk reasons about starts as captured traffic. There are four ways to get it into a project: the proxy, one-off sends, the crawler, and API import. This page covers the proxy and send; see Coverage for crawl, browse, and import.

The proxy

Point a browser (or any client) at Nullhawk and it captures the traffic:

nullhawk proxy --project ./engagement --listen 127.0.0.1:8080

Configure your browser/system to use 127.0.0.1:8080 as its HTTP/HTTPS proxy, and install the CA (nullhawk ca --install) so HTTPS can be decrypted. Useful flags:

  • --only <host> — decrypt only this host; tunnel everything else untouched (the safer posture — your mail and bank stay private).
  • --exempt <host> — never decrypt this host (use for certificate-pinned apps); accepts *. wildcards.
  • --in-scope-only — record only in-scope traffic.
  • -k, --insecure-upstream — don't verify the target's certificate (staging with self-signed certs).
  • --attach-headers — put the project's attached headers on in-scope requests your browser makes (see Scope, programme & identities).

The proxy speaks HTTP/1.x and HTTP/2, demultiplexing each of the browser's h2 streams and forwarding upstream over whatever protocol the origin negotiated — recording an h2→h1 downgrade as an explicit event when it happens.

What the proxy records

Each exchange is stored with both the wire form (the bytes that arrived, e.g. the gzip stream) and the decoded form (the JSON inside it), plus TLS details and any framing quirks. A deliberately-malformed Content-Length is recorded as-is, not silently corrected — the whole point of a security proxy is to preserve the ambiguity a normalizing client would erase.

WebSocket

WebSocket sessions the proxy sees are captured too, and there's a WebSocket repeater:

nullhawk ws ./engagement            # list captured sessions; replay frames

One-off requests: send

send is like curl, except nothing you wrote is rewritten — header order, casing, and duplicates go out exactly as given:

nullhawk send https://example.com/login -X POST \
  -H 'Content-Type: application/json' -d '{"u":"a"}'
  • -k — accept any TLS cert (reported every time).
  • --client-cert / --client-key — mutual TLS.
  • --insecure — self-signed staging targets.

Because send preserves bytes, it's the tool for probing smuggling, header-injection, and framing quirks by hand before you reach for the automated checks.

Reviewing & replaying

Once traffic is captured, you read it, resend it, and transform it by hand. These are the manual-testing power tools — the source of the experiments the scanner later automates.

History

nullhawk history ./engagement                          # list exchanges
nullhawk history ./engagement --query 'status>=500 AND host:api'
nullhawk history ./engagement --body <request-id>      # dump one response body
nullhawk history ./engagement --body <request-id> --wire > wire.gz   # the bytes as they arrived

See the query fields with nullhawk help history. History shows a structured view of what the bytes actually carried — it will not invent a Host header a raw request was sent without.

Repeater (repeat)

Resend a captured request, optionally editing it first. It sends the request exactly as saved — a wrong Content-Length is reported, never silently corrected:

nullhawk repeat ./engagement <request-id> --edit       # opens in $EDITOR
nullhawk repeat ./engagement <request-id> --dry-run    # show what would be sent
nullhawk repeat ./engagement <request-id> --raw        # edit and send as raw bytes
nullhawk repeat ./engagement <request-id> --diff <other-id>   # compare two responses
nullhawk repeat ./engagement <request-id> --tree       # what descended from what
  • Structured mode serializes a clean message model.
  • Raw mode preserves bare LFs, wrong lengths, and duplicate-header order — and there's a raw HTTP/2 editor for the requests the h2 library refuses to send.

Every send keeps a link to what it derived from, so --tree answers "which edit caused this?".

Decoder (decode)

A local, offline Burp-Decoder analog — it sends nothing, so you never paste a live token into an online tool. Codecs: base64/base64url, URL, HTML, hex, gzip, deflate, and JWT; chainable in both directions.

nullhawk decode 'eyJhbGciOi...'              # decode
nullhawk decode --from base64 --from gzip <value>   # chain, left to right
nullhawk decode --smart <value>              # auto-peel layers, conservatively
nullhawk decode --encode --from base64 'hello'

Match & replace

Rewrite proxied traffic with rules, Burp/Caido-style — applied to in-scope traffic:

nullhawk matchreplace ./engagement ...       # add / list / replace / enable rules

In the desktop app this is the Match & Replace tab; the repeater applies the same rules, so there is one rule set and no door that skips it.

Scope, programme & identities

These are the declarations that make a run safe, compliant, and able to reason about who is who.

Scope

Scope declares what you may touch. Exclusions win over inclusions.

nullhawk scope add  ./engagement example.com
nullhawk scope add  ./engagement '*.example.com' --path-prefix /api
nullhawk scope add  ./engagement staging.example.com --exclude
nullhawk scope list ./engagement

Scope normalization decodes to a fixed point, so a double-encoded bypass like /%2561dmin cannot slip a path past an exclusion.

Programme

A programme records the engagement's terms — the rules Nullhawk must obey and the finding classes the target will not accept (a bug-bounty programme's constraints, say):

nullhawk programme ./engagement ...

An exclusion is about reporting, not looking: an excluded passive check still runs and its observations are still listed, and the hypothesis it raises still reaches the active scheduler (because proven impact may be in scope even when the raw class is not). An excluded active check is not scheduled at all. Nothing is silenced — the scan output and both report formats name every excluded class and its reason, so "nobody looked" and "it was looked at and they don't take them" can't be confused.

Attached (identification) headers

Some programmes require you to identify your traffic (for example X-HackerOne-Research: <your-handle>). Declare the header once and Nullhawk puts it on in-scope requests it sends — and on your browser's in-scope traffic when the proxy runs with --attach-headers:

nullhawk header ./engagement add 'X-HackerOne-Research' '<your-handle>'
nullhawk header ./engagement list

It is applied in the one function every structured send passes through, before an identity's credential, so a project setting can never decide who a request is from. Stealth is never the goal — the header exists to make research traffic identifiable.

Identities

An identity is a persona the project tests as, with a credential:

export TOKEN_B=...   # never on the command line; ps reads that
nullhawk identity add  ./engagement "User B" --kind bearer --from-env TOKEN_B
nullhawk identity add  ./engagement alice --kind cookie --session-cookie session --from-env ALICE
nullhawk identity list ./engagement
  • --kind — bearer (an Authorization header), cookie (the full Cookie header), or header (a named header).
  • --session-cookie <name> — for cookie identities, names the one cookie that identifies the session, so attribution compares it exactly. Two identities from one browser share every cookie but the session; a fuzzy match would file an IDOR that doesn't exist.
  • --owns <id> — optionally declare an object this identity owns (strengthens evidence; not required for the access-control sweep, which infers ownership).

Keeping a session alive

A captured credential decays. Rather than storing a password, Nullhawk adopts a fresher session from traffic a person generated:

nullhawk identity refresh ./engagement       # adopt a newer session from proxy traffic
nullhawk identity renew   ./engagement alice  # replay a recorded login to mint a fresh session

A long active run can renew mid-flight with scan active --renew (replays the recorded login before/within the run) or --refresh (adopts existing fresher proxy traffic). The credential value is never printed — a host, a time, and a byte count are enough to judge and nothing like enough to misuse.

Authorization & access control

Authorization bugs — IDOR/BOLA, privilege escalation, missing authentication — are where Nullhawk's evidence-first model earns its keep. There are three layers: declaring object ownership, replaying one request as everyone (authz), and sweeping the whole app (authz-sweep). For a hands-on walkthrough see Tutorial 2.

Identifiers and objects

Analysis reads captured traffic and offers values that vary where an identifier would against a path that holds still — not because they look numeric. It sends nothing and declares no ownership on its own:

nullhawk identifiers ./engagement --analyze          # offer candidate identifiers, with signals
nullhawk identifiers ./engagement --accept <value>
nullhawk object      ./engagement add <id> --owner "User A" --name account --in-request <req>
nullhawk object      ./engagement list

Declaring an object records a claim of ownership — it's the tester's assertion, not something Nullhawk establishes. That's what both convicts (a non-owner reached it) and exonerates (every caller gets their own record in an identical shape).

Single-request replay: authz

Replay a captured request as several identities and compare what came back — the core IDOR and privilege-escalation check:

nullhawk authz ./engagement <request-id>                      # as every other identity + anon control
nullhawk authz ./engagement <request-id> --as-identity alice  # whose request this is
nullhawk authz ./engagement <request-id> --as-identity alice --verify   # reproduce before reporting
nullhawk authz ./engagement <request-id> --construct --verify # substitute a declared object

The anonymous control is what makes the matrix trustworthy: without it, a public page produces one "violation" per identity, all true and all worthless. The comparison is structural — it reports which field differed ($.email present for A, absent for B), not a similarity percentage. Only GET/HEAD/OPTIONS are replayed by default; a state-changing method needs an explicit opt-in.

Whole-app sweep: authz-sweep (Pro)

The automated version: across the whole project, infer which identity owns each object, then have every other identity try to reach each object it doesn't own — built from the owner's own captured request.

nullhawk authz-sweep ./engagement --budget 40 --verify
nullhawk authz-sweep ./engagement --host api.example.com --no-save

It runs two cross-identity tests per endpoint:

  • Replay — the owner's own request sent as each non-owner (catches the classic URL-id IDOR).
  • Construction — one identity's object substituted into a different endpoint.

Both carry an anonymous control and send through the same scope-guarded path as everything else. Leads are ranked, de-duplicated by claim, and each cites two replayable requests. In the desktop app this is Testing → Access Control.

Flags: --host (one host), --budget <n> (ceiling spread across endpoints), --verify (Firm → Confirmed), --no-save (preview), --insecure (skip TLS verification).

What's covered, and what isn't

  • Replay needs no declarations and works on any engagement.
  • Construction needs object ownership to have been declared/inferred.
  • An endpoint whose captured credential matches no declared identity is reported as untested, never guessed at. "No findings" from a sweep is not "no IDOR" — it's bounded by what was captured and attributed.

Scanning: passive & active

The scanner is two passes with a strict boundary between them. Tutorial 3 walks the full ladder; this page is the reference.

Passive (free)

Reads only what's already captured and sends nothing, so it's safe at any point:

nullhawk scan passive ./engagement
nullhawk scan passive ./engagement --detector cache.sensitive      # one check
nullhawk scan passive ./engagement --host api.example.com --since 2026-01-01T00:00:00Z
nullhawk scan passive ./engagement --no-save                       # print without recording

It produces observations (facts, filed as leads) and hypotheses (suspicions that wait for a verifier). Five hundred endpoints missing one header is one finding citing three exchanges, not five hundred findings. A run records which detectors ran, at which versions, including the ones that saw nothing — the row that turns silence into a fact.

Active (Pro)

Settles the hypotheses a passive pass raised by running experiments — the only scan pass that sends traffic:

nullhawk scan active ./engagement --dry-run        # show exactly what would be sent, to which hosts
nullhawk scan active ./engagement                  # run (asks to confirm)
nullhawk scan active ./engagement --yes            # non-interactive (CI)
nullhawk scan active ./engagement --detector input.sqli --host app.example.com
nullhawk scan active ./engagement --quiet          # drop the "loud" checks (OPSEC)

An active run only tests hypotheses a passive pass already raised — nothing is invented, so run scan passive first. The scheduler sends one host's requests sequentially with a pause, runs different hosts concurrently, and enforces a ceiling (--max-requests). A run that stops early says so above its results.

The detector suite

nullhawk detectors            # every check, its version, whether it sends, and its noise level

The suite spans reflected input, SQLi, command injection, SSTI, SSRF (incl. redirect→SSRF chaining), path traversal, CRLF, open redirect, CORS, cache deception/poisoning, host-header injection, HTTP request smuggling, access-control bypass, authentication enforcement, scheduled cross-identity access, JWT guessable-secret, hidden parameters, DOM/stored XSS, and more. Each detector carries an intrusiveness level (silent / moderate / loud); --quiet drops the loud ones.

Custom checks

Define your own scan checks (Nullhawk's answer to Burp's BChecks):

nullhawk check ./engagement ...     # add / list / enable user-defined checks

The zero-false-positive benchmark

nullhawk benchmark suite.json       # score the scanner against labelled targets

Each case pairs a captured-traffic project with a labelled expectation (fire or clear). The headline number is the false-positive count; any FP produces a non-zero exit, so it's CI-gateable. It's the data behind the zero-FP claim.

Intruder, fuzz, race & sequencer

The tools that send one request many ways. Unlike the scanner, these conclude nothing on their own — what a difference means is the judgment of whoever chose the payloads. They group responses so the outlier is obvious.

Fuzz / intruder

Send one request many times, once per payload, and compare what came back. Responses are grouped by (status, length) so the crowd is one line and the outlier is a short row below it — and outliers are measured against the majority, not the baseline (in two hundred usernames, the unchanged request is just one more wrong answer).

nullhawk fuzz ./engagement <request-id> ...    # payload positions and lists

The unthrottled intruder is Pro; a throttled run is available free. It files nothing into the findings store — it's an instrument, not a detector. (It will replay a POST where the scanner refuses to, and says the method and count first.)

Race

Send one captured request many times at once, to find a race condition — single-use codes, balance/redeem flows, coupon double-spend:

nullhawk race ./engagement <request-id> ...

Sequencer

Measure how unpredictable a token is — session IDs, CSRF tokens, password-reset tokens:

nullhawk sequencer ./engagement ...

A predictable token is a finding in itself; the sequencer gives you the evidence (the sampled values and the analysis) to back the claim.

When to reach for which

  • Fuzz — enumerate a parameter (usernames, ids, files) and spot the anomaly. It found a real local-file-inclusion on a public test target in twelve requests.
  • Race — any "this should only work once" flow.
  • Sequencer — anything whose security rests on a value being unguessable.

Recon & offense tools

Two families of standalone tools. Recon is passive (reads captured traffic, sends nothing, free). Offense probes a live endpoint (sends traffic, Pro, confirm-before-send). See Tutorial 4 for a recon walkthrough.

Recon (passive, free)

nullhawk recon       ./engagement       # the whole passive picture in one pass
nullhawk fingerprint ./engagement       # stack/CMS/edge per host, each line citing its evidence
nullhawk surface     ./engagement       # input surface: params/body/cookies/dynamic segments per endpoint
nullhawk secrets     ./engagement       # leaked secrets in any captured response body
nullhawk externals   ./engagement       # cross-origin scripts/styles without Subresource Integrity
nullhawk retire      ./engagement       # known-vulnerable JS libraries by version
nullhawk jsminer     ./engagement       # mine captured JS for secrets AND undiscovered endpoints
nullhawk sitemap     ./engagement       # host → path coverage tree

These read the project's traffic store and never send. recon is the one-pass umbrella; the others are the individual views. jsminer --endpoints surfaces paths referenced in JavaScript that you haven't captured — feed them to the crawler.

Offense (active, Pro — confirm before sending)

Each probes a live endpoint and is gated on the Pro tier; each asks before sending unless you pass --yes.

nullhawk discover https://app.example.com/      # unlinked paths: .git/.env/backups/admin/swagger
nullhawk xxe      <url> ...                      # XML external entity: in-band file read + blind OOB
nullhawk upload   <url> --field file             # file-upload validation: dangerous files + a control
nullhawk graphql  <url>                          # schema exposure: introspection, field suggestions, batching
nullhawk llm      <url> ...                      # prompt-injection probing of an LLM-backed endpoint
nullhawk oob      ...                            # out-of-band collaborator: serve, mint, poll callbacks
  • discover learns the host's "not here" shape from decoy requests first, then reports only departures — so a soft-404 host doesn't flood you with false hits.
  • xxe / upload send state-changing POSTs; they set Content-Length explicitly and always include a benign control so "accepted the same as the control" is the signal.
  • graphql reports introspection, "did you mean" field suggestions (a leak even with introspection off), and batching/alias amplification (a rate-limit-bypass lever).
  • oob provides a collaborator callback host for blind SSRF/injection, correlating each callback back to the request that caused it. Also used by scan active's OOB-capable checks via --collaborator.

JWT workbench

nullhawk jwt decode <token>                      # header/payload/alg/subject/lifetime; flags unsigned
nullhawk jwt forge  <token> --alg-none           # forge the variants that test whether a server verifies
nullhawk jwt forge  <token> --sign-hs256-env KEY --set role=admin

jwt sends nothing — the acceptance test is the next request you make with send/repeat. The scanner's jwt.secret detector automates the guessable-HMAC-secret case; auth.enforcement covers tampered-signature and alg:none acceptance.

Coverage: crawl, browse & import

Coverage is exactly what you captured — the single biggest limiter on what a scan is worth. Three tools widen it beyond hand-browsing.

Crawl

Fetch in-scope pages and feed them to the project. The crawler respects scope — out-of-scope links are recorded, not fetched, unless you name them explicitly:

nullhawk crawl ./engagement --seed https://target.com/ --max-pages 500 --max-depth 5

The crawler is deliberately free (discovery shouldn't be behind a paywall). It does not submit forms or log in.

Browse (browser-driven capture)

Drive a real headless browser through an in-process capturing proxy, so JavaScript-driven XHR/fetch endpoints a static crawl can't see land in the project:

nullhawk browse ./engagement --url https://target.com/ --max-pages 40
nullhawk browse ./engagement --url https://target.com/ --identity alice     # crawl behind login
nullhawk browse ./engagement --record-login alice --url https://target.com/login
  • --identity <label> injects a cookie identity's session into the browser to crawl behind a login.
  • --record-login <label> opens a visible browser and watches for the session cookie to be set, saving it as a cookie identity and printing the identity renew command to replay that login later.

Import an API description

Turn a spec into traffic the scanner can work over:

nullhawk import openapi ./engagement spec.yaml --base https://api.target.com     # OpenAPI 3.x / Swagger 2.0
nullhawk import graphql ./engagement introspection.json

Import with --send (or the plan's send: true) actually fetches the endpoints; without it, the spec is parsed into requests you can review and send selectively. This is how you cover an API that has no browsable UI.

A coverage loop

Capture or crawl → run recon and jsminer --endpoints to find referenced-but-uncaptured paths → discover the unlinked → feed those back in → re-run surface to see the widened input surface. Repeat until the map stops growing, then scan.

Findings, proof & reporting

The end of the loop: what a run concluded, turned into something a triager — or a client — can act on.

Findings

nullhawk findings ./engagement                       # list: severity, confidence, status
nullhawk findings ./engagement --actionable          # only the ones worth acting on now
nullhawk findings ./engagement --show <finding-id>   # the full claim and its evidence
nullhawk findings ./engagement --triage <finding-id> --status false-positive

Storage refuses a claim that fails its own validation. Re-running a check updates the claim rather than duplicating it, and leaves your triage decision alone — but confidence follows the evidence currently attached, so a re-run without --verify can take a Confirmed finding back down to what the stored comparison actually supports. A findings list that un-dismissed things is a list nobody reads.

Proof of concept

nullhawk poc ./engagement <finding-id>

Compiles a finding into steps someone can run, built from the exchanges it already cites and nothing else. Credentials become placeholders named after the identity (the same one in every step), so a reader supplies two values and runs the whole thing — curl where curl can express the request, a stated reason where it can't.

Reports

nullhawk report ./engagement --format markdown --output report.md
nullhawk report ./engagement --format html     --output report.html
nullhawk report ./engagement --format json
nullhawk report ./engagement --format sarif     --output findings.sarif    # Pro (for CI)
nullhawk report ./engagement --actionable --severity high

A report quotes the request and response behind every claim; a citation the project can't resolve is printed as missing, with the reason, and counted in the caveats. Scope, identities, and coverage come first, so a clean run reads as a record of what was tested, not a clean bill of health. Credentials are redacted with the length of what was removed.

The HTML report escapes everything and loads nothing — no script, no external stylesheet, no CDN font. It quotes bodies from the application you just attacked, some of which exist because that app reflects input; an unescaped or phone-home report would deliver the payload it documents, on the client's machine. Review quoted bodies before sharing.

Snapshots & retest (Pro)

Record what the engagement looks like now and compare two moments, so a retest shows what was fixed, what regressed, and what's new:

nullhawk snapshot take ./engagement --label "initial"
# ... retest later ...
nullhawk snapshot diff ./engagement --since "initial"

A snapshot stores claims, scope, identities, and declared objects as copies, so a later run can't rewrite its own past. diff never says fixed: a claim that stopped appearing is reported as gone, with the reason — and only one of the reasons is about the application at all (the other two are "nothing re-tested it" and "the check changed"). Because findings carry the detector and version that produced them, a regression isn't confused with a rewritten check.

Premium modules: mobile, cloud & desktop

Three Pro modules extend Nullhawk beyond the browser. They live in the desktop app (there are no CLI equivalents). Tutorial 5 is the hands-on walkthrough; this is the reference.

All three are gated under Feature::PremiumModules (Pro). On Free, the view shows a "needs the Pro tier" callout and the analysis action is disabled. The capture plumbing that supports them (the proxy, the mobile on-LAN onboarding server) stays free — capture is free across the whole tool; only the analysis deliverable is Pro.

Mobile (Android & iOS)

Mobile tab. Two halves:

  • Capture (free) — the tab shows your machine's LAN IP and runs a small on-LAN server so a phone fetches the CA and setup steps from one URL. Point the device's Wi-Fi proxy at your machine, trust the CA, and the app's API lands in History. Pinned apps need the pin defeated off-tool.
  • Static analysis (Pro) — drop an APK or IPA; Nullhawk unpacks it and runs the shared secret/endpoint miner over DEX, resources, and plists, surfacing hardcoded keys, URLs, and versioned libraries.

Desktop / thick-client

Desktop tab. Point the app's HTTP proxy at Nullhawk and its API flows into History like any website's. The artifact analysis (Pro):

  • Electron — unpack an app.asar, mine it, and decode the Electron security posture (nodeIntegration, contextIsolation, webSecurity, sandbox).
  • Native binary — identify a PE/ELF/Mach-O's format, bits, and architecture, and mine strings from the raw binary.

Cloud (AWS / GCP / Azure)

Toolkit → Cloud key check (Pro). Given a cloud credential:

  • Validate — confirm it's live and resolve the identity behind it.
  • Blast radius (AWS, GCP) — map what that identity can actually do, turning "a key leaked" into "this key can reach these resources".

These send authenticated requests to the provider's own APIs — run them only on credentials you're authorized to test.

The desktop app

The desktop app is the same engine behind a window — the same crates, the same scope guard, the same evidence model as the CLI. Every CLI capability has a view; this is the map.

Layout

The left nav is grouped by the stage of the work:

  • Overview — Dashboard, Setup (project, scope, identities, attached headers, programme, session renewal).
  • Traffic — History, Repeater, WebSocket, Match & Replace, Decoder.
  • Discovery — Crawler, Site map, Fingerprint, Attack surface, Secrets, Externals, Vulnerable libs, Discover, Import API, Identifiers, Toolkit, Mobile, Desktop.
  • Testing — Scan, Custom Checks, Fuzzer, Race, Sequencer, DOM XSS, Authorization, Access Control, LLM, Collaborator, Header OOB, XXE, Upload, GraphQL.
  • Results — Findings, Report, Snapshots, Programme, Licence.

Things worth knowing

  • Access Control (Testing) is the GUI for the whole-app authz-sweep — open a project, declare two identities, set a budget, and run the sweep; leads come back with repro steps. It's Pro.
  • Pro gating is visible before you click. A gated view (active scan, the offense probes, the premium modules) reads the licence, disables its send control on Free, and shows a "needs the Pro tier" callout — so you never press a button that only errors.
  • The Decoder sends nothing — it's there so you never paste a live token into an online tool.
  • Credential values never reach the frontend. The typed IPC edge carries a credential's kind but has no field that could carry its value, so a token can't reach a devtools console or a screenshot by accident.
  • Stop is honest. The active scan's Stop button promises exactly what it can — no further request is sent; a request already on the wire completes, because nothing can recall one.
  • Version negotiation. The interface and engine agree a contract version on start; if they disagree the app says so rather than risk decoding a request under the wrong contract.

Running it

Installed builds launch from your applications menu / Start menu. From source:

pnpm -C frontend build
cargo build --release -p nullhawk-desktop --features custom-protocol
# then run target/release/nullhawk-desktop

A debug build loads the dev server (pnpm -C frontend dev), not the bundled frontend — running a debug build without the dev server shows ERR_CONNECTION_REFUSED. Release builds with --features custom-protocol bundle the UI and need no dev server.

Extensions

An extension is a manifest plus a WebAssembly module. It receives only the capabilities you approve — nothing is granted implicitly — and the exact grant is recorded in the project.

nullhawk ext install ./engagement jwt-tools.manifest.yaml           # grants required capabilities only
nullhawk ext install ./engagement jwt-tools.manifest.yaml --grant-all
nullhawk ext list        ./engagement
nullhawk ext permissions ./engagement com.example.jwt-tools
nullhawk ext enable      ./engagement com.example.jwt-tools
nullhawk ext disable     ./engagement com.example.jwt-tools
nullhawk ext run jwt-tools.manifest.yaml --exchange exchange.json   # test a module against one exchange

A passive-check extension runs in a sandbox — no filesystem, network, or clock, bounded by fuel and memory — over each captured exchange during scan passive, folding its observations in as leads. Because it sees the same redacted exchanges the built-in checks do, an extension cannot leak a credential either.

If a required capability is declined at install, the extension installs disabled rather than half-working — a partially-capable extension that silently skips what it couldn't do is worse than one that refuses to run.

To write an extension (the manifest format, the host ABI, the capability model), see the developer reference: docs/extensions.md.

CLI command reference

The full command list. Run nullhawk <command> --help for a command's options, and nullhawk help <topic> for longer guidance (e.g. history query syntax). Pro marks a feature that needs a Pro licence.

Projects & setup

CommandDoes
projectCreate, inspect, and manage projects
setupSet up a machine: project, CA, and trust (the first-run path)
caManage the interception certificate authority
scopeShow and change what the engagement may touch
programmeThe engagement's terms and what the target won't accept
headerIdentification headers put on your traffic
licenseShow the active licence, or activate one
versionPrint version and build information

Capture & review

CommandDoes
proxyRun the intercepting proxy (HTTP/1.x + HTTP/2)
sendSend a single request, exactly as written
historyBrowse captured traffic
repeatResend a request, optionally editing it (structured or raw)
wsCaptured WebSocket sessions + the WebSocket repeater
decodeEncode/decode through a chain of codecs, or auto-peel layers
matchreplaceRewrite proxied traffic with rules
sitemapHost → path coverage tree

Coverage

CommandDoes
crawlCrawl in-scope targets to widen coverage
browseBrowser-driven capture (incl. authenticated / record-login)
importImport an OpenAPI/GraphQL description as traffic

Recon (passive, free)

CommandDoes
reconThe whole passive picture in one pass
fingerprintName the stack behind each host, with evidence
surfaceMap the input surface per endpoint
secretsLeaked secrets in captured response bodies
externalsCross-origin scripts/styles without Subresource Integrity
retireKnown-vulnerable JavaScript libraries
jsminerMine JS for secrets and undiscovered endpoints

Identities & authorization

CommandDoes
identityManage the identities a project tests as
identifiersReview values that might be object identifiers
objectDeclare which identifiers are objects, and who owns them
authzReplay a request as several identities and compare
authz-sweepPro — sweep the whole project for broken access control

Scanning & checks

CommandDoes
scan passiveObservations + hypotheses over captured traffic (free)
scan activePro — settle hypotheses by sending experiments
detectorsList the checks this build has, and which send traffic
checkUser-defined scan checks (BChecks analog)
benchmarkScore the scanner against labelled targets (TP/FP)

Sending tools

CommandDoes
fuzzSend one request once per payload (unthrottled intruder is Pro)
raceSend one request many times at once (race conditions)
sequencerMeasure token unpredictability
domxssDrive a browser to find DOM-based XSS
discoverPro — find unlinked paths (.git/.env/admin/swagger)
xxePro — XML external entity injection
uploadPro — file-upload validation
graphqlPro — GraphQL schema exposure
llmPro — prompt-injection probing
oobOut-of-band collaborator (serve/mint/poll callbacks)
jwtRead a JWT and forge the variants that test verification

Results & reporting

CommandDoes
findingsRead and triage recorded findings
pocCompile a finding into runnable steps
reportTurn findings into a document (SARIF is Pro)
snapshotPro — record and compare engagement snapshots
runRun a declarative plan file end to end (CI)

Extensions

CommandDoes
extInstall and manage extensions (capability-scoped WASM)

Licensing & tiers

Nullhawk is dual-licensed: the source is AGPL-3.0-or-later, and a paid commercial licence unlocks the Pro and Enterprise feature tiers in the official builds. This page is the canonical map of which capability sits in which tier and where the boundary is enforced. For activation, see Getting started → Licensing.

Tiers

TierHow you get itFor
FreeThe default (unlicensed, misconfigured, or expired installs run here)Manual testing, triage, learning
ProA signed commercial licence, or a one-time trialA working pentester / bug-bounty hunter who wants the automation
EnterpriseA signed commercial licenceTeams: shared projects, audit log, SSO (roadmap)

Tiers are ordered Free < Pro < Enterprise; a higher tier includes everything below it.

Free — everything a tester does by hand

Proxy, TLS interception, repeater (structured + raw H2), match & replace, decoder; history, crawler, authenticated browse, site map, API import; the passive scanner and the full recon toolset (fingerprint, surface, secrets, externals, retire, jsminer); single-request authz and identifier/object/ownership declaration; findings, triage, PoC, and Markdown/HTML/JSON reports. The crawler is deliberately free.

Pro — the automation that sends, and the premium modules

Enforced by EntitlementGate::require(Feature::…) at the entry of each command, in both the CLI and the desktop app (core/engine/src/license.rs).

FeatureUnlocks
ActiveScannerscan active; authz-sweep; the offense probes (discover, xxe, upload, header-OOB, graphql, llm, oob test); API send/replay; DOM-XSS; in-session identity renewal
IntruderThe unthrottled intruder (fuzz, race)
SarifExportreport --format sarif (for CI)
RetestSnapshotsEngagement snapshots and retest comparison
PremiumModulesMobile static analysis, cloud key validation + blast radius (AWS/GCP/Azure), desktop-binary analysis (PE/ELF/Mach-O, Electron asar)

Enterprise — teams (roadmap)

TeamProjects (shared, multi-user projects), AuditLog (tamper-evident), and Sso. Defined in the feature model; the backing functionality is not all shipped.

How the gate behaves

A command checks entitlement with gate().require(Feature::X)? before any gated work. On Free it returns an error naming the feature and the tier it needs — nothing is silently degraded or quietly does nothing. In the desktop app, a gated view also reads the licence and disables its send control with a "needs the Pro tier" callout, so the gate is visible before the button is pressed.

The mobile capture plumbing (the proxy and the on-LAN onboarding server) stays free — capture is free across the tool; only the static-analysis deliverable is Pro.

For developers

Building Nullhawk, extending it, or understanding how it enforces its guarantees. These live in the repository's docs/ directory (linked to GitHub so they stay single-sourced).

Architecture & guarantees

  • Architecture — crate layout and where invariants are enforced.
  • Security invariants — the rules the codebase does not break.
  • Threat model — hostile targets, extensions, local attackers, and the limits.
  • Storage — why bodies are content-addressed and kept out of the database.

Building & contributing

  • Development — building, testing, conventions, and the Windows build traps.
  • Dependencies — dependency, audit, and secret-scanning policy.
  • Extensions — the manifest format, host ABI, and capability model for writing an extension.

Release & operations

  • Release — the signed, licensed release ceremony.
  • Licensing keys — the issuer keypair and signing flow.
  • CI integration — the reusable GitHub Action and SARIF wiring.
  • Licensing — the canonical free/Pro/Enterprise gate audit.

Roadmap & positioning

  • Roadmap — what exists, what's next, and why, in order.
  • Feature parity — the Burp / Caido / ZAP matrix and competitive position.
  • Coverage — the OWASP WSTG / API Top 10 methodology and the assist-tier backlog.