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.

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. Your HTTP/2 history is searchable and groupable by host, path, and parameter, and the JS responses — bundles, chunks, and the HTML with its inline state — are right there to mine, source maps and all. Browse a target for ten minutes, then read what you caught. download Crusader free and start your next engagement by reading the map before you draw on it.

Frequently asked questions

What is passive reconnaissance, really?
It’s extracting attack surface by re-reading traffic you already captured — the JS bundles, their source maps, and the HTML you loaded as a normal user — instead of sending new probes. The distinction that matters: passive recon is re-reading, not re-requesting a quieter way. The richest artifacts are files already sitting in your proxy history, so it generates zero new requests and there is nothing for a WAF or rate limiter to see.
How do source maps leak the original source code?
A production JS bundle often ends with a //# 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?
In the bootstrap state object inlined into the HTML — 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.
Try Crusader free →