Repository navigation
JS Reconnaissance
📖 Canonical version: read this page on the official docs site — https://www.redamon.org/docs/js-reconnaissance. The GitHub wiki is a mirror.

JS Recon is RedAmon's deep JavaScript reconnaissance engine. It runs as GROUP 5b in the recon pipeline -- immediately after resource enumeration -- and analyzes every JavaScript file discovered by crawlers (Katana, Hakrawler, GAU) plus any manually uploaded files. The goal is to extract secrets, endpoints, dependency confusion risks, source map exposures, DOM XSS sinks, and framework fingerprints embedded in client-side code.
Pipeline position: GROUP 5b runs whenever
JS_RECON_ENABLED(JS Recon in the project form) is true. It is not a scan module: there is nojs_reconentry to add toscanModules, and an older project or preset that still lists one loads fine but that entry does nothing. JS Recon cannot browse. It analyzes the JS files that resource enumeration's crawlers and HTTP probing found, so keep Resource Enumeration (or at least HTTP Probing) in the scan modules; with neither, it analyzes only manually uploaded files. JS Recon feeds discovered subdomains back into the pipeline and updates the Neo4j graph in a background thread.
JS Recon runs seven parallel analysis modules via ThreadPoolExecutor(max_workers=4):
| Module | What It Finds | Severity Range |
|---|---|---|
| Pattern Scanning | 90+ secret patterns (cloud credentials, payment keys, auth tokens, infrastructure URLs, info leaks) | Critical - Info |
| Secret Validation | Live validation of 21 secret types against their APIs (GitHub, Stripe, Slack, AWS, etc.) | Critical - High |
| Source Map Discovery | Exposed .map files via comment parsing, HTTP headers, and path probing; the map's own sources go through every other module |
Low - Info (what the sources hold is reported as its own Secret, Endpoint and DOM-sink findings) |
| Dependency Confusion | Scoped npm packages not registered on public npm (attacker could register them) | Critical - High |
| Endpoint Extraction | REST APIs, GraphQL, WebSocket, router definitions, admin/debug paths, API docs | High - Info |
| Framework & Security | Framework detection (12 built-in), DOM XSS sinks (15 patterns), dev comments with sensitive keywords | Critical - Info |
| AI SDK Detection (Phase 6) | LLM/vector-DB SDK imports, hard-coded provider keys, dangerouslyAllowBrowser opt-in, AI-frontend product markers in JS chunks, provider base URLs |
Critical - Info |
Scans every JS file against 90+ hardcoded regex patterns organized into seven categories:
| Category | Examples |
|---|---|
| Cloud | AWS Access Key, GCP API Key, Azure Client Secret, DigitalOcean Token, Firebase Config |
| Payment | Stripe Secret/Publishable Key, Square Access Token, PayPal Client ID, Razorpay Key |
| Auth | GitHub Token, GitLab PAT, Slack Token/Webhook, Discord Bot Token, Twilio SID, Telegram Bot Token |
| JS Services | Sentry DSN, Algolia API Key, Mapbox Token, Supabase Key, OpenAI API Key |
| General Secrets | JWT tokens, generic API keys, private keys, database URIs, NPM tokens |
| Infrastructure | S3 bucket URLs, GCP Storage URLs, Azure Blob Storage URLs |
| Info Leaks | Email addresses, private IPs (RFC 1918), UUIDs, debug flags |
Each finding includes a confidence level (high/medium/low), severity, redacted value, line number, and surrounding code context. A minimum confidence filter (JS_RECON_MIN_CONFIDENCE) controls which findings are kept.
Confidence. high is kept for a value with a provider-specific shape (AKIA…, ghp_…, xoxb-…, sk_live_…). A pattern that finds a generic run of characters only because a keyword sits nearby says medium: AWS Secret Key, Azure AD Client Secret, Azure Connection String, Plaid Secret Key, GoCardless API Token, Notion API Token, Harness API Key and Basic Auth Header. The Priority Board reads high as enough confidence for "Act soon", so an unvalidated keyword-anchored hit now stays in "Plan" at most. Raising JS_RECON_MIN_CONFIDENCE to high therefore also drops these patterns.
Keyword-anchored patterns are bounded. About 70 patterns look for a value near a keyword (etsy … "<24 characters>"). The keyword and the value may be at most 120 characters apart, and the nearest value is taken. On a minified bundle, one line used to let a match run from the keyword to the last candidate anywhere on the line. The Azure AD client secret also needs the value to be assigned to the client-secret key (clientSecret: "…", client_secret = "…"), with azure, msal or aad within those 120 characters: "azure" anywhere on the line plus any long quoted string later (MSAL's empty clientSecret: "" default did it) is no longer a hit.
Context around each secret. Each Secret stores up to 400 characters of the code around the match, as context: on a line over 1,000 characters (minified code) about 150 characters either side of the match, otherwise the line itself with the nearest 150 characters of the lines above and below. Every secret-shaped value in that text, the match itself and any other key on the same line, is shown in its redacted form (AKIAQ7...B3V6), whole even where the edge of the window cuts it. Emails, private IPs, UUIDs and bucket URLs stay readable. The Priority Board's reviewer reads it as evidence.
Keys that are public by design. A browser key, a publishable key, a DSN or a write-only analytics key ships to every browser that loads the page. These are written at severity info and flagged public_client_key, not dropped (an unrestricted Google key can still be billed against a paid API):
| Family | Pattern names |
|---|---|
| Google / Firebase | GCP API Key (AIza…), Firebase API Key |
| Analytics and monitoring | Segment Write Key, Amplitude API Key, New Relic Browser API Token (NRJS-…), Sentry DSN |
| Payments and maps | Stripe Publishable Key (pk_live_…), Google reCAPTCHA Key, Mapbox Token (pk.…), Flutterwave Public Key |
| Search, chat, realtime | Algolia App ID, Algolia Search Key, Intercom App ID, Pusher Key |
| Auth and mail | Clerk Publishable Key, Mailgun Public Key (pubkey-…) |
| Supabase | Supabase anon key, Supabase Publishable Key (sb_publishable_…) |
An Algolia key is the public search-only key only when its label says so (searchKey, search_only_api_key, …); the admin key has the same format, so an unlabelled one stays a high Algolia API Key. The Priority Board ranks public keys in Track, the client report lists them on an "Expected-public client keys" line instead of counting them, the Red Zone secrets table shows them at info with a public key chip, and Mute Rules can select them with Public by design. A node written by an earlier release has no flag and keeps its old severity until JS Recon runs again; every reader also treats its pattern name as public meanwhile.
Keys that look public but are not. Some values have a public twin:
| Finding | Severity | How it is told apart |
|---|---|---|
| Supabase service_role key | critical (high confidence) | A Supabase key is a JWT. Its payload is base64url-decoded (never verified, at most 4 KB; anything unreadable leaves the finding as it was) and its role claim read: service_role bypasses row-level security, anon is the public key above. A Supabase key matched both by the Supabase pattern and the JWT pattern is one finding. |
Supabase Secret Key (sb_secret_…) |
critical | The new key format; sb_publishable_… is its public twin |
| Signed URL in client code | medium, low once expired | A presigned S3 URL (X-Amz-Signature with its X-Amz-Credential) or a GCS signed URL (X-Goog-Signature): whoever holds it reads or writes the object until it expires. X-Amz-Date / X-Goog-Date plus -Expires in the past makes it low. |
| Zapier Webhook, Make Webhook | high |
hooks.zapier.com/hooks/catch/… and hook.<region>.make.com/…: a POST runs the owner's automation, like a Slack or Discord webhook |
CSS-module class names. A CSS-modules build names a form field's class after the field: password: "LoginForm_password__3xYz9". A value shaped like a CSS-module class (<Component>_<class>__<hash>, or css-loader's ___ and -- forms) is noise, like a route, a selector or an i18n key.
Embedded binary data. A match sitting inside a long base64 run (an embedded image or font) or right next to a font marker (@font-face, fontFamily, base64,, .woff) is dropped as noise. Only the text around the match counts: a match is inside a base64 run only when the run reaches well past it, so a long JWT, a Bearer header or a 1Password service-account token is kept; and on a line over 1,000 characters only the 300 characters either side of the match are checked for font markers, so one fontFamily near the top of a minified bundle no longer hides every secret on that line.
Custom patterns: Upload a JSON or TXT file with additional regex patterns via the project settings UI.
When JS_RECON_VALIDATE_KEYS is enabled, discovered secrets are tested against their respective service APIs with a single, minimally-scoped request per secret. Per-service rate limiting (1 req/sec) prevents abuse.
Supported services (21):
| Service | Validation Method |
|---|---|
| AWS | STS GetCallerIdentity |
| GitHub | /user endpoint |
| GitLab | /api/v4/user |
| Slack | auth.test |
| Stripe | /v1/charges?limit=1 |
| Google Maps | Geocode API |
| Twilio | /Accounts |
| SendGrid | /v3/scopes |
| Mailgun | /domains |
| Mailchimp | /3.0/ping |
| HubSpot | /crm/v3/objects/contacts |
| Heroku | /account |
| Firebase | Realtime Database URL: one anonymous /.json?shallow=true read. 200 (publicly readable) is validated; 401 (the database rules refuse the read) is rejected; 403 is unknown
|
| DigitalOcean | /v2/account |
| Telegram | /getMe |
| Discord | /users/@me |
| Postmark | /server |
| Okta | /api/v1/users?limit=1 |
| Shopify | /admin/api/shop.json |
| Cloudflare | /client/v4/user |
| OpenAI | /v1/models |
Each validation returns one of the following statuses, plus scope/permissions info when available. What the provider said and what it could not tell are kept apart: only rejected means the key is dead.
| Status | Badge | Meaning |
|---|---|---|
validated |
LIVE (red) | API call confirmed the key is active and working. The scope/permissions field shows what access the key grants (e.g., user=admin, 3 scopes, database publicly readable). This is a confirmed, exploitable finding. |
rejected |
rejected | The provider said it does not accept the key: a 401, or the provider's own answer (Slack invalid_auth, token_revoked…; Google "The provided API key is invalid"). The key was real at some point but no longer works; the Priority Board moves it out of the ranking (inactive). A Firebase database whose rules refuse an anonymous read (401) is rejected too ("Database locked"): the protection works, so it is never proof. |
unknown |
could not check | No verdict: a timeout, a 429 or 5xx, a network error, a call the validator's circuit breaker skipped, or a 403 (often a live key missing the scope or SSO grant the check needed). The key may well be live; it stays ranked. |
format_invalid |
malformed | The value fails the format check of a validator that makes no call (a Twilio SID or Twitter bearer token with too little entropy). |
format_validated |
format ok | The value passes a format-only check; nothing was called. |
unvalidated |
no validator | No automated validator exists for this secret type, or the pattern did not include a validator_ref. The key may or may not be live -- manual verification is needed. Most generic regex matches (e.g., Generic API Key) fall into this category. |
incomplete |
incomplete | A validator exists but requires additional context that was not found alongside the key. For example, AWS validation needs both the Access Key ID and Secret Key; Twilio needs both the Account SID and Auth Token. Only one half was discovered. |
skipped |
not checked | Validation was not attempted. This happens when: (1) the Validate Keys setting is disabled in project settings, or (2) the finding has low confidence and was excluded from validation to avoid false API calls. |
Findings written by an earlier release hold invalid, which mixed rejections with calls that never got an answer, and sometimes error. They keep that word until JS Recon runs again; the Priority Board still reads an invalid with a 401 as rejected and anything else as open, and a mute rule on invalid also matches rejected and format_invalid.
Discovers exposed source maps through three methods:
-
Comment parsing -- extracts
//# sourceMappingURL=and/*# sourceMappingURL=directives -
HTTP header checking -- looks for
SourceMapandX-SourceMapresponse headers -
Path probing -- tests 8 default paths (e.g.,
{url}.map,{url}.js.map) plus custom probe paths
When a source map is accessible, JS Recon:
- Extracts original source filenames (up to 100)
- Hands each of the target's own sources with text (not
node_modulesor the bundler runtime, which are public code full of example keys) to every module, as a file of its own named<map URL>#<source path>(for examplehttps://app.example.com/static/js/main.js.map#src/config.ts). A key insrc/config.tsbecomes a Secret with thatsource_url, under its own JS file node, and goes through validation like any other; afetch("/api/v2/orders")there becomes an Endpoint; DOM sinks and developer comments are found in the readable code. A source over 2 MB, and anything past 50 MB of recovered sources in one run, is not analyzed (the run records it). A framework that every original file imports is reported once per map - Reports the map itself as
source_map_exposure: low when it embeds the target's own source text, info when it lists only file paths, low when every source is a library.secrets_in_sourcecounts the Secrets found in its sources. Discovery has at most half of the JS Recon timeout, so the other modules always get their share - Reports a map that is referenced but not served as
source_map_reference(info) only when that says something: an access refusal (401 or 403), or a map on another host it could not or may not reach. A 403 needs corroboration, because S3 without ListBucket, and CloudFront in front of it, refuse keys that do not exist: a 403 carrying S3 markers (<Code>AccessDenied</Code>,Server: AmazonS3, anx-amz-*header) is not reported, and otherwise one made-up sibling (<map URL>.<random>) is requested; if it is refused alike the row is dropped. A sibling check that gets no answer keeps the row, markedcontrol_check: failed. No map is fetched from a host the Rules of Engagement exclude
Custom probe paths: Upload a TXT file with additional URL templates to test.
Extracts scoped npm packages (@scope/package) from JavaScript code using multiple patterns:
- ES6 imports (
import ... from '@scope/pkg') - CommonJS requires (
require('@scope/pkg')) - Dynamic imports (
import('@scope/pkg')) - Export statements (
export ... from '@scope/pkg') - Webpack chunk names
Each scoped package is checked against the public npm registry:
| Condition | Severity | Risk |
|---|---|---|
| Package not on public npm | Critical | Attacker can register the package name and inject malicious code |
| Package exists but marked "internal" | High | Verify ownership -- could be a squatted name |
Well-known public scopes (100+) are automatically filtered to reduce noise.
Custom packages: Upload a TXT file listing known internal package names to skip.
Deep pattern-based parsing extracts multiple endpoint types:
| Type | Detection Method |
|---|---|
| REST API |
fetch(), axios, $.ajax, XMLHttpRequest, Node http clients, superagent, and any receiver's .get/.post/.put/.patch/.delete/.head/.options (this.get("/v1/orders"), minified n.get("/users")), plus a request object passed to a call (r({url: "/api/export", method: "GET"})) |
| GraphQL | GraphQL endpoint URLs, introspection queries |
| WebSocket |
new WebSocket(), socket.io connections |
| Router definitions | React Router, Vue Router, Express route patterns |
| Config objects |
baseURL, apiUrl, serverUrl patterns |
| API documentation | Swagger, OpenAPI, GraphQL Playground paths |
| Admin/Debug | Admin panels, debug endpoints, internal tools (classified as HIGH severity) |
Endpoints are categorized (api, admin_debug, authentication, file_upload, etc.) and deduplicated by signature. Query parameters are extracted from URL patterns.
The URL may be a single-, double- or backtick-quoted string, after a base the code adds in front: fetch(API_BASE + "/v2/items") yields /v2/items. A template literal keeps its literal part: axios.get(`/api/users/${id}`) is stored as path /api/users/ with path_template /api/users/${id} on the Endpoint. A path without a leading slash (api.get("v1/accounts")) counts only from a call that is HTTP by name (fetch, axios, api, http, client); from any other receiver, map.get("key"), params.get("q") or headers.get("content-type") is a lookup, not an endpoint. Express-style app.get / router.get calls stay route definitions.
Tool miss: when 20 or more files were analyzed and not one endpoint came out, the app most likely calls its API in a way the extractor does not read. The run says so in its log, sets endpoint_extraction_miss in the JS Recon scan metadata, and records a js_endpoint_extraction note in the report's Coverage Limitations, so "no endpoints" is not read as "the app calls nothing".
An endpoint's HTTP method comes from its own call: the verb the call names (axios.post(...), client.put(...), xhr.open('PATCH', ...)), then a method: or type: key in that call's own options; a call that names neither is GET. Other code on the same line, such as a postMessage in a minified bundle, never changes it.
Endpoint validation (Validate Endpoints, off by default): each extracted endpoint on an in-scope host is probed once, with GET for a GET endpoint and HEAD or OPTIONS for any other method, so nothing is changed. An accepted status makes it hittable, but a server that answers every path alike (a single-page app returning its index page) would make every endpoint hittable. So a hittable endpoint is checked against a control: the same host is asked, the same way, for a made-up path next to it. When the made-up path gets the same answer the endpoint is catch_all and, like a not_hittable one, gets no Endpoint node. A control that cannot be sent or read keeps the endpoint hittable.
Custom keywords: Upload a TXT file with additional endpoint search keywords.
Framework detection identifies 12 built-in frameworks with version extraction:
React, Next.js, Vue.js, Nuxt, Angular, jQuery, Svelte, Ember.js, Backbone.js, Lodash, Moment.js, Bootstrap
DOM sink detection flags 15 XSS-vulnerable patterns:
| Sink Type | Examples |
|---|---|
| Direct injection |
innerHTML, outerHTML, document.write()
|
| Code execution |
eval(), Function(), setTimeout(string), setInterval(string)
|
| Navigation |
location.href, location.assign(), window.open()
|
| Prototype pollution |
__proto__, constructor.prototype, Object.assign misuse |
| React-specific | dangerouslySetInnerHTML |
Dev comment extraction finds TODO/FIXME/HACK comments containing sensitive keywords (password, secret, admin, bypass, hardcoded).
Custom frameworks: Upload a JSON file with additional framework signatures.
Detects AI/LLM signals inside every JavaScript file the previous modules already downloaded. Reads from recon/helpers/ai_signal_catalog.py — the same single source of truth used by every other Adversarial AI hook. Pure pattern matching; sends no additional traffic to the target.
Five new JsReconFinding.finding_type values:
finding_type |
What it catches | Typical severity |
|---|---|---|
ai-sdk-client |
Vendor SDK imports shipped to the browser. openai, @anthropic-ai/sdk, @google/genai, @langchain/* (12 sub-packages), @llamaindex/*, ai/react + @ai-sdk/* (12 providers), @modelcontextprotocol/sdk, Pinecone/Qdrant/Chroma/Weaviate clients, langfuse, langsmith, portkey-ai, ollama. 65 SDK families total. |
medium–high |
ai-sdk-key-literal |
Hard-coded provider API keys. Two tiers: prefix-anchored (OpenAI sk-proj-*T3BlbkFJ*, Anthropic sk-ant-api03-*AA, HuggingFace hf_*, Groq gsk_*, Replicate r8_*, Langfuse sk-lf-*, Pinecone pcsk_*, OpenRouter sk-or-v1-*, etc.) and constructor-context (paired with the SDK class to suppress false positives). Includes NEXT_PUBLIC_* env-var hydration leaks and Bearer <key>/x-api-key header literals. |
critical |
ai-sdk-browser-allowed |
The OpenAI/Anthropic SDK dangerouslyAllowBrowser: true escape hatch. Catches bareword JS, terser-minified !0, and JSON-stringified "dangerouslyAllowBrowser":true (the form seen in Next.js __NEXT_DATA__ blobs). |
critical |
ai-frontend-detected |
AI-product markers in shipped JS chunks the http_probe HTML/title channels cannot see (those live in async-loaded chunks). Open WebUI WEBUI_* constants, Gradio customElements.define("gradio-app",...), Streamlit stApp testids, Flowise chatflowid, LobeChat @lobehub/*, AnythingLLM, NextChat, BetterChatGPT, SillyTavern, ComfyUI, AUTOMATIC1111, InvokeAI, Jupyter-AI, Chainlit. |
medium–high |
ai-provider-url |
Provider base URLs hard-coded in bundles. OpenAI, Anthropic, Cohere, Gemini, Vertex AI, HuggingFace Inference, Replicate, Groq, Together, DeepSeek, Perplexity, Fireworks, Mistral, xAI, OpenRouter, AWS Bedrock regional endpoints, Azure OpenAI, Helicone/Portkey/Cloudflare AI gateways, Langfuse Cloud, LangSmith API. | medium |
Catalogue size: 164 patterns across the 5 channels (33 prefix-anchored keys + 18 constructor-context + 65 SDK imports + 3 browser flags + 23 frontend markers + 22 provider URLs).
Constructor-context suppression. The pattern new OpenAI({apiKey: "sk-proj-...T3BlbkFJ..."}) would match both the constructor-context regex AND the prefix-anchored regex. Without dedup that's two findings for one underlying problem. The matcher records each constructor match's byte range in a "claimed" set; subsequent prefix matches whose span falls inside a claimed range are suppressed. Result: exactly one finding per leaked key, attributed to its SDK (OpenAI SDK constructor rather than generic OpenAI API Key).
Gemini disambiguation. Google's AIzaSy[A-Za-z0-9_-]{33} key shape is shared by Gemini, Maps, Firebase, YouTube Data, Translate, and Cloud Functions. A naive match would flood reports with false positives. The catalogue scans ±2KB around each match for Gemini-specific tokens (@google/genai, @google/generative-ai, GoogleGenerativeAI, generativelanguage.googleapis.com, gemini-1.5, x-goog-api-key); if any are present the finding escalates to Google Gemini API Key / critical, otherwise it stays at Google API Key (likely Maps/Firebase) / medium.
Secret enrichment (value-prefixed reuse). When an ai-sdk-key-literal finding's captured value overlaps an existing Secret node (caught earlier by the legacy JS_SECRET_PATTERNS scan), the matching Secret gets ai_provider and ai_finding_id properties set. The Cypher dedup is gated on the Secret's matched_text starting with a known AI-key prefix (sk-, hf_, lsv2_, gsk_, r8_, pcsk_, pplx-, xai-, csk-, tgp_, pa-, AIzaSy, co_, rpa_, pk-lf-, fw_) so Stripe/Slack/AWS literals that happen to co-locate in the same JS file are never wrongly enriched. Existing "show me all leaked API keys" queries automatically gain AI context: MATCH (s:Secret) WHERE s.ai_provider IS NOT NULL.
Toggle. JS Recon → AI SDK Detection (default on). Disabling skips the pass entirely; existing js_recon findings are unaffected.
Example queries.
// Sites shipping an LLM SDK to the browser
MATCH (b:BaseURL)-[:HAS_JS_FILE]->(:JsReconFinding {finding_type:'js_file'})
-[:HAS_JS_FINDING]->(jf:JsReconFinding {finding_type:'ai-sdk-client'})
RETURN b.url, jf.sdk_name, jf.source_url AS js_file
ORDER BY b.url;
// CRITICAL: hard-coded keys in client-shipped JS
MATCH (jf:JsReconFinding {finding_type:'ai-sdk-key-literal', severity:'critical'})
RETURN jf.source_url, jf.sdk_name, jf.sample;
// Pivot from generic Secret to AI provider
MATCH (s:Secret) WHERE s.ai_provider IS NOT NULL
RETURN s.source_url, s.secret_type, s.ai_provider, s.validation_status;For the full catalogue rationale, signal channel architecture, and per-vendor cite list see Adversarial AI Recon → JS Recon AI SDK Detection.
JS Recon collects files from two sources:
- Pipeline output -- JS URLs discovered by Katana, Hakrawler, GAU, and jsluice during resource enumeration, and URLs HTTP probing found
-
Manual uploads -- files uploaded via the JS Recon Upload API (
/api/js-recon/{projectId}/upload)
The crawler's exclude list does not hide JS from JS Recon. Katana and Hakrawler drop URLs that match KATANA_EXCLUDE_PATTERNS, and the shipped list holds /static/, /_next/static, .chunk.js, .bundle.js, /dist/, /build/ and .min.js: exactly where React, Next.js and webpack apps keep their own code. Every in-scope JS URL a crawler saw is handed to JS Recon (and jsluice) before that filter. The exclude list still keeps those files out of the crawl's URL list and out of the Endpoint nodes. A partial JS Recon run reads the JS files the last run analyzed (its JS file nodes) as well as the graph's Endpoints.
Filtering rules:
- A JS file is one whose name ends in
.js,.mjs,.jsx,.tsor.tsx, also with a cache-busting suffix (app.js__v_3fa2,bundle.mjs~1,main.js-1a2b; the query string is ignored), but notapp.js.maporapp.js.gz. An extensionless name under/js/or/javascript/, or a URL HTTP probing saw answer with a JavaScriptContent-Type, counts too - At download, an HTML page is never analyzed: a single-page app answers a missing
.jswith its HTML shell and a 200. A JavaScript or ECMAScriptContent-Typeis accepted on any URL; otherwise the name must say JS (S3 and CDNs serve bundles asapplication/octet-stream) - Skipped:
node_modules/, CDN libraries, jQuery/Bootstrap minified files - Optional: framework chunks (
.chunk.js,.bundle.js), archived URLs from GAU/Wayback - A host the Rules of Engagement exclude is dropped, whichever source named it
Limits:
- Max files: 10,000 (
JS_RECON_MAX_FILES; 50 in stealth mode, and the memory governor can lower it). The budget is shared round-robin between hosts, so one large site cannot take it all, in a fixed order: within a host, the app's own entry points and page chunks (main,app,index,_app,pages/,chunks/) first, vendor and runtime files last. The JS Recon scan metadata records, per host, how many files there were, how many were taken and how many were cut, and a cut appears in the report's Coverage Limitations asjs_recon_file_cap - Max file size: 5 MB per file
- Download concurrency: 10 threads (configurable)
- Lines longer than 500k characters are skipped to prevent regex DoS
- Volume, measured: a 520-chunk Next.js-style site with a first-party source map per chunk (544 files, 21 MB, plus 1,046 recovered sources) is downloaded in seconds and analyzed in about a minute, at under 300 MB of memory
What a run could not re-check is kept. At the end of a run, js_recon findings it did not report again are removed, except where the run could not look:
- no JS file came from the pipeline although the target had live URLs (Resource Enumeration off or failed, a crawl that found nothing usable), or JS Recon crashed: every earlier js_recon finding is kept;
- the
JS_RECON_MAX_FILEScap cut the file list, or an analyzer failed or timed out: the findings on the hosts of the files it did not analyze are kept, and the hosts it did analyze are cleaned up as usual.
A run with no live target at all keeps nothing back. What was cut is listed in the report's Coverage Limitations.
What each host got is recorded on it. Every web host the run looked at (each live one HTTP probing answered, each one it was handed pages on, each one serving a candidate file) gets a coverage stamp on its BaseURL: complete with how many files it read and how many findings came out, no_input when the host served no JavaScript, partial with the reason (file cap, host unreachable, analyzer failed or timed out), or failed. A host the Rules of Engagement exclude gets none, and neither does one that answered only with a 5xx and served no candidate file. A partial JS Recon run stamps the hosts it analyzed even when it found nothing, so "analyzed, nothing found" stays apart from "never analyzed".
Three toggles control which JS files are collected beyond the default crawler output:
| Toggle | Default | Description |
|---|---|---|
| Include Webpack Chunks | On | Analyze .chunk.js and .bundle.js files that Katana may exclude. These contain application code with embedded secrets |
| Include Framework JS | On | Fetch Next.js (/_next/static/chunks/) and Nuxt.js (/_nuxt/) bundles that Katana excludes. Often contain API keys and Firebase configs |
| Include Archived JS | On | Analyze JS files that only web archives know (GAU, URLScan, ParamSpider). Old builds often contain hardcoded keys removed from production. Off, a JS URL that only an archive reported is left out; one a crawler or HTTP probing also saw is always analyzed. Needs GAU (or URLScan) on to have anything to add |
Upload JS files directly for analysis without crawling. This is useful for files obtained from:
- Burp Suite -- exported JS responses from intercepted traffic
- Mobile APKs -- extracted JavaScript bundles from hybrid mobile apps
- Browser DevTools -- saved JS files from authenticated areas
- Internal apps -- JS files from applications not reachable by crawlers
How to use:
- Open your project settings and expand the JS Recon Scanner section
- Scroll to Manual JS File Upload at the bottom
- Click Upload JS Files and select one or more files
- Uploaded files appear in a list with size and a delete button
Manual upload is only available in edit mode (after the project has been created).
Constraints:
| Rule | Value |
|---|---|
| Accepted extensions |
.js, .mjs, .map, .json
|
| Max file size | 10 MB per file |
| Multiple files | Yes (select multiple in the file dialog) |
| Storage location | /data/js-recon-uploads/{projectId}/ |
Uploaded files are automatically merged with pipeline-discovered JS files during the scan. They can also be analyzed standalone when JS Recon runs in manual_upload mode.
Five upload slots let you extend the built-in detection capabilities with target-specific data. These are additive -- they run alongside the defaults, never replacing them. Each upload type has client-side validation that runs before the file is sent to the server; if validation fails, a modal shows the exact error.
Custom extension files are only available in edit mode (after the project has been created). Click the Custom Extension Files collapsible header in the JS Recon settings to reveal all five slots.
Each upload slot has:
- An Upload button to select the file
- A display of the current file name and size (if uploaded)
- A Delete button to remove the current file
- A ? icon that opens a format guide modal with examples and how-it-works explanation
Purpose: Add your own regex patterns to detect company-specific secrets, internal API key formats, or custom tokens that the built-in 90+ patterns do not cover.
Accepted formats: .json or .txt
[
{
"name": "MyCompany API Key",
"regex": "MYCO-[a-f0-9]{32}",
"severity": "critical",
"confidence": "high"
},
{
"name": "Internal Service Token",
"regex": "svc_tok_[A-Za-z0-9]{40}",
"severity": "high",
"confidence": "medium"
}
]| Field | Type | Required | Values |
|---|---|---|---|
name |
string | Yes | Display name for the pattern |
regex |
string | Yes | Python-compatible regex (compiled via re module) |
severity |
string | No |
critical, high, medium, low, info (default: medium) |
confidence |
string | No |
high, medium, low (default: medium) |
One pattern per line, pipe-delimited:
MyCompany API Key|MYCO-[a-f0-9]{32}|critical|high
Internal Token|svc_tok_[A-Za-z0-9]{40}|high|medium
# Lines starting with # are comments
Field order: name | regex | severity | confidence (severity and confidence are optional).
-
JSON: Must be a non-empty array. Each item must have
name(string) andregex(string). Each regex is compiled to verify syntax.severitymust be one ofcritical/high/medium/low/info.confidencemust be one ofhigh/medium/low. -
TXT: Each non-comment line must have at least 2 pipe-separated fields (
name|regex). Each regex is compiled to verify syntax.
Each pattern is compiled as a Python regex and applied line-by-line to every downloaded JS file. When a match is found, a finding is created with the specified severity and confidence. The matched text is redacted in the output (first 6 + last 4 chars shown). Patterns with a high false-positive rate should use confidence low.
Regex syntax: Patterns run in Python (
remodule), not JavaScript. Avoid JS-only syntax like(?<name>...)named groups -- use(?P<name>...)or plain capture groups instead.
Purpose: Add extra URL path templates to probe when looking for .map source map files. The scanner already tries 8 default paths. Use this to add paths specific to your target application's build tool or CDN structure.
Accepted format: .txt
One path template per line:
{base}/assets/maps/{filename}.map
{base}/sourcemaps/{filename}.map
{base}/build/static/js/{filename}.map
{base}/_assets/{filename}.map
# Lines starting with # are comments
| Variable | Expands To | Example |
|---|---|---|
{url} |
Full JS file URL | https://example.com/js/app.js |
{base} |
Scheme + host | https://example.com |
{filename} |
JS filename only | app.js |
- File must contain at least one non-comment line
- Each line must contain either a
{template variable or a/path separator
For each downloaded JS file, the scanner first checks for a sourceMappingURL comment and SourceMap HTTP header. If neither is found, it probes each path template by replacing {url}, {base}, and {filename} with actual values and making an HTTP GET request. If a valid source map JSON (with "version" and "sources" fields) is returned, the scanner parses it, extracts original source filenames, and hands each first-party source in sourcesContent to every analysis module as a file of its own (see Source Map Discovery above).
Purpose: List known internal/private npm package names used by the target organization. These are ALWAYS checked against the public npm registry, even if not found in the JS code via import/require statements. This is useful when minified JS strips import names.
Accepted format: .txt
One scoped package name per line:
@mycompany/auth-sdk
@mycompany/api-client
@mycompany/shared-utils
@internal/config
@targetcorp/payment-lib
# Lines starting with # are comments
- File must contain at least one non-comment line
- Every line must use the
@scope/nameformat (must start with@and contain/) - Well-known public scopes (
@types,@babel,@angular,@vue,@react, etc. -- 100+ scopes) are automatically skipped during scanning
For each package name, the scanner makes a GET request to https://registry.npmjs.org/{package}:
| npm Registry Response | Finding Severity | Meaning |
|---|---|---|
| 404 (not found) | Critical | Package does not exist on public npm. An attacker could register it and inject malicious code when the target runs npm install
|
| 200 (exists) | High | Package exists but is listed as "internal." Verify ownership -- could be a squatted name |
Results appear in the Dependencies tab of the JS Recon dashboard.
Purpose: Add extra keywords to search for in JavaScript content. When a keyword is found inside a quoted string in the JS code, the surrounding URL is extracted as a discovered endpoint. Use this for target-specific API paths that the built-in patterns might miss.
Accepted format: .txt
One keyword per line:
/internal-api/v2/
/backoffice/
mycompany-service
admin-panel
graphql-gateway
/legacy/api/
# Lines starting with # are comments
- File must contain at least one non-comment line
- Each keyword must be at least 2 characters long
- Use path fragments like
/internal-api/for precision - Use service names like
mycompany-servicefor broader matching - Avoid very short keywords (< 4 chars) to reduce false positives
For each keyword, the scanner searches all JS file content using a case-insensitive regex. When a match is found, it extracts the surrounding quoted string (the URL/path containing the keyword). Each discovered URL is classified by category (admin, debug, auth, api, etc.) and assigned a severity. Results appear in the Endpoints tab of the JS Recon dashboard.
Purpose: Add detection signatures for custom or internal JavaScript frameworks not covered by the 12 built-in ones. Each signature defines regex patterns that identify the framework and optionally extract its version.
Accepted format: .json
[
{
"name": "MyCompanyFramework",
"patterns": [
"MyFramework\\.init",
"__MY_FRAMEWORK__",
"myfw-version"
],
"version_regex": "MyFramework\\.version\\s*=\\s*[\"']([0-9.]+)[\"']"
},
{
"name": "InternalRouter",
"patterns": [
"InternalRouter\\.navigate",
"__INTERNAL_ROUTER__"
],
"version_regex": null
}
]| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Display name for the framework |
patterns |
string[] | Yes | Non-empty array of regex strings. If any pattern matches, the framework is detected |
version_regex |
string or null | No | Regex with capture group 1 for version extraction. Set to null if not needed |
Since regexes live inside JSON strings, backslashes need double-escaping:
| Regex Intent | In JSON | Notes |
|---|---|---|
| Literal dot | \\. |
One backslash + dot in JSON string |
Whitespace \s
|
\\s |
One backslash + s in JSON string |
| Quote in regex |
[\"'] or ['\"]
|
Escaped quote inside JSON |
- Must be a non-empty JSON array
- Each item must have
name(string) andpatterns(non-empty array of strings) - Every pattern string is compiled as a regex to verify syntax
-
version_regex(if not null) is compiled as a regex to verify syntax
Each signature's patterns are compiled as Python regexes (re module) and searched in the JS file content. If any pattern matches, the framework is detected. The version_regex (if provided) is then used to extract the version number from capture group 1. Detected frameworks appear in the Security tab of the JS Recon dashboard under "Frameworks." Version information enables targeted CVE lookups.
Regex syntax: Use Python regex syntax. Avoid JS-only features like
(?<name>...)named groups -- use(?P<name>...)instead.
JS Recon extracts hostnames from discovered endpoints, source map URLs, and cloud asset URLs. New in-scope subdomains are merged back into combined_result['dns']['subdomains'] with source='js_recon', making them available to downstream pipeline modules.
JS Recon creates and updates the following Neo4j node types:
| Node Type | Relationship | Created When |
|---|---|---|
JsReconFinding (finding_type='js_file') |
(BaseURL)-[:HAS_JS_FILE]->(JsReconFinding) (or Domain for uploaded files) |
One per analyzed JS file; acts as the parent for all other findings |
| JsReconFinding (finding types below) | (JsReconFinding{js_file})-[:HAS_JS_FINDING]->(JsReconFinding) |
Dependency confusion, source maps, DOM sinks, frameworks, dev comments, emails, internal IPs, object references (UUID/IDOR candidates), cloud assets |
JsReconFinding (finding_type='external_domain') |
(Domain)-[:HAS_JS_FINDING]->(JsReconFinding) |
3rd-party domains leaked in JS URLs (no single parent JS file) |
| Secret | (JsReconFinding{js_file})-[:HAS_SECRET]->(Secret) |
Discovered secrets (with source='js_recon') |
| Endpoint | (JsReconFinding{js_file})-[:HAS_ENDPOINT]->(Endpoint) |
Discovered endpoints (with source='js_recon' or js_recon_source=true) |
All nodes include user_id and project_id for multi-tenant isolation. Graph updates run in a dedicated background thread.
JsReconFinding finding_type values: js_file, dependency_confusion, source_map_exposure, dom_sink, dev_comment, framework, email, internal_ip, object_reference, cloud_asset, external_domain, plus the Phase 6 AI SDK values: ai-sdk-client, ai-sdk-key-literal, ai-sdk-browser-allowed, ai-frontend-detected, ai-provider-url
AI SDK findings carry extra properties beyond the core set: sdk_name (canonical vendor product name), ai_provider (mirror of sdk_name for prefix-consistent queries), sample (redacted form of captured key — first 6 chars + ... + last 4, never the full secret), byte_offset (stable across re-scans for idempotent MERGE), detection_method='ai_sdk_catalogue'.
Secret enrichment. For ai-sdk-key-literal findings, the Phase 6 mixin also enriches matching Secret nodes (caught by the legacy JS_SECRET_PATTERNS scan) with ai_provider and ai_finding_id. This means existing "all leaked secrets" queries automatically gain AI context without a parallel taxonomy.
JsReconFinding core properties: id, finding_type, severity, confidence, title, detail, evidence, source_url, base_url, source, discovered_at, user_id, project_id, updated_at
JsReconFinding type-specific properties:
-
cloud_asset->cloud_provider(aws/gcp/azure),cloud_asset_type -
external_domain->times_seen,sample_urls -
object_reference->potential_idor(boolean; heuristic flag, not actual IDOR detection -- only means the value matched a UUID v4 pattern and could be worth testing manually)
- Secret redaction -- plaintext secret values are never stored; only redacted form (first 6 + last 4 characters) persists in output and graph
- Rate limiting -- per-service 1 req/sec rate limit during secret validation
- File size limits -- 10 MB per JS file download, 10 MB per manual upload
-
Project isolation -- uploaded files stored in
/data/js-recon-uploads/{projectId}/ - Regex DoS prevention -- lines exceeding 500k characters are skipped
| Method | Path | Description |
|---|---|---|
| GET | /api/js-recon/{projectId}/upload |
List uploaded JS files |
| POST | /api/js-recon/{projectId}/upload |
Upload a JS file for analysis (max 10 MB; .js, .mjs, .map, .json) |
| DELETE | /api/js-recon/{projectId}/upload?name=filename.js |
Delete an uploaded file |
| GET | /api/js-recon/{projectId}/download |
Download JS Recon scan results |
JS Recon results are displayed in the Red Zone via a dedicated table component with six sub-tabs:
| Tab | Content |
|---|---|
| Secrets | Severity badges, redacted values, validation status, confidence, category |
| Endpoints | Method, path, full URL, type (REST/GraphQL/WS), category, source JS file |
| Dependencies | Package name, scope, npm registry status, severity, recommendation |
| Source Maps | JS URL, map URL, accessible status, discovery method, file count, embedded secrets |
| Security | Frameworks (name, version), DOM sinks (type, pattern, severity), dev comments, IDOR candidates |
| Attack Surface | New subdomains, cloud assets (S3/GCP/Azure), emails, internal IPs, external domains |
Features: full-text search across all fields, pagination (50 items/page), and three export formats — XLSX (one worksheet per category), JSON (object keyed by section name), and MD (one ## Section per category, GFM table per section). See Data Export & Import for format details.
See Project Settings Reference > JS Reconnaissance for the complete parameter table.
Quick overview:
| Setting | Default | Purpose |
|---|---|---|
| JS_RECON_ENABLED | false | Master toggle |
| JS_RECON_MAX_FILES | 500 | Max JS files to download |
| JS_RECON_CONCURRENCY | 10 | Download threads |
| JS_RECON_TIMEOUT | 900 | Total timeout (seconds) |
| JS_RECON_REGEX_PATTERNS | true | Secret pattern scanning |
| JS_RECON_SOURCE_MAPS | true | Source map discovery |
| JS_RECON_DEPENDENCY_CHECK | true | npm dependency confusion |
| JS_RECON_EXTRACT_ENDPOINTS | true | Endpoint extraction |
| JS_RECON_DOM_SINKS | true | DOM sink detection |
| JS_RECON_FRAMEWORK_DETECT | true | Framework detection |
| JS_RECON_DEV_COMMENTS | true | Dev comment extraction |
| JS_RECON_VALIDATE_KEYS | true | Live secret validation |
| JS_RECON_MIN_CONFIDENCE | low | Minimum confidence filter |
The full JS Recon output is stored in combined_result['js_recon'] and includes:
js_recon/
scan_metadata/ -- timestamp, mode, file count, duration
secrets[] -- id, name, severity, redacted_value, validation status
endpoints[] -- id, method, path, type, category, source_js
dependencies[] -- package_name, scope, npm_exists, severity
source_maps[] -- js_url, map_url, accessible, source_files
dom_sinks[] -- type, pattern, severity, line
frameworks[] -- name, version, confidence
dev_comments[] -- content, type, severity, line
cloud_assets[] -- provider, type, url
emails[] -- email, category, context
ip_addresses[] -- ip, type (private), context
object_references[] -- type (uuid), value, potential_idor
discovered_subdomains[] -- new in-scope subdomains
external_domains[] -- out-of-scope domains with frequency
summary/ -- aggregated counts by severity, type, and category
| Mode | Trigger | File Source |
|---|---|---|
| post_recon | Pipeline GROUP 5b (automatic) | JS URLs from resource_enum + http_probe, merged with any uploaded files |
| standalone | Manual trigger via orchestrator | Crawls target domain then analyzes (bypasses pipeline) |
| manual_upload | Uploaded files present during post_recon | Uploaded files merged with pipeline-discovered URLs |
JS Recon runs independently of resource enumeration. When resource_enum is active, pipeline-discovered JS URLs are merged with any uploaded files. When resource_enum is disabled, JS Recon analyzes uploaded files only. If neither source has files, JS Recon exits gracefully with zero findings.
- Running Reconnaissance -- see how JS Recon fits in the full pipeline (GROUP 5b)
- Attack Surface Graph -- understand the JsReconFinding node type
- Project Settings Reference -- complete parameter reference
- Red Zone -- explore JS Recon findings in the graph and data table
Getting Started
- Getting Started
- Deploying to a Server
- User Management & Roles
- Creating a Project
- Recon Presets
- Global Settings
Core Workflow
- Red Zone
- Recon Pipeline Workflow
- Running Reconnaissance
- Scan Timeline
- AI Agent Guide
- Fireteam — Parallel Specialists
- Exploit-Path Search (LATS)
- Agent Workspace
- Reverse Shells
Scanning & OSINT
- AI in the Recon Pipeline
- Adversarial AI Recon
- AI Gauntlet
- JS Reconnaissance
- GraphQL Security Testing
- Subdomain Takeover Detection
- VHost & SNI Enumeration
- TLS Certificate Grab
- Web Cache Poisoning
- Serialized Object Detection
- Origin Discovery
- GVM Vulnerability Scanning
- GitHub Secret Hunting
- Secret Multiscanner
- Supply-Chain Scanning
AI & Automation
- AI Model Providers
- MCP Tool Plugins
- MCP Server
- Knowledge Base & Web Search
- Agent Skills
- Chat Skills
- Tradecraft Lookup
- CVE Intel
- Playwright Browser Automation
- CypherFix — Automated Remediation
- Priority Board
- Rules of Engagement (RoE)
Plugins
HackLab
Analysis & Reporting
- Insights Dashboard
- TrafficMind
- Authenticated Session Recording
- proxy_brain — web hacking in code
- Pentest Reports
- Attack Surface Graph
- Surface Shaper
- EvoGraph — Attack Chain Evolution
- Data Export & Import
Contributing
Reference & Help