The richest recon target is a file you already downloaded
Most recon writeups open with a subdomain brute-force and a wordlist. That’s fine, but it’s loud, it’s slow, and it runs before you understand the app. The problem isn’t that people don’t know how to enumerate — it’s that they treat recon as something you go out and do, when the highest-yield artifact is already sitting in your proxy history. You downloaded the whole codebase the moment the page loaded. You just haven’t read it yet.
Passive recon isn’t running quieter scans. It’s re-reading traffic you already have — and re-reading beats re-requesting, because a file you already downloaded can’t trip a WAF.
That reframe is the whole game. The JS bundle your browser pulled to render the app is not a black box. It ships, or points to, everything you’d otherwise spend a week fuzzing for: the original source, the full route table, every lazily-loaded admin chunk, and — inlined into the HTML — the current user’s role. None of it costs a request.
Source maps: the entire codebase, handed to you
This is the single highest-yield artifact in modern web recon, and most people walk right past it. Open the main bundle and look at the last line:
//# sourceMappingURL=main.4f2a91c.js.map
Fetch that .map. A source map is a JSON file that maps minified positions back to the original, and it embeds the original source content. Feed it to a reconstructor and you get the pre-build tree back:
unwebpack-sourcemap https://app.target.com/static/js/main.4f2a91c.js.map -o ./src
# or
sourcemapper -url https://app.target.com/static/js/main.4f2a91c.js.map -output ./src
Out comes the original unminified source: real variable names, developer comments, the folder structure (src/features/billing/adminOverride.ts tells you more than any wordlist), and dev-only code paths that minification flattened away. Even when the sourceMappingURL comment has been stripped, the map is very often still deployed at the guessable path — just append .map to the bundle URL and try main.<hash>.js.map directly. Teams disable the comment and forget the emit flag constantly. This is not a rare misconfiguration; it’s the default state of a shocking number of production apps.
The chunk manifest: every hidden route, enumerated for you
Even without a source map, the entry bundle betrays the whole app. Code-split apps ship a webpack chunk manifest — a big map of chunkId → filename hash, sitting near __webpack_require__.u (the function that builds a chunk’s URL). It exists so the app can lazily fetch routes on demand, which means it enumerates every route, including the ones the UI only loads after it decides you’re an admin.
Grep the bundle for the manifest, pull every chunk filename it names, download them all, and grep the lot for endpoints:
# find the manifest / chunk map
grep -oE '__webpack_require__\.u\s*=\s*function[^}]+' main.js
# then pull each chunk hash it references and grep them for routes
grep -rhoE '"/(api|admin|internal)[a-zA-Z0-9/_{}$.-]*"' ./chunks/
The admin panel, the billing-ops screen, the internal debug view — the UI gates their rendering behind your role, but the code to render them was shipped to your browser anyway, and the manifest tells you exactly which file to open. There is no navigation link to /admin/impersonate; there is a chunk that references it.
While you’re grepping the reconstructed source and chunks, the client-side route table is the other free map. React Router / Vue Router configs enumerate every path in one array — grep for the route definitions and you get /admin, /internal, /debug with no nav entry anywhere in the DOM.
The bootstrap state object: role and internals, in the HTML
Before you make a single new request, read the HTML you already have. SPAs inline their initial state directly into the page to avoid a first-paint round-trip. Look for one of these globals in a <script> block:
window.__INITIAL_STATE__ = {...}
__NEXT_DATA__ (Next.js, in <script id="__NEXT_DATA__">)
window.__NUXT__ (Nuxt)
window.__CONFIG__
These routinely contain the full API base URLs (including the internal ones the SPA quietly calls), every feature-flag value, and — the part people miss — the current user object, with its role. Occasionally a poorly-scoped serializer dumps other users’ data into the same blob. You read all of it out of the HTML you already loaded. No request, no log line.
Mining tokens and secrets from what you captured
Any request you made carrying Authorization: Bearer eyJ... is a free intelligence drop. A JWT is three base64url segments; decode the middle one:
# middle segment = claims
echo '<middle-segment>' | base64 -d 2>/dev/null | jq
Read sub (the user/subject id), role, tenant, and exp (session length — short expiry tells you where to expect refresh flows). The gold is iss and aud, which routinely leak internal hostnames like auth.internal.corp — new surface you’d never have guessed from the outside.
Then grep everything you’ve reconstructed — JS, JSON responses, and the source-map output — for the prefixes that actually pay. These are exact enough to run blind:
AIza # Google API key
AKIA | ASIA # AWS access key id
xox[baprs]- # Slack token
sk_live_ # Stripe live secret key
ghp_ | gho_ # GitHub token
glpat- # GitLab PAT
-----BEGIN # private key block
eyJ # JWT
Front-end secrets are embarrassingly common, and a source map surfaces the ones that got minified into an unsearchable soup — because the map hands you back the original string literals with their variable names attached.
The detection trap: never trust a single host list
The mistake that quietly caps your surface: assuming the app talks to the hosts you’d guess. It doesn’t. The SPA on app.target.com fans out to api., events., uploads., flags. subdomains — some on the apex, some on S3, some on a vendor. Group your history by host first. Every distinct host is distinct surface, and half of them never appear in any nav link or marketing page. If you started with a hand-written host list and fuzzed against it, you never saw the events. host that logs to a different backend with different auth. The bootstrap state object and the reconstructed source will name these hosts for you — cross-reference them against what your history actually contacted, and the deltas are the interesting part.
While grouping, flag the second-order signals: a stray /api/v1/ call under a /api/v3/ app (old version, often missing the auth v3 added), a /graphql endpoint (introspection may be on — one query dumps the entire schema), and any parameter you can now see is real because you found the code that reads it. That’s the natural bridge to mining the parameters the app never documents — the source map tells you which ones exist before you send a single value.
Mine it in Crusader: the Investigate tab
Everything above is grep against files you exported by hand. Crusader skips the export step: the whole capture lands in a per-project SQLite database (~/.crusader/projects/<slug>/history.db) with one central table — exchanges — and the Investigate tab is where you read it. Ask a plain-English question and it writes the SQL for you, or drop straight to raw SQL yourself; either way the query runs read-only behind a safe-query guard (a single statement, SELECT/WITH/EXPLAIN only, writes blocked by both the parser and a read-only handle), and any result row pivots straight into Repeater. It’s safe to point at a client’s captured project — and safe to hand to an agent.
Group by host first — the detection trap above, as one line:
SELECT host, COUNT(*) AS n, SUM(status >= 500) AS errs
FROM exchanges GROUP BY host ORDER BY n DESC;
Every row is a host you actually contacted — the events., uploads., flags. subdomains no nav link would have named. Surface every JS bundle to mine for source maps and secrets, largest first:
SELECT id, url, size FROM exchanges
WHERE response_content_type LIKE '%javascript%'
ORDER BY size DESC;
Sweep the bodies for the prefixes that pay — and because the headers are stored as JSON, read them straight with json_extract:
-- secret prefixes in any captured response body
SELECT id, url FROM exchanges
WHERE response_body LIKE '%AKIA%'
OR response_body LIKE '%sk_live_%'
OR response_body LIKE '%-----BEGIN%';
-- who is sending Bearer tokens, and to which host
SELECT id, host, json_extract(request_headers, '$.Authorization') AS auth
FROM exchanges WHERE auth LIKE 'Bearer %';
Run these in the Investigate tab, or headless as crusader sql query "<sql>" (or point sqlite3 straight at the file — it’s a normal SQLite db). For fuzzier, word-level hunting, Investigate’s full-text search rides an FTS5 index, exchange_fts, spanning URLs, headers, and bodies — the same index behind the top search bar, crusader history search, and the MCP history.search tool:
crusader history search "graphql" --desc --limit 20
And you don’t have to hand-write all of it. Crusader’s passive Scanner runs over this same captured history — sending nothing — and already flags the marquee artifacts from this post: source-map leak, JS-bundle secrets, GraphQL introspection, internal-IP disclosure, debug endpoints, shadow-OpenAPI inference. SQL is for going past the modules; the Sitemap (crusader sitemap) folds the capture into a per-host tree and marks the “ghost” endpoints you’ve seen referenced but never actually hit.
Then act on it: any row pivots straight into Repeater for a manual replay — the clean handoff from “this endpoint exists” to mining its hidden parameters or diffing it across two identities. Every verb prints JSON to stdout, so the whole loop scripts — or an agent drives it over MCP.
Why this beats active scanning
Two reasons, and they compound. First, it’s grounded in ground truth: every endpoint, host, and parameter came from code a developer actually wrote, not a wordlist guess — so your hit rate is real, and you skip the 90% of any wordlist that was never deployed. Second, it’s silent by construction: the highest-value work happens on files already in your history, so it generates zero new requests. No rate limit to trip, no WAF rule to fire, no anomaly detector to spike. A scanner announces itself on every line; re-reading a source map you downloaded during a normal login announces nothing. You go active later — surgically, only against the routes and params you’ve already confirmed exist.
The point
Active recon asks the server questions and hopes it answers honestly. Passive recon reads the answers the app already shipped to your browser: the source map that rebuilds its codebase, the chunk manifest that lists its hidden admin routes, the bootstrap blob that hands over the current user’s role. The bugs were reachable the whole time — the app told you where, in a file you’d already downloaded and never opened.
Crusader is built to make that re-read the default loop, not an export-and-grep chore: the whole capture lands in one queryable exchanges table, the passive Scanner flags the source-map and secret leaks for you, and any row pivots into Repeater. Browse a target for ten minutes, then read what you caught — with a SELECT, not a wordlist. download the Crusader alpha and read the map before you draw on it.
Frequently asked questions
What is passive reconnaissance, really?
How do source maps leak the original source code?
//# sourceMappingURL=main.<hash>.js.map comment. Fetch that .map file and tools like unwebpack-sourcemap or sourcemapper reconstruct the original unminified tree — real variable names, comments, folder structure, and dev-only code paths that minification erased. Even when the comment is stripped, the map frequently still sits at the guessable <bundle>.js.map path. Developers forget to disable map emission in prod constantly.Where do SPAs leak the current user's role and internal URLs?
window.__INITIAL_STATE__, __NEXT_DATA__, window.__NUXT__, or window.__CONFIG__. These commonly hold full API base URLs, feature-flag values, and the current user object including its role, and sometimes leak other users’ data. Internal hostnames also leak from JWT claims: base64url-decode the middle segment of any Bearer eyJ... token and read iss/aud for hosts like auth.internal.corp.