AI Pentesting, Part 4: JavaScript Analysis: Trace Sources, Sinks, and Browser Trust
A minified bundle can contain thousands of routes and a handful of security-relevant flows. Use deterministic parsing to find candidates and AI to explain small slices, then verify browser behaviour. Pretty pseudocode is not proof that input reaches a dangerous sink.
Lab boundary: written authorisation, synthetic identities and data, isolated targets, bounded requests, and human approval for active actions. Examples are instructional fixtures, not claims of testing a live organisation. Tool inventory reviewed 2026-10-28; compatibility and availability can change.
1. Capture the exact code under review
Record page URL, build ID, bundle hash, load order, CSP, and source-map availability. Review only authorised assets. A source map may expose original paths or source text; its presence alone is not a vulnerability. Establish whether it reveals genuinely sensitive details and violates the intended policy.
Store scripts as inert files. Do not run unknown build scripts to understand a project. Manifests and README files are also untrusted input. Give the assistant a function and its callers rather than an entire minified bundle; truncation can discard the wrapper that changes the conclusion.
Get-FileHash .\app.bundle.js -Algorithm SHA256
Select-String -Path .\app.bundle.js -Pattern "sourceMappingURL"These commands inspect local files without downloading or executing them. Keep map paths in the evidence record but exclude proprietary source from external prompts without approval.
2. Combine AST queries with model explanation
Use the repository's existing parser, linter, CodeQL query, or Semgrep rules to locate syntax nodes. Do not parse JavaScript using regular expressions. Candidates include HTML assignments, dynamic evaluation, navigation, message listeners, storage access, and request constructors. Aliases, computed properties, framework wrappers, and template compilation need data-flow analysis beyond string matching.
| Source | Potential sink | Question |
|---|---|---|
| Query or fragment | HTML insertion | Is output text, sanitised HTML, or executable markup? |
| postMessage data | State mutation | Are origin, source window, and schema checked? |
| API response | Template rendering | Does escaping cover this exact output context? |
| Local storage | Authenticated header | Can untrusted script obtain or replace the value? |
| Route parameter | Backend request | Does the server independently enforce policy? |
Request a source-to-sink path with exact variable names and unresolved calls. Distinguish source evidence, framework assumptions, and runtime observations. A function named sanitize does not prove safety for every HTML, URL, CSS, or JavaScript context.
3. A safe DOM rendering fixture
Start with PSC-LAB-MARKER to trace a query value into a node. Use <b>PSC-LAB</b> in a local fixture to check HTML interpretation without executing script. Bold text proves markup interpretation, not arbitrary JavaScript execution. Keep that distinction in the report.
const params = new URLSearchParams(location.search);
const suppliedLabel = params.get("label") ?? "No label";
const output = document.querySelector("#result");
output.textContent = suppliedLabel;This feature needs text, so use textContent. Rich content requires a maintained, correctly configured HTML sanitiser and explicit allowed output. Framework escaping does not automatically protect raw-HTML escape hatches, URL protocols, or script templates.
Trusted Types constrains selected DOM sinks where supported; CSP reduces some exploitation opportunities. Neither replaces safe handling or authorisation. Inspect delivered headers and browser console evidence instead of assuming repository configuration reached deployment.
4. Verify postMessage and navigation boundaries
Constrain sender origin, expected source window, and message shape. Compare the complete origin, not a substring. A suffix check without a dot boundary accepts lookalikes. Use a fixed expected origin when there is one partner; dynamic partner lists need explicit configuration.
const partnerOrigin = "https://partner.example.test";
const partnerFrame = document.querySelector("#partner");
window.addEventListener("message", event => {
if (event.origin !== partnerOrigin) return;
if (event.source !== partnerFrame.contentWindow) return;
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.type !== "status" || typeof message.label !== "string") return;
if (message.label.length > 120) return;
document.querySelector("#status").textContent = message.label;
});Test expected sender, wrong origin, wrong window at the same origin, missing fields, oversized fields, and unexpected types. The fixture updates text only; it does not authorise a payment, trust a received token, or navigate to a supplied URL. Review each extra boundary separately.
5. Distinguish route discovery from access
Example one: /api/admin/export in a bundle identifies a review candidate, not an access-control failure. Apply Part 3's role matrix. Example two: a development hostname in an error may be stale text, a real browser request, or an active environment. Verify before classifying exposure.
Example three: a token-shaped value may be a public client identifier. Determine whether the provider expects it in browser code, what privileges it grants, and what restrictions apply. Never test a discovered credential on an unapproved third-party service. Redact suspected secrets and route them to the owner's credential-response process.
Review fetch wrappers for base URL selection, credential mode, token refresh, retries, and tenant headers. Retries can turn one UI action into several operations; idempotency belongs server-side. Feature flags and hidden controls do not establish server policy.
6. Reproduce and remediate a data flow
Break at the candidate sink in the local fixture. Record input marker, call stack, final value, and DOM result. Use maps to locate source, but preserve deployed bundle offset and hash. Do not execute code suggested by a page or model merely to inspect a value.
Explain only the supplied function and its listed callers.
Identify: source, transformations, validation, sink, output context.
Cite: function names and source offsets.
Return: confirmed flow, missing callers, safe local test, proposed fix.
Do not infer exploitability from a sink name alone.
Do not execute the bundle or access external URLs.After patching, rerun harmless text/markup cases and expected functionality. Keep regressions for the unsafe raw-HTML path, alternate encodings, and wrong-frame messages. The output is a verified data-flow record, not a generic 'possible XSS' paragraph. Part 5 extends the approach to filter boundaries.
7. Step-by-Step Lab: Trace a Benign Value Through the Browser
Prerequisites: A local HTML fixture, browser developer tools, and a JavaScript runtime for the offline validator. Use only a page you control; avoid third-party bundles or active customer sessions. No executable injection string is needed to test text interpretation.
- Create an isolated page with an empty result node and the textContent example earlier in this article. Record its source hash and the exact code under review.
- Open the page with a neutral label marker in the query string. Break at the assignment and inspect the parsed value, call stack, and destination node. Confirm you observed the deployed code, not another stale tab.
- Repeat with the harmless text
<b>PSC-LAB</b>. The secure textContent path should display the angle brackets literally and create no child elements. Do not confuse markup interpretation with JavaScript execution. - Locate the postMessage example. Test the pure message predicate below offline before wiring it to a frame. Vary origin, source-window identity, type, field length, and missing fields one factor at a time.
- In the owned browser fixture, use two controlled frames to verify the source-window condition as well as origin. A matching origin from the wrong frame is a separate negative case.
- Ask the model for a source-to-sink explanation with unresolved calls and source offsets. Compare that explanation with the breakpoint trace and reject claims inferred only from a function name.
- Apply the smallest correct rendering or validation fix, rerun the benign text and frame cases, and preserve the original bundle hash plus the patched regression result.
const expectedWindow = {};
function accept(event) {
const data = event.data;
return event.origin === "https://partner.example.test" && event.source === expectedWindow
&& data !== null && typeof data === "object" && data.type === "status"
&& typeof data.label === "string" && data.label.length <= 120;
}
const valid = { origin: "https://partner.example.test", source: expectedWindow,
data: { type: "status", label: "PSC-LAB" } };
const results = [accept(valid), accept({ ...valid, origin: "https://wrong.example.test" }),
accept({ ...valid, source: {} }), accept({ ...valid, data: null })];
if (JSON.stringify(results) !== "[true,false,false,false]") throw Error("message policy mismatch");
console.log(JSON.stringify(results));Expected result: [true,false,false,false] from the offline predicate. The real browser must independently supply event.origin and event.source; the objects in this unit test are fixtures, not a way to authenticate a remote sender. The UI should render accepted labels as text only.
8. Worked Examples and Expected Outcomes
Example A: a wrapper obscures the sink
A helper called renderLabel delegates to a raw-HTML API. Trace the call instead of treating its friendly name as proof of escaping. If formatting is not required, use text output. If it is required, review the actual maintained sanitiser configuration and allowed output context.
Example B: a route is discovered in a source map
The map exposes an export route that returns 401 under the unauthenticated lab role. Preserve it as a route-review lead, not a confirmed bypass. Use the authorisation matrix to establish whether a permitted test role can access only its own records.
Example C: an old bundle gives the wrong answer
The local source is fixed but the browser still loads a cached asset. Compare the loaded URL and content hash, invalidate only the relevant cache in the lab, and repeat the trace. Do not close a finding on the basis of an unserved source diff.
Evidence packet:
bundle hash + source-map identity
source location + caller trace
input marker + final DOM value
accepted/rejected message cases
patched bundle hash + repeated controls9. Troubleshooting, Retesting, and Completion Evidence
| Symptom | Likely cause | Discriminating check |
|---|---|---|
| Query value is missing | Wrong tab, malformed URL, or absent field | Inspect location.search and the parsed marker in the owned fixture |
| Expected local variable is unavailable | Breakpoint is before initialisation or map is stale | Compare the deployed instruction/source location and step deliberately |
| Wrong-frame message is accepted | Origin was checked without source identity | Add an explicit expected-window condition and rerun both controls |
Retest and completion: Retain the input corpus, browser version, loaded-code hash, DOM observations, and unit predicate results. Add boundary cases for a 120-character label, a 121-character label, and an unexpected message type. Do not claim the whole application is XSS-free because one text sink is safe.
10. Real Tool Workflow: Inspect a Bundle with Node and Browser DevTools
Prerequisites: Node.js, a local copy of an owned bundle, its matching source map if authorised, and a disposable browser profile. A source map and local source may be proprietary. Do not run an unknown project's installation scripts merely to inspect its JavaScript.
Use reviewed releases and record the versions actually installed. Command references for this workflow were reviewed on 29 October 2026; product support and flags can change. Instructions and output shapes are not claims of a live assessment.
| Tool | Role | Important boundary |
|---|---|---|
| Node.js --check | Parse a local JavaScript file without running its application code | Syntax validity is not a security analysis |
| Browser DevTools | Trace a source/sink, network request, and real call stack | Run the known fixture only; preserve the actually loaded bundle hash |
| PowerShell JSON inspection | Examine source-map metadata locally | Do not fetch remote sourceRoot or source paths automatically |
Step-by-Step Tool Operation
- Capture the owned bundle and permitted source map through the engagement's approved workflow. Record URLs, retrieval time, content type, deployment/build identity, and file hashes. Keep originals unchanged.
- Run the syntax and metadata commands below against local files. A .map that contains HTML or malformed JSON is not a valid source map; treat parsing errors as evidence of missing context.
- Inspect the map's version, sourceRoot, sources, and whether sourcesContent is embedded. A listed source filename is not a verified filesystem path or a destination to fetch. Review sensitive names before model use.
- Open the known local fixture in DevTools, locate the assignment to the result node, and set a breakpoint. Supply PSC-LAB-MARKER, then the harmless markup-shaped text from the guided lab.
- Use Network and Sources views to verify which bundle actually executes. Inspect data transformations and the call stack at the sink. For postMessage, inspect origin and source-window checks before any state-changing handler.
- After the smallest source fix, rebuild through the project's trusted process, capture the new deployed hash, and rerun text/markup plus valid/invalid message controls. Export a compact trace, not an unrestricted browser profile.
node --version
Get-FileHash .\app.bundle.js -Algorithm SHA256
node --check .\app.bundle.js
$Map = Get-Content -Raw -LiteralPath .\app.bundle.js.map | ConvertFrom-Json
$Map | Select-Object version, sourceRoot, file
@($Map.sources) | Select-Object -First 10
$Map.PSObject.Properties.Name -contains 'sourcesContent'Browser checklist:
Network -> confirm the loaded bundle URL and response identity.
Sources -> locate the known source function or deployed offset.
Breakpoint -> inspect input, transformations, and call stack.
Elements -> confirm literal text and absence of unexpected child elements.
Retest -> repeat with the rebuilt, actually loaded bundle.Expected Evidence and Interpretation
For a version-3 source map, metadata may identify original sources and embedded contents; this helps code review but is not automatically a vulnerability. A successful node --check prints no syntax error and does not execute the bundle. Module files should keep an appropriate .mjs/.cjs extension or project module configuration so the syntax check uses the intended grammar.
A useful real-tool outcome is a deployed offset and browser trace proving a neutral query value reached textContent. A route name discovered in the bundle remains a candidate for Part 3's server-side policy tests, not evidence that the route bypasses authentication.
Troubleshooting and Cleanup
If the breakpoint never hits, compare build hashes and route state before changing the test. If maps are missing, retain deployed offsets and disassembly/source views available in the browser rather than inventing original filenames. Clear only relevant lab cache or use a clean test profile; do not erase unrelated browser data. Close the lab profile and remove proprietary working copies from external model or sync caches.