Runtime instrumentation companion to
script2builtins. Drives a real Chromium against a
URL, traps every catalog API + every network sink + every dynamic-execution
point, and emits findings in the same shape the static analyzer produces.
The static analyzer is fast and cheap but has four hard-baked blind
spots: code inside eval / Function strings, fully dynamic property
keys, Reflect.get / descriptor-getter trampolines, and anti-debug
checks. This package closes all four by running the script in an
instrumented browser and emitting the same RawAccess / NetworkSink
structs the static pass produces, so the two reports compose into one.
npm install -g script2builtins-runtime
# dynamic mode only — skip if you'll only run static analysis:
npm install -g playwright
npx playwright install chromiumRequires Node 20+. playwright is a peer dependency — install it
yourself only when you need dynamic mode. Static-only users can skip it
and avoid the ~300 MB browser download. Installing this package
transitively installs script2builtins, so you don't need both — the
unified s2b CLI ships here.
One CLI, dispatched by what you give it. Static mode never launches a browser:
s2b detector.js # static (file)
s2b - # static (stdin)
s2b detector.js --dynamic # wrap file in HTML harness, drive it
s2b https://target.example/ # dynamic (browser + traps + auto-static
# on every captured script)
s2b https://target.example/fp.js --static-only
# fetch URL, run static, no browserCommon flags: --json, --out <dir>, --min-severity,
--no-color. Dynamic-only: --headless, --nav-timeout, --idle,
--ua, --harness-mode data|file|http-harness, --trap-reflect-get.
s2b --help for the full list.
When running against a file (s2b detector.js --dynamic), pick how the
harness HTML is served:
| mode | origin | when to use |
|---|---|---|
data (default) |
opaque data: |
cheapest; storage APIs behave differently from a real site |
file |
opaque file:// |
want relative imports to resolve from disk |
http-harness |
real http://127.0.0.1 |
the script needs cookies, localStorage, or same-origin fetches |
The HTTP server is started per-run on an ephemeral port and shut down on completion.
Same import surface for both modes:
import { analyze, run, analyzeUrl, renderRuntimeText } from "script2builtins-runtime";
// Static — no browser
const r1 = analyze(source, { name: "detector.js" });
// Static on a URL — fetch + analyze, no browser
const r2 = await analyzeUrl("https://example.com/fp.js");
// Dynamic — drives a browser, also runs static on every captured script
const r3 = await run({
url: "https://target.example/",
outDir: "./runs/automated",
headless: true,
});
console.log(renderRuntimeText(r3, { minSeverity: "medium" }));
for (const f of r3.findings) {
if (f.provenance === "runtime" && f.api.botDetectionTell) {
console.log("RUNTIME-ONLY TELL:", f.api.key, f.callSites, "sites");
}
}
// Inspect runtime exfiltration: now populated thanks to the runtime
// body re-parser (parseRuntimeBody from script2builtins/analyze).
for (const s of r3.reconstructedSinks) {
for (const a of s.payload?.leakedApis ?? []) {
console.log("LEAK", s.kind, s.url, "→", a.key);
}
}A few opt-in / opt-out levers tune the trap surface:
channelName— the trap installs its drain channel under a randomwindow.__s2b_<6 hex bytes>per attach.Session.channelNameexposes the chosen name. Override withattach({ channelName: "…" })for tests / external observation.trapWorkers(defaulttrue) — wraps classicnew Worker(url)to bootstrap the trap inside worker scope viaimportScripts(<trap blob>). Module workers andSharedWorkerpass through unchanged.trapReflectGet(defaultfalse) — wrapsReflect.getso introspection trampolines that hold non-Proxy root references still surface accesses. Off by default because engine internals callReflect.getheavily. Enable for high-coverage forensic runs:s2b <url> --trap-reflect-get.trapDynamicExec(defaulttrue) —eval,Function,setTimeout("string", …),setInterval("string", …).useProxyRoots(defaulttrue) — install root Proxies for the curatednavigator,screen,document, … set. Set false to fall back to descriptor-only patching.hardenIntrospection(defaulttrue) —Function.prototype.toStringmasking so wrapped functions still look native.
Each entry in RuntimeReport.scripts carries an acquisition tag:
network— fetched as a JS response (text/javascript,.js, etc.).inline—<script>tag without asrcattribute.srcdoc— inline<script>inside an<iframe srcdoc>attribute.eval/function-ctor/settimeout-string— code captured from the dynamic-execution traps.
summary.networkScripts, inlineScripts, srcdocScripts, and
evalScripts are the headline counts.
Full docs live in docs/ and are served via GitHub Pages at
https://yourorg.github.io/script2builtins-runtime/.
- Architecture
- Execution flow — full processing diagrams
- Trap internals
- Static vs runtime
- Recipes
- Catalog reference
- Limits
- Design review — decisions, smells, rationale
See ROADMAP.md for phase status, open design
questions, and per-task tracking.
MIT