diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 74bd071a..378902ed 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,7 +43,7 @@ all of them. ┌───────── Frontends (in-process Go, one binary) ─────────┐ Go ────► x/tools SSA ───┐ Python ──► python3 ast ──┤ - JS ────► goja AST ──┤ + JS ────► esbuild AST ──┤ Java ────► JVM bytecode ──┤─► lower ─► gIR (core + intrinsics, canonical FQNs) Rust ────► rustc MIR ──┤ │ Ruby ────► ruby Ripper ──┤ │ @@ -177,14 +177,16 @@ Python/Java/Rust/Ruby shelling out to a toolchain on `PATH`. lives in `http.HandleFunc` closures. Emits `go:` names. - **Python** (`converters/python/`) — shells out to `python3` for an `ast` JSON dump, then lowers it to a real CFG (`ssabuild`). Emits `py:` names; requires `python3`. -- **JavaScript** (`converters/javascript/`) — pure-Go parse via **goja**, then - lowers. TS/JSX/ESM are stripped/lowered in-process by esbuild (no Node), with source - maps remapping positions back; a plain `.js` is handed to goja first and only falls - back to esbuild when the parse fails, since one extension covers four dialects. - Flow, which esbuild cannot load at all, is blanked in place beforehand - (`flowstrip.go`) so byte offsets — and therefore positions — are preserved. `.vue`/`.svelte` SFCs are also - handled (`sfc.go`): the ` + + diff --git a/test/js/crossfile_sfc_import/app.js b/test/js/crossfile_sfc_import/app.js new file mode 100644 index 00000000..da083ae5 --- /dev/null +++ b/test/js/crossfile_sfc_import/app.js @@ -0,0 +1,13 @@ +// Cross-file taint into a sink inside a .vue component, imported by a specifier +// that keeps its extension (see expected.yaml). +import express from "express"; +import render from "./Renderer.vue"; + +const app = express(); + +app.get("/r", function (req, res) { + const id = req.query.id; // source + res.send(render("SELECT * FROM users WHERE id = " + id)); // cross-file call into Renderer.vue +}); + +export default app; diff --git a/test/js/crossfile_sfc_import/expected.yaml b/test/js/crossfile_sfc_import/expected.yaml new file mode 100644 index 00000000..32490b60 --- /dev/null +++ b/test/js/crossfile_sfc_import/expected.yaml @@ -0,0 +1,10 @@ +# Expected findings for this sample (see test/README.md). +# Source in app.js, sink in Renderer.vue: fires only if `./Renderer.vue` resolves +# to the module the walk named `Renderer`. When it does not, both files still +# convert and coverage still reads ok, so the file+line pin is the only alarm. +findings: + - rule: js-sqli + min: 1 + max: 1 + line: 6 + sink: "js:some-db.query" diff --git a/test/js/decorators_for_await/expected.yaml b/test/js/decorators_for_await/expected.yaml new file mode 100644 index 00000000..9d97682c --- /dev/null +++ b/test/js/decorators_for_await/expected.yaml @@ -0,0 +1,9 @@ +# Expected findings for this sample (see test/README.md). +# A file whose syntax no single ladder rung reads by extension. Both flows are +# intra-procedural, so a count of 2 means the whole file parsed -- this pins +# dialect coverage and nothing else. If it drops to 0, a rung stopped matching. +findings: + - rule: js-code-injection + min: 2 + max: 2 + sink: "js:eval" diff --git a/test/js/goja_gaps/service.js b/test/js/decorators_for_await/service.js similarity index 51% rename from test/js/goja_gaps/service.js rename to test/js/decorators_for_await/service.js index d63802e3..f077bc75 100644 --- a/test/js/goja_gaps/service.js +++ b/test/js/decorators_for_await/service.js @@ -1,7 +1,7 @@ -// Decorators and `for await` are the two constructs goja's parser cannot spell, -// and a .js file carrying either was previously lost WHOLE -- not analyzed with -// reduced precision, but skipped. This sample is the end-to-end proof that the -// esbuild downlevel recovers the flows inside them. +// Decorators, `for await` and ESM syntax in one .js file: no single rung of the +// dialect ladder reads all three by extension, so this pins that the ladder +// keeps trying until one does. Both flows are intra-procedural on purpose -- +// the assertion is that the file is analyzed AT ALL, not how deeply. import Service from '@ember/service'; export default class SessionService extends Service { diff --git a/test/js/esm_module/app.js b/test/js/esm_module/app.js index ebbb43ef..0abb50f7 100644 --- a/test/js/esm_module/app.js +++ b/test/js/esm_module/app.js @@ -1,7 +1,7 @@ // ES-module syntax in a plain .js file, as Babel-transpiled Node projects and -// anything with "type": "module" write it. goja cannot parse the import, so -// judging the esbuild path by extension alone dropped the whole file -- and the -// language still reported coverage=ok because other files in the tree converted. +// anything with "type": "module" write it. The extension says nothing about the +// dialect, so only trying the ladder's rungs finds this one -- and losing the +// file would be SILENT, since a sibling converting still reports coverage=ok. import express from "express"; import { exec } from "child_process"; diff --git a/test/js/esmodule/app.mjs b/test/js/esmodule/app.mjs index ab9136bd..b134051a 100644 --- a/test/js/esmodule/app.mjs +++ b/test/js/esmodule/app.mjs @@ -1,7 +1,6 @@ -// ES module (import/export) command injection: goja cannot parse top-level -// import, so esbuild lowers it to CommonJS first. The named-import call -// execSync(cmd) becomes an interop call (0, import_child_process.execSync)(cmd); -// the sink must still be recognized. +// ES module command injection. A named import binds its sink through the +// import-alias table (aliases.go), not through a require, so the call must still +// canonicalize to js:child_process.execSync. import { execSync } from "child_process"; export function run(req) { diff --git a/test/js/goja_gaps/expected.yaml b/test/js/goja_gaps/expected.yaml deleted file mode 100644 index c4521bb3..00000000 --- a/test/js/goja_gaps/expected.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Expected findings for this sample (see test/README.md). -# Both flows sit in a file goja cannot parse, so before the downlevel this file -# contributed NOTHING -- the assertion is that it is analyzed at all. Precision -# inside a downleveled class is reduced (__decorateClass breaks method-to-method -# resolution), which is why the flows here are intra-procedural: they pin the -# recovery without pinning the loss. -findings: - - rule: js-code-injection - min: 2 - max: 2 - sink: "js:eval" diff --git a/test/js/loop_header_callback/app.js b/test/js/loop_header_callback/app.js new file mode 100644 index 00000000..a4e60c29 --- /dev/null +++ b/test/js/loop_header_callback/app.js @@ -0,0 +1,15 @@ +// Command injection reached from a function literal in a loop HEADER, not its +// body (see expected.yaml). +const { exec } = require("child_process"); + +function handleBatch(req, res) { + for (const row of req.query.rows.filter((v) => v.ok)) { + exec(row.cmd); + } + for (let i = 0, run = (c) => exec(c); i < 1; i++) { + run(req.query.cmd); + } + res.send("ok"); +} + +module.exports = handleBatch; diff --git a/test/js/loop_header_callback/expected.yaml b/test/js/loop_header_callback/expected.yaml new file mode 100644 index 00000000..a2320804 --- /dev/null +++ b/test/js/loop_header_callback/expected.yaml @@ -0,0 +1,10 @@ +# Expected findings for this sample (see test/README.md). +# The flow is ordinary; what this pins is that the loop HEADERS are collected. If +# they are not, this finding still fires -- the header literals just go unnamed +# and lower to js.unsupported, which only TestNoUnsupportedInstructions sees. +findings: + - rule: js-command-injection + min: 1 + max: 1 + line: 7 + sink: "js:child_process.exec" diff --git a/test/js/typescript/app.ts b/test/js/typescript/app.ts index 86b8fa5e..932a8fa7 100644 --- a/test/js/typescript/app.ts +++ b/test/js/typescript/app.ts @@ -1,6 +1,6 @@ // TypeScript command injection: type annotations (: string, : void, the Request -// interface) must be stripped by esbuild so goja can parse it, and the finding -// must point at THIS .ts file at the correct line. +// interface) are erased by the ladder's TS rung, and the finding must still point +// at THIS .ts file at the correct line -- an erased annotation shifts no offset. const cp = require("child_process"); interface Req {