diff --git a/.claude/skills/cve-recall/projects.yaml b/.claude/skills/cve-recall/projects.yaml index 36d404d..3de6373 100644 --- a/.claude/skills/cve-recall/projects.yaml +++ b/.claude/skills/cve-recall/projects.yaml @@ -32,7 +32,9 @@ projects: - {name: directus, ecosystem: npm, package: "@directus/api", repo: https://github.com/directus/directus.git, lang: ts, subdir: api} - {name: strapi, ecosystem: npm, package: "@strapi/strapi", repo: https://github.com/strapi/strapi.git, lang: ts} - {name: nocodb, ecosystem: npm, package: nocodb, repo: https://github.com/nocodb/nocodb.git, lang: ts} - - {name: ghost, ecosystem: npm, package: ghost, repo: https://github.com/TryGhost/Ghost.git, lang: js, subdir: ghost} + # No subdir: Ghost is a monorepo; Portal (apps/portal) is a separate React app + # carrying its own CVEs, so scoping to `ghost/` hides them. + - {name: ghost, ecosystem: npm, package: ghost, repo: https://github.com/TryGhost/Ghost.git, lang: js} - {name: n8n, ecosystem: npm, package: n8n, repo: https://github.com/n8n-io/n8n.git, lang: ts, subdir: packages} - {name: nextjs, ecosystem: npm, package: next, repo: https://github.com/vercel/next.js.git, lang: ts, subdir: packages/next/src} - {name: verdaccio, ecosystem: npm, package: verdaccio, repo: https://github.com/verdaccio/verdaccio.git, lang: ts} diff --git a/cmd/godzilla/main.go b/cmd/godzilla/main.go index 8d59577..d6723e1 100644 --- a/cmd/godzilla/main.go +++ b/cmd/godzilla/main.go @@ -160,7 +160,15 @@ func runScan(args []string) { buildTimeout := fs.Duration("build-timeout", proc.BuildTimeout(), "deadline for a whole-project build subprocess (only runs with -allow-build)") _ = fs.Parse(args) - buildpolicy.SetAllowed(*allowBuild) + // Only an EXPLICIT -allow-build decides the policy. Calling SetAllowed + // unconditionally made the flag's false default UNSET GODZILLA_ALLOW_BUILD, so + // the environment variable this flag's own help text offers as the alternative + // could never take effect through the CLI. + fs.Visit(func(f *flag.Flag) { + if f.Name == "allow-build" { + buildpolicy.SetAllowed(*allowBuild) + } + }) proc.SetTimeouts(*parseTimeout, *buildTimeout) report.Version = version // stamp the tool version into SARIF/JSON reports diff --git a/converters/go/converter.go b/converters/go/converter.go index fc5ab59..6331332 100644 --- a/converters/go/converter.go +++ b/converters/go/converter.go @@ -62,6 +62,11 @@ type Converter struct { // collectRouteHandlers alongside routeHandlers. routeFormParams map[*ssa.Function][]string + // funcAliases maps a package-level variable that holds a FUNCTION to that + // function, so a call through the variable gets a callee name. Populated by + // collectFuncAliases. + funcAliases map[*ssa.Global]*ssa.Function + // targetPkgs is the set of user-authored (scanned) package import paths. // Dependency bodies are lowered so taint flows through them, but findings are // scoped back to these packages so a sink reached inside a library is not @@ -137,6 +142,7 @@ func (c *Converter) ConvertFile(path string) (*ir.Program, error) { // between route-handler detection and the grouping below. allFns := ssautil.AllFunctions(prog) c.routeHandlers, c.routeFormParams = collectRouteHandlers(allFns) + c.funcAliases = collectFuncAliases(allFns) // Lower only the functions REACHABLE from user (reportable) code. A whole // dependency closure runs to tens of thousands of functions, but the @@ -459,6 +465,11 @@ func (c *Converter) lowerModules(funcsByPkg map[*ssa.Package][]*ssa.Function, st // typeCache/fnNames, so it can lower functions concurrently without locking or // racing on the shared caches. targetPkgs is intentionally NOT copied: it is read // only via TargetPackages() on the top-level converter, never on the worker path. +// worker returns a Converter for one lowering goroutine. Every whole-program +// analysis result computed once in convertProgram has to be carried across here: +// a worker builds its own maps, so a field left out is silently EMPTY during +// lowering rather than nil-panicking, and the analysis it represents just stops +// having an effect. func (c *Converter) worker() *Converter { w := NewConverter() w.fset = c.fset @@ -466,6 +477,7 @@ func (c *Converter) worker() *Converter { w.baseNames = c.fnNames w.routeHandlers = c.routeHandlers w.routeFormParams = c.routeFormParams + w.funcAliases = c.funcAliases return w } @@ -757,6 +769,75 @@ var routingVerbs = map[string]bool{ // (r.GET("/x", h), mux.HandleFunc(..., h), e.Use(mw), …) — and maps each to the // register name of its request/context parameter, plus its bound-form parameter // registers (see formParams). +// collectFuncAliases maps each package-level variable that holds a FUNCTION to +// that function. A package re-exporting another's function as a variable -- +// `var Params = macaron.Params`, how grafana's pkg/web surfaces macaron -- turns +// every call through it into an INDIRECT call: SSA loads the global and calls the +// loaded value, so there is no static callee and convertCall leaves the name +// empty. An unnamed callee matches no source, sink or propagator glob, so the +// call is invisible to every rule; grafana CVE-2021-43798 reaches os.Open through +// exactly that alias. +// +// Only a variable with EXACTLY ONE store program-wide is mapped, and only when +// that store's value is a function. A second store means the variable is +// reassigned -- a mock swapped in by a test, a hook rebound at init -- and the +// call site no longer has one answer, so naming either would be a guess. Counting +// every store rather than only the function-valued ones is what makes the guard +// hold: a var later set to nil or to a different function is excluded, not +// silently resolved to its first value. +func collectFuncAliases(allFns map[*ssa.Function]bool) map[*ssa.Global]*ssa.Function { + type record struct { + fn *ssa.Function + stores int + } + seen := map[*ssa.Global]*record{} + for fn := range allFns { + for _, b := range fn.Blocks { + for _, instr := range b.Instrs { + st, ok := instr.(*ssa.Store) + if !ok { + continue + } + g, ok := st.Addr.(*ssa.Global) + if !ok { + continue + } + r := seen[g] + if r == nil { + r = &record{} + seen[g] = r + } + r.stores++ + if target, ok := st.Val.(*ssa.Function); ok { + r.fn = target + } + } + } + } + out := map[*ssa.Global]*ssa.Function{} + for g, r := range seen { + if r.stores == 1 && r.fn != nil { + out[g] = r.fn + } + } + return out +} + +// aliasedFunc returns the function an indirect call's callee value resolves to +// when that value is a load of a function-holding package-level variable, or nil. +// A load is UnOp(MUL) over the global. +func (c *Converter) aliasedFunc(v ssa.Value) *ssa.Function { + load, ok := v.(*ssa.UnOp) + if !ok || load.Op != token.MUL { + return nil + } + g, ok := load.X.(*ssa.Global) + if !ok { + return nil + } + return c.funcAliases[g] +} + func collectRouteHandlers(allFns map[*ssa.Function]bool) (map[*ssa.Function]string, map[*ssa.Function][]string) { handlers := map[*ssa.Function]string{} forms := map[*ssa.Function][]string{} @@ -1174,6 +1255,15 @@ func (c *Converter) convertCall(call ssa.CallCommon) *ir.CallCommon { } } else if b, ok := call.Value.(*ssa.Builtin); ok { cc.Callee = "builtin." + b.Name() + } else if fn := c.aliasedFunc(call.Value); fn != nil { + // A call through a function-holding package-level variable. Named with the + // function it resolves to rather than the variable, because that is the Go + // frontend's convention throughout -- callees are semantic FQNs from SSA, + // never the syntax at the call site. See collectFuncAliases. + cc.Callee = c.canonicalFunc(fn) + if sig := fn.Signature; sig != nil && sig.Recv() != nil { + cc.MethodName = fn.Name() + } } if n := len(call.Args); n > 0 { cc.Args = make([]*ir.Value, n) diff --git a/converters/java/converter.go b/converters/java/converter.go index c17f602..c96ddbc 100644 --- a/converters/java/converter.go +++ b/converters/java/converter.go @@ -217,6 +217,22 @@ func resolveJavaSource(scanPath string, idx map[string]string, source string) st return scanPath } +// Usable reports whether the `java` on PATH is one ConvertFile will accept, and +// the major version it found (0 when the probe could not determine one). +// +// It is the SAME predicate ConvertFile enforces, exported so a test can skip on +// what would otherwise fail. Guarding on mere presence of `java` disagrees: an +// old JDK passes the guard and then fails the conversion, which reads as a +// broken frontend rather than a missing toolchain. +func Usable() (int, bool) { + javaExe, err := exec.LookPath("java") + if err != nil { + return 0, false + } + major, ok := javaMajorCached(javaExe) + return major, !ok || major >= minJDK +} + // javaMajorCached memoizes javaMajor per launcher path for the process // lifetime: the JDK under a fixed path does not change mid-run, and the probe // costs a full JVM spawn. diff --git a/converters/java/converter_test.go b/converters/java/converter_test.go index 57c53d9..9ce7664 100644 --- a/converters/java/converter_test.go +++ b/converters/java/converter_test.go @@ -11,9 +11,19 @@ import ( ir "github.com/bytevet/godzilla/pkg/ir/v1" ) -// requireJava skips when no JDK `java` launcher is on PATH (the frontend runs -// the embedded JavaDump.java single-file program via it). -func requireJava(t *testing.T) { testsupport.RequireTool(t, "java") } +// requireJava skips unless the `java` on PATH is one the frontend can use. +// +// Presence alone is the wrong predicate: the JDK 24 floor is enforced inside +// ConvertFile, so an older JDK passes a presence guard and then fails every +// test — red that reads as a broken frontend rather than a toolchain this +// machine does not have. +func requireJava(t *testing.T) { + t.Helper() + testsupport.RequireTool(t, "java") + if major, ok := Usable(); !ok { + t.Skipf("found Java %d; the frontend requires JDK %d+", major, minJDK) + } +} func eachInstr(prog *ir.Program, visit func(*ir.Instruction)) { for _, fn := range irwalk.Funcs(prog) { diff --git a/converters/javascript/converter_test.go b/converters/javascript/converter_test.go index 845b3d4..2145aed 100644 --- a/converters/javascript/converter_test.go +++ b/converters/javascript/converter_test.go @@ -83,20 +83,6 @@ func requireFinding(t *testing.T, findings []analysis.Finding, ruleID string) an return analysis.Finding{} } -// requireNoFallbackIntrinsic asserts no instruction lowered to js.unsupported. -// A fallback marks a construct the lowering does not model, and it is silent: -// the file still converts, so only this catches it. -func requireNoFallbackIntrinsic(t *testing.T, prog *ir.Program, what string) { - t.Helper() - for _, fn := range irwalk.Funcs(prog) { - for inst := range irwalk.Instrs(fn) { - if inst.Op == ir.OpCode_OP_CODE_INTRINSIC && inst.Intrinsic == "js.unsupported" { - t.Errorf("%s: unsupported instruction in %s: %s", what, fn.CanonicalName, inst.Comment) - } - } - } -} - func TestConvertXSSSample(t *testing.T) { prog := mustConvert(t, "../../test/js/xss/app.js") diff --git a/converters/javascript/decorators_test.go b/converters/javascript/decorators_test.go new file mode 100644 index 0000000..2414ee4 --- /dev/null +++ b/converters/javascript/decorators_test.go @@ -0,0 +1,58 @@ +package js_converter + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// decorateCallees returns the __decorate* callees in a file's lowered IR, which +// is how the TypeScript legacy-decorator rung is OBSERVABLE from outside: the +// flag rewrites decorators into those calls, so their presence means the file was +// parsed with it and their absence means it was parsed as written. +func decorateCallees(t *testing.T, src string) []string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "m.ts") + if err := os.WriteFile(p, []byte(src), 0o600); err != nil { + t.Fatal(err) + } + prog := mustConvert(t, p) + var out []string + for _, m := range prog.Modules { + for _, f := range m.Functions { + for _, b := range f.Blocks { + for _, in := range b.Instrs { + if in.Call != nil && strings.Contains(in.Call.Callee, "__decorate") { + out = append(out, in.Call.Callee) + } + } + } + } + } + return out +} + +// A parameter decorator does not parse without TypeScript's legacy decorators, +// which costs the WHOLE file -- NestJS and Angular are built out of them. The +// rung exists so those files are analyzed at all. +func TestParameterDecoratorParses(t *testing.T) { + src := "export class C {\n async list(@Req() req: Request) { eval(req.query.cmd); }\n}\n" + if got := decorateCallees(t, src); len(got) == 0 { + t.Errorf("expected the decorator rung to lower this file, got no __decorate* calls") + } +} + +// The other half, and the reason the flag is a rung rather than a default: it +// lowers EVERY experimental decorator in the file it is set on. A file whose +// decorators sit on the class, method and property parses without it, so it must +// still reach the lowering as WRITTEN -- if this starts reporting __decorate* +// calls, the rung has stopped being confined to files that need it and every +// decorated file is paying for the few that do. +func TestOrdinaryDecoratorsAreNotLowered(t *testing.T) { + src := "@Controller('x')\nexport class C {\n @Get() list(req: any) { eval(req.query.cmd); }\n @Inject() svc: any;\n}\n" + if got := decorateCallees(t, src); len(got) != 0 { + t.Errorf("class/method/property decorators were lowered (%v); the rung should not have been reached", got) + } +} diff --git a/converters/javascript/dialect.go b/converters/javascript/dialect.go index 177baaa..7aed762 100644 --- a/converters/javascript/dialect.go +++ b/converters/javascript/dialect.go @@ -80,6 +80,25 @@ func parseSource(path, code string) (*jsast.File, string, error) { firstErr = parseError(errs) } } + // Rung: TypeScript's legacy decorators. A decorator on a PARAMETER is a parse + // error without them, which loses the whole file -- NestJS and Angular sources + // are built out of them. Decorators on classes, methods and properties parse + // without the flag, so only the parameter form needs this. + // + // A rung and not a default, because the flag LOWERS: it rewrites every + // experimental decorator in the file into __decorateClass/__decorateParam + // calls emitted after the class, and Class.Decorators comes back empty. Set + // always, it would trade the as-written tree of every decorated file for the + // few that cannot parse otherwise. Reached only on failure, the lowered shape + // is confined to files that would contribute nothing at all. + for _, m := range ladder { + if !m.ts { + continue // the flag needs TypeScript; it is ignored otherwise + } + if f, errs := jsast.Parse(code, jsast.Options{TS: true, JSX: m.jsx, ExperimentalDecorators: true}); len(errs) == 0 { + return f, code, nil + } + } // Last rung: Flow. Its residue past TypeScript has no dialect of its own, so // no rung recovers it -- the source itself has to change (flowstrip.go). // Retrying the WHOLE ladder rather than one rung keeps the dialect question diff --git a/converters/javascript/dialects_test.go b/converters/javascript/dialects_test.go index 77c206d..15e5cba 100644 --- a/converters/javascript/dialects_test.go +++ b/converters/javascript/dialects_test.go @@ -2,6 +2,8 @@ package js_converter import ( "os" + + "github.com/bytevet/godzilla/internal/testsupport" "path/filepath" "testing" ) @@ -56,7 +58,7 @@ func TestDialectsScanReportsFullCoverage(t *testing.T) { t.Errorf("Skipped() = %d, want 0 — a dialect stopped parsing; see parseLadder", c.Skipped()) } // Converting is half of it: a dialect can parse and still lower to a fallback. - requireNoFallbackIntrinsic(t, prog, "testdata/dialects") + testsupport.RequireNoFallbackIntrinsic(t, prog, "js.unsupported", "testdata/dialects") } // TestConvertCorpusTreeSkipsOnlyBroken pins the skip count over the whole JS @@ -76,7 +78,7 @@ func TestConvertCorpusTreeSkipsOnlyBroken(t *testing.T) { if c.Skipped() != 1 { t.Errorf("Skipped() = %d, want 1 (only resilience/broken.js)", c.Skipped()) } - requireNoFallbackIntrinsic(t, prog, "test/js") + testsupport.RequireNoFallbackIntrinsic(t, prog, "js.unsupported", "test/js") } // TestLadderOrderKeepsRelationalArgs pins the rung ORDER, which is load-bearing diff --git a/converters/javascript/lower.go b/converters/javascript/lower.go index 2334c51..c174592 100644 --- a/converters/javascript/lower.go +++ b/converters/javascript/lower.go @@ -98,6 +98,10 @@ type funcState struct { // parameter (`(rq, res) => ...`) still matches the request-source globs (COV-11). isHandler bool reqParam string + + // isComponent marks this function as usable as a React component (see + // isComponentName), which makes its first parameter the props object. + isComponent bool } // reqConventionNames are the request-object parameter names the source globs @@ -308,6 +312,10 @@ func lowerFunction(m *moduleCtx, pf pendingFunc) *ir.Function { fs := m.newFuncState() fs.isHandler = m.handlers[pf.node] + // Derived, not carried: unlike isHandler -- a fact only the route + // registration elsewhere in the AST knows -- this is a pure function of the + // function's own name, which pendingFunc already holds. + fs.isComponent = isComponentName(pf.objectName) // A method's qualname is "." (or nested "."); record the // prefix so `this.method(x)` resolves to the sibling method. if i := strings.LastIndexByte(pf.qualname, '.'); i >= 0 { @@ -362,27 +370,41 @@ func (fs *funcState) bindParams(fn *ir.Function, args []jsast.Arg, hasRest bool) // A route handler's first parameter is the framework request object; // remember it so property reads off it are canonicalized to `req` and match // the request-source globs regardless of the parameter's actual name. - if i == 0 && fs.isHandler { - fs.reqParam = name - // A signature-destructured request object — `({ query, body }, res) =>` - // — has no `req.query` member read to seed taint from (COV-11). - if pat, ok := a.Binding.Data.(*jsast.BObject); ok { - fs.bindHandlerDestructure(pat, a.Binding.Loc) + if i == 0 { + pat, destructured := a.Binding.Data.(*jsast.BObject) + switch { + case fs.isHandler: + fs.reqParam = name + // A signature-destructured request object — `({ query, body }, res) =>` + // — has no `req.query` member read to seed taint from (COV-11). + if destructured { + fs.bindDestructuredParam("req", pat, a.Binding.Loc) + } + case fs.isComponent && destructured: + // reqParam stays unset on purpose: it drives canonRoot's request-name + // canonicalization, which would rewrite a component's own parameter + // reads into request sources. + fs.bindDestructuredParam("props", pat, a.Binding.Loc) } } } } -// bindHandlerDestructure binds each property of a route handler's destructured -// request parameter — `({ query, body: b }, res) => ...` — to a synthetic -// `js:req.` source read, so the local carries request taint exactly as an -// in-body `req.query` member read would. Nested/computed patterns are skipped. -func (fs *funcState) bindHandlerDestructure(pat *jsast.BObject, loc jsast.Loc) { +// bindDestructuredParam binds each property of a destructured first parameter — +// `({ query, body: b }, res) => ...` — to a synthetic `js:.` read, so +// the local carries taint exactly as an in-body `req.query` member read would. +// Nested/computed patterns are skipped. +// +// The root is the CALLER's decision, not this function's: a route handler's +// parameter is the request object ("req"), a component's is its props ("props"). +// They are separate roots because they are separate rule surfaces — a props read +// must not match a request-source glob. +func (fs *funcState) bindDestructuredParam(root string, pat *jsast.BObject, loc jsast.Loc) { for _, b := range objectPatternBindings(fs.src, pat) { if b.Key == "" || b.Local == "" { continue } - fs.write(b.Local, fs.emitRootPropertyRead("req", b.Key, nil, loc)) + fs.write(b.Local, fs.emitRootPropertyRead(root, b.Key, nil, loc)) } } @@ -878,6 +900,20 @@ func objectPatternRest(f *jsast.File, op *jsast.BObject) string { // `js:` prefix because emitCall writes the callee verbatim -- sfc.go's Vue // equivalent gets the prefix for free by injecting source text that is then // lowered as an ordinary call. +// isComponentName reports whether a function with this leaf name can be used as a +// React component, which is what makes its first parameter a props object. +// +// The capital initial is React's OWN dispatch rule, not a proxy for it: JSX +// compiles a lowercase tag to a host-element STRING, so a lowercase-named +// function cannot be rendered as a component at all. Why the broader "its first +// parameter is destructured" is wrong is pinned by test/js/react_props_xss_safe. +// +// A class METHOD leafs to its method name, so it is never a component -- correct, +// since a class component reads this.props rather than a parameter. +func isComponentName(leaf string) bool { + return leaf != "" && leaf[0] >= 'A' && leaf[0] <= 'Z' +} + const reactHTMLSink = "js:__godzilla_react_html" // emitReactHTMLSink gives React's dangerouslySetInnerHTML a callee to match. diff --git a/converters/python/converter_test.go b/converters/python/converter_test.go index 875606c..b5fb54d 100644 --- a/converters/python/converter_test.go +++ b/converters/python/converter_test.go @@ -585,3 +585,82 @@ func TestCollectImportAliases(t *testing.T) { t.Errorf("relative import should be skipped") } } + +// TestConvertCorpusTreeIsFullyModeled converts the whole Python corpus and pins +// both halves of "this tree converted completely": exactly one file skipped +// (resilience/broken.py, deliberately unparseable) and no instruction lowered to +// a fallback intrinsic. +// +// Neither is visible otherwise. A batch errors only when ZERO files convert, so +// a frontend can drop all but one file and still report Converted; and an +// unmodelled construct costs findings with nothing failing. +func TestConvertCorpusTreeIsFullyModeled(t *testing.T) { + requirePython3(t) + c := NewConverter() + prog, err := c.ConvertFile(filepath.Join("..", "..", "test", "python")) + if err != nil { + t.Fatalf("ConvertFile(test/python): %v", err) + } + if c.Skipped() != 1 { + t.Errorf("Skipped() = %d, want 1 (only resilience/broken.py)", c.Skipped()) + } + testsupport.RequireNoFallbackIntrinsic(t, prog, "py.unsupported", "test/python") +} + +// TestCollectsDefsInsideControlFlow pins that a def nested in control flow +// becomes its own ir.Function. +// +// The collector and lowerBody split the statement tree between them: lowerBody +// descends into the compounds and deliberately skips defs, trusting the +// collector to claim them. When the collector recursed only into def/class +// bodies, a def under `if`/`try`/`with` was claimed by NEITHER — it vanished +// with no error and no fallback intrinsic, and `if TYPE_CHECKING:`, feature +// flags and `except ImportError:` shims all take that shape. +func TestCollectsDefsInsideControlFlow(t *testing.T) { + requirePython3(t) + + const src = `import sys +if sys.version_info >= (3, 11): + def in_if(): pass + class Wrapper: + try: + def in_class_try(self): pass + except ImportError: + def in_class_except(self): pass + finally: + def in_class_finally(self): pass +for _ in range(1): + pass +else: + with open("f") as f: + def in_for_else_with(): pass +` + dir := t.TempDir() + path := filepath.Join(dir, "m.py") + if err := os.WriteFile(path, []byte(src), 0o600); err != nil { + t.Fatal(err) + } + prog, err := NewConverter().ConvertFile(path) + if err != nil { + t.Fatalf("convert: %v", err) + } + got := map[string]bool{} + for _, m := range prog.GetModules() { + for _, f := range m.GetFunctions() { + got[f.GetCanonicalName()] = true + } + } + // The qualname prefix reflects the enclosing SCOPES only: control flow opens + // none, so a def under `try` in a class body is still `Wrapper.`. + for _, want := range []string{ + "py:m.in_if", + "py:m.Wrapper.in_class_try", + "py:m.Wrapper.in_class_except", + "py:m.Wrapper.in_class_finally", + "py:m.in_for_else_with", + } { + if !got[want] { + t.Errorf("function %q was not collected", want) + } + } +} diff --git a/converters/python/lower.go b/converters/python/lower.go index bbbce0e..a508adc 100644 --- a/converters/python/lower.go +++ b/converters/python/lower.go @@ -91,6 +91,17 @@ func convertModule(root astNode, filename, moduleName string, classes routeClass // class-body statements are a documented limitation. cn := s.str("name") collect(s.list("body"), qualPrefix+cn+".", classes.ctxFor(cn), true) + default: + // A def inside control flow (`if TYPE_CHECKING:`, a feature flag, an + // `except ImportError` fallback) would otherwise be claimed by nobody: + // lowerBody descends into the compounds but deliberately skips defs, + // trusting this collector. Recursing over every nested statement list + // rather than enumerating compounds is what keeps the two in step. + // Control flow opens no scope, so the qualname prefix and the + // enclosing class context carry through unchanged. + for _, key := range []string{"body", "orelse", "handlers", "finalbody"} { + collect(s.list(key), qualPrefix, cls, inClass) + } } } } @@ -809,20 +820,11 @@ func (fs *funcState) newVoidInst(n astNode) *ir.Instruction { // that distinction a rule gated on `verify=False` reads `verify=flag` as absent // and stays silent. // -// Because the wrapped value may now be tainted, the marker MUST propagate: the -// value goes in Operands, which is what markTaintFromOperands reads, and -// builtin.kwarg is in the engine's intrinsicPropagators. Omit either and taint -// dies silently at every Python keyword argument — TestKwargMarkerPropagates -// pins the pair together. +// The two channels the marker must fill are ssabuild.SetKwargMarker's business; +// TestKwargMarkerPropagates pins the engine half. func (fs *funcState) emitKwargMarker(name string, v *ir.Value, n astNode) *ir.Value { inst := fs.newValueInst(n) - inst.Op = ir.OpCode_OP_CODE_INTRINSIC - inst.Intrinsic = "builtin.kwarg" - inst.Operands = []*ir.Value{v} - inst.Call = &ir.CallCommon{ - Callee: "builtin.kwarg", - Args: []*ir.Value{ssabuild.Str(name), v}, - } + ssabuild.SetKwargMarker(inst, name, v) fs.emit(inst) return ssabuild.Reg(inst.Name) } diff --git a/converters/ruby/converter.go b/converters/ruby/converter.go index 65c746d..59e5d3a 100644 --- a/converters/ruby/converter.go +++ b/converters/ruby/converter.go @@ -86,6 +86,9 @@ func (c *Converter) batch() *frontend.Batch[rbFileResult] { convertRubyChunk(rubyExe, scriptPath, root, files, out) }, Result: func(r *rbFileResult) (*ir.Module, error) { return r.mod, r.err }, + PostProgram: func(prog *ir.Program, _ bool) { + resolveCellTemplateCalls(prog) + }, } } diff --git a/converters/ruby/converter_test.go b/converters/ruby/converter_test.go index 50eae07..40c3658 100644 --- a/converters/ruby/converter_test.go +++ b/converters/ruby/converter_test.go @@ -1,6 +1,8 @@ package ruby_converter import ( + "os" + "path/filepath" "strings" "testing" @@ -83,3 +85,125 @@ func hasRule(findings []analysis.Finding, id string) bool { } return false } + +// TestConvertCorpusTreeIsFullyModeled converts the whole Ruby corpus and pins +// both halves of "this tree converted completely": nothing skipped, and no +// instruction lowered to a fallback intrinsic. +// +// Neither is visible otherwise. A batch errors only when ZERO files convert, so +// a frontend can drop all but one file and still report Converted; and an +// unmodelled construct costs findings with nothing failing. +func TestConvertCorpusTreeIsFullyModeled(t *testing.T) { + requireRuby(t) + c := NewConverter() + prog, err := c.ConvertFile(filepath.Join("..", "..", "test", "ruby")) + if err != nil { + t.Fatalf("ConvertFile(test/ruby): %v", err) + } + if c.Skipped() != 0 { + t.Errorf("Skipped() = %d, want 0", c.Skipped()) + } + testsupport.RequireNoFallbackIntrinsic(t, prog, "ruby.unsupported", "test/ruby") +} + +// TestCollectsDefsBeyondTopLevel pins the two shapes the collector used to walk +// past: a def nested in control flow, and a `class << self` body. +// +// Both were claimed by nobody. walkDefs entered only class/module bodies while +// lowerStmt recurses into if/while/case/blocks and returns nil for a def, +// trusting the collector — so the method existed in neither, with no error and +// no fallback intrinsic to notice. `class << self` was worse: its Ripper tag +// appeared in no switch at all, so the whole singleton body vanished. +func TestCollectsDefsBeyondTopLevel(t *testing.T) { + requireRuby(t) + + const src = `class Report + class << self + def singleton; end + end + if ENV['LEGACY'] + def in_if; end + end +end +[1].each do + def in_block; end +end +` + dir := t.TempDir() + path := filepath.Join(dir, "m.rb") + if err := os.WriteFile(path, []byte(src), 0o600); err != nil { + t.Fatal(err) + } + prog, err := NewConverter().ConvertFile(path) + if err != nil { + t.Fatalf("ConvertFile: %v", err) + } + got := map[string]int{} + for _, m := range prog.GetModules() { + for _, fn := range m.GetFunctions() { + got[fn.GetCanonicalName()] = len(fn.GetParams()) + } + } + // A singleton def is a CLASS method: named like `def self.m` so a call on the + // class resolves to it from another file, with the receiver in slot 0. + if _, ok := got["ruby:Report.singleton"]; !ok { + t.Errorf("class << self body was dropped; got %v", got) + } else if n := got["ruby:Report.singleton"]; n != 1 { + t.Errorf("ruby:Report.singleton has %d params, want 1 (the receiver)", n) + } + for _, want := range []string{"ruby:m.Report.in_if", "ruby:m.in_block"} { + if _, ok := got[want]; !ok { + t.Errorf("function %q was not collected; got %v", want, got) + } + } +} + +// TestPositionsAreOneBased pins Ruby's columns against the source they name. +// +// Ripper counts columns from 0; every other frontend, every editor, and every +// tool that consumes a SARIF region counts from 1. A whole-language off-by-one +// is invisible to a count-based oracle — it shipped for the life of the +// frontend — so it is checked here against the actual text at the position. +func TestPositionsAreOneBased(t *testing.T) { + requireRuby(t) + + const src = "def handle(req)\n" + + " host = req.params[:host]\n" + + " system(\"ping \" + host)\n" + + "end\n" + lines := strings.Split(src, "\n") + dir := t.TempDir() + path := filepath.Join(dir, "m.rb") + if err := os.WriteFile(path, []byte(src), 0o600); err != nil { + t.Fatal(err) + } + prog, err := NewConverter().ConvertFile(path) + if err != nil { + t.Fatalf("ConvertFile: %v", err) + } + + want := map[string]string{"ruby:req.params": "req.params", "ruby:system": "system("} + seen := map[string]bool{} + for _, m := range prog.GetModules() { + for _, fn := range m.GetFunctions() { + for _, b := range fn.GetBlocks() { + for _, in := range b.GetInstrs() { + text, ok := want[in.GetCall().GetCallee()] + if !ok { + continue + } + seen[in.GetCall().GetCallee()] = true + p := in.GetPos() + line := lines[p.GetLine()-1] + if got := int(p.GetColumn()) - 1; got < 0 || !strings.HasPrefix(line[got:], text) { + t.Errorf("%s reported at %d:%d, which is %q — want it to start %q", + in.GetCall().GetCallee(), p.GetLine(), p.GetColumn(), line[max(got, 0):], text) + } + } + } + } + } + if len(seen) != len(want) { + t.Fatalf("only found %v of the expected calls %v", seen, want) + } +} diff --git a/converters/ruby/erb.go b/converters/ruby/erb.go index 9e8df6d..8f5dc92 100644 --- a/converters/ruby/erb.go +++ b/converters/ruby/erb.go @@ -139,11 +139,18 @@ func IsERBFile(path string) bool { return strings.HasSuffix(path, ".erb") } // the file. Decidim is the evidence: its cells escape by hand // (`decidim_html_escape(...)` inside app/cells), which is only necessary because // the template does not -- and CVE-2024-41673 is the one that got missed. -func erbAutoEscapes(path string) bool { - // Both spellings matter: the directory is mid-path when a monorepo or an - // absolute path is scanned, and a bare prefix when the scan root IS the app. +func erbAutoEscapes(path string) bool { return !isCellsPath(path) } + +// isCellsPath reports whether path lives under a cells directory. The one +// predicate behind both halves of the cells model -- the unescaped template sink +// here and the cell-argument source in lower.go -- so the two cannot come to +// disagree about what a cell is. +// +// Both spellings matter: the directory is mid-path when a monorepo or an absolute +// path is scanned, and a bare prefix when the scan root IS the app. +func isCellsPath(path string) bool { s := filepath.ToSlash(path) - return !strings.Contains(s, "/app/cells/") && !strings.HasPrefix(s, "app/cells/") + return strings.Contains(s, "/app/cells/") || strings.HasPrefix(s, "app/cells/") } // modifierKeywords end an expression and start a trailing condition, so they diff --git a/converters/ruby/lower.go b/converters/ruby/lower.go index 89a88ff..5945789 100644 --- a/converters/ruby/lower.go +++ b/converters/ruby/lower.go @@ -3,8 +3,10 @@ package ruby_converter import ( "encoding/json" "fmt" + "strings" "github.com/bytevet/godzilla/converters/ssabuild" + "github.com/bytevet/godzilla/internal/irwalk" ir "github.com/bytevet/godzilla/pkg/ir/v1" ) @@ -86,39 +88,28 @@ func convertModule(root interface{}, filename, moduleName string) *ir.Module { // `ruby:.`). See localCallee for what each resolves. localFuncs := map[string]bool{} qualifiedFuncs := map[string]bool{} - var collectNames func(ss []interface{}, prefix string) - collectNames = func(ss []interface{}, prefix string) { - for _, s := range ss { - switch tag(s) { - case "def": - name := identName(at(s, 1)) - localFuncs[name] = true - qualifiedFuncs[prefix+name] = true - case "class", "module": - cname := identName(at(at(s, 1), 1)) - collectNames(bodyStmts(classModuleBody(s)), prefix+cname+".") - } + walkDefs(root, defScope{}, func(d interface{}, sc defScope) { + // A singleton def is NOT recorded: its function is named `ruby:.m`, + // so resolving a bare self-call to `ruby:.m` here would + // point the callee at a function that does not exist. + if tag(d) == "def" && !sc.singleton { + name := identName(at(d, 1)) + localFuncs[name] = true + qualifiedFuncs[sc.qualPrefix+name] = true } - } - collectNames(stmts, "") + }) var functions []*ir.Function - var collect func(ss []interface{}, qualPrefix, className string) - collect = func(ss []interface{}, qualPrefix, className string) { - for _, s := range ss { - switch tag(s) { - case "def": - functions = append(functions, lowerDef(s, filename, moduleName, qualPrefix, localFuncs, qualifiedFuncs)) - case "defs": - functions = append(functions, lowerDefs(s, filename, moduleName, className, qualPrefix, localFuncs, qualifiedFuncs)) - case "class", "module": - // class C ... end → constant name at at(s,1) = ["const_ref",["@const","C",pos]] - name := identName(at(at(s, 1), 1)) - collect(bodyStmts(classModuleBody(s)), qualPrefix+name+".", name) - } + walkDefs(root, defScope{}, func(d interface{}, sc defScope) { + switch { + case tag(d) == "defs": + functions = append(functions, lowerDefs(d, filename, moduleName, sc.className, sc.qualPrefix, localFuncs, qualifiedFuncs)) + case sc.singleton: + functions = append(functions, lowerSingletonDef(d, filename, moduleName, sc.className, sc.qualPrefix, localFuncs, qualifiedFuncs)) + default: + functions = append(functions, lowerDef(d, filename, moduleName, sc.qualPrefix, localFuncs, qualifiedFuncs)) } - } - collect(stmts, "", "") + }) // The module entry point: top-level statements that are not a def/class. if init := lowerModuleInit(stmts, filename, moduleName, localFuncs, qualifiedFuncs); init != nil { @@ -129,6 +120,56 @@ func convertModule(root interface{}, filename, moduleName string) *ir.Module { return mod } +// defScope is the naming context a def inherits from the nodes enclosing it. +type defScope struct { + qualPrefix string // dotted class prefix after `ruby:.`, e.g. "Admin.User." + className string // innermost enclosing class, the namespace a class method takes + singleton bool // inside a `class << self` body: a plain def is a CLASS method +} + +// walkDefs visits every `def`/`defs` in a Ripper tree with its enclosing scope. +// +// Only class/module/sclass open a scope; every other node is descended through +// unchanged, so a def under `if`, `unless`, a `rescue` clause or a `do` block is +// reached. That generality is the point: lowerStmt recurses into all of those, +// and a def the collectors miss is lowered by NOBODY — it leaves no intrinsic +// and fails no test, it simply is not analyzed. +func walkDefs(n interface{}, sc defScope, visit func(def interface{}, sc defScope)) { + switch tag(n) { + case "def", "defs": + visit(n, sc) + case "class", "module": + // class C ... end → constant name at at(n,1) = ["const_ref",["@const","C",pos]] + name := identName(at(at(n, 1), 1)) + walkDefs(classModuleBody(n), defScope{qualPrefix: sc.qualPrefix + name + ".", className: name}, visit) + case "sclass": + // `class << self; def m; end; end` — ["sclass", target, bodystmt]. + walkDefs(at(n, 2), defScope{qualPrefix: sc.qualPrefix, className: sc.className, singleton: true}, visit) + default: + l, ok := asList(n) + if !ok { + return + } + for _, c := range l { + walkDefs(c, sc, visit) + } + } +} + +// lowerSingletonDef lowers a plain `def` inside a `class << self` body. Ruby +// makes it a class method, so it is named and shaped like `def self.m`: a +// class-qualified canonical name a call on the class resolves to from another +// file, and a receiver in parameter slot 0 to line the arguments up. +func lowerSingletonDef(defNode interface{}, filename, moduleName, className, qualPrefix string, localFuncs, qualifiedFuncs map[string]bool) *ir.Function { + fn := lowerDef(defNode, filename, moduleName, qualPrefix, localFuncs, qualifiedFuncs) + fn.Name = fn.ObjectName + if className != "" { + fn.CanonicalName = "ruby:" + className + "." + fn.ObjectName + } + fn.Params = append([]*ir.Value{ssabuild.Reg("self")}, fn.Params...) + return fn +} + // programStmts returns the top-level statement list of a `["program",[stmts]]`. func programStmts(root interface{}) []interface{} { if tag(root) != "program" { @@ -260,11 +301,23 @@ func lowerDefs(defNode interface{}, filename, moduleName, className, qualPrefix return fn } -// paramNames extracts the positional parameter names from a `params` node -// (`["params", [reqs], [opts], rest, …]`) in source order: required first, then -// optional (defaulted). The optionals matter for taint — a classic vulnerable -// signature is `def m(filter = nil)`, and unbound its tainted argument maps to no -// parameter and drops. Keyword/splat/block params are out of scope. +// kwargSlotParam mirrors, on the callee, the inert placeholder a caller leaves in +// the keyword hash's positional slot (appendArgList). The engine binds arguments +// to parameters by POSITION, so without it every appended marker lands one slot +// early. Never written or read; '@' cannot start a Ruby local, so it cannot +// collide. +const kwargSlotParam = "@kwargs" + +// paramNames extracts the parameter names from a `params` node +// (`["params", [reqs], [opts], rest, [post], [keywords], kwrest, block]`) in the +// order a CALLER supplies them. The optionals matter for taint — a classic +// vulnerable signature is `def m(filter = nil)`, and unbound its tainted argument +// maps to no parameter and drops. +// +// Keyword binding is positional, so it is exact only when the call passes every +// declared positional: the caller's placeholder sits after the positionals +// PASSED, this one after those declared. `*rest` and post-required params are +// omitted because they shift the same alignment. func paramNames(n interface{}) []string { // def may wrap params in `paren`: ["paren", ["params", …]]. if tag(n) == "paren" { @@ -281,14 +334,40 @@ func paramNames(n interface{}) []string { } } // Optionals: index 2 is a list of [identNode, defaultExpr] pairs. - opts, _ := asList(at(n, 2)) - for _, o := range opts { - pair, ok := asList(o) + out = append(out, pairParamNames(at(n, 2))...) + kws := keywordParamNames(n) + if len(kws) == 0 { + return out + } + return append(append(out, kwargSlotParam), kws...) +} + +// keywordParamNames returns a def's keyword parameters (index 5) followed by its +// `**rest` (index 6), in declaration order. +func keywordParamNames(n interface{}) []string { + out := pairParamNames(at(n, 5)) + // `**rest` arrives wrapped: [kwrest_param, [@ident, "rest"]]. An anonymous + // `**` has nil inside and yields no name. + if name := identName(at(at(n, 6), 1)); name != "" { + out = append(out, name) + } + return out +} + +// pairParamNames reads a `[identNode, defaultOrFalse]` pair list — how Ripper +// spells both the optional (index 2) and keyword (index 5) parameter lists. A +// keyword's ident is an `@label` carrying its trailing colon; a positional name +// can never end in one, so the trim is unconditional. +func pairParamNames(list interface{}) []string { + items, _ := asList(list) + var out []string + for _, it := range items { + pair, ok := asList(it) if !ok || len(pair) == 0 { continue } if name := identName(pair[0]); name != "" { - out = append(out, name) + out = append(out, strings.TrimSuffix(name, ":")) } } return out @@ -381,9 +460,81 @@ func (fs *funcState) ivarGlobal(ivarName string) string { return "rubyfield:" + fs.moduleName + "." + fs.classQual + ivarName } +// assocPairs returns the `assoc_new` pairs of a hash node. Ripper spells the two +// hash forms differently -- `bare_assoc_hash` holds the pair list directly, while +// a braced `hash` wraps it in `assoclist_from_args` (and is nil when empty). +func assocPairs(n interface{}) []interface{} { + return assocNodes(n, "assoc_new") +} + +// assocSplats returns the `assoc_splat` entries of a hash node -- the `**rest` in +// `t(key, **params, scope: s)`. Emitted after assocPairs and never interleaved: +// markers bind to parameters by position, and paramNames puts `**rest` last. +func assocSplats(n interface{}) []interface{} { + return assocNodes(n, "assoc_splat") +} + +func assocNodes(n interface{}, want string) []interface{} { + list := at(n, 1) + if tag(list) == "assoclist_from_args" { + list = at(list, 1) + } + pairs, ok := asList(list) + if !ok { + return nil + } + var out []interface{} + for _, p := range pairs { + if tag(p) == want { + out = append(out, p) + } + } + return out +} + +// assocKeyName returns the keyword a pair was written under, or "" for a computed +// key. Ripper spells `index: x` as an `@label` whose text INCLUDES the trailing +// colon, so it is trimmed; `:index => x` arrives as a symbol literal instead. +// +// A nameless key still gets a marker: the name is only rule metadata, while the +// operand is the taint channel, and a computed key loses the former without +// losing the latter. +func assocKeyName(pair interface{}) string { + k := at(pair, 1) + switch tag(k) { + case "@label": + return labelName(k) + case "symbol_literal": + return symbolText(k) + case "@tstring_content": + return scalarText(k) + case "string_literal": + if inner := at(k, 1); tag(inner) == "string_content" { + if parts, ok := asList(at(inner, 1)); ok && len(parts) == 1 { + return scalarText(parts[0]) + } + } + } + return "" +} + +// symbolText returns a `symbol_literal`'s bare name. Ripper wraps it twice -- +// [symbol_literal, [symbol, [@ident, "html"]]] -- and reading it at one level +// yields a list, not a name. A `dyna_symbol` (`:"#{x}"`) names nothing. +func symbolText(n interface{}) string { return identName(at(at(n, 1), 1)) } + +// labelName returns a keyword key's name. Ripper's `@label` text carries the +// trailing colon (`html:`), and every consumer wants it gone. +func labelName(n interface{}) string { return strings.TrimSuffix(scalarText(n), ":") } + +// posFrom converts a Ripper node's position to a gIR one. Ripper counts columns +// from 0 and every other frontend — and every editor a reported column is read +// in — counts from 1, so the column is shifted here. A node with no position at +// all keeps Line 0, which the report layer already reads as "unknown"; a +// Column 1 alongside it would claim a precision that does not exist. func posFrom(filename string, n interface{}) *ir.Position { if line, col, ok := firstPos(n); ok { - return &ir.Position{Filename: filename, Line: int32(line), Column: int32(col)} + return &ir.Position{Filename: filename, Line: int32(line), Column: int32(col + 1)} } return &ir.Position{Filename: filename} } @@ -473,8 +624,8 @@ func (fs *funcState) lowerStmt(s interface{}) *ir.Value { fs.emit(&ir.Instruction{Op: ir.OpCode_OP_CODE_RET, Operands: []*ir.Value{v}}) fs.terminated = true return v - case "def", "class", "module": - return nil // lowered separately by convertModule.collect + case "def", "defs", "class", "module", "sclass": + return nil // lowered separately by walkDefs default: return fs.lowerExpr(s) } @@ -494,7 +645,8 @@ func (fs *funcState) lowerExpr(n interface{}) *ir.Value { return fs.lowerStringContent(n) case "xstring_literal": return fs.lowerBacktick(n) - case "@tstring_content", "@int", "@float", "@CHAR": + // @label is a keyword-argument key (`name:`) -- a symbol literal, like the rest. + case "@tstring_content", "@int", "@float", "@CHAR", "@label": return ssabuild.Str(scalarText(n)) case "string_embexpr": // `#{ stmts }` — lower the inner statements, return the last value. @@ -505,7 +657,7 @@ func (fs *funcState) lowerExpr(n interface{}) *ir.Value { // receiver of `Net::HTTP.get` off the ruby.unsupported path. return ssabuild.Str(constPathName(n)) case "symbol_literal": - return ssabuild.Str(identName(at(at(n, 1), 1))) + return ssabuild.Str(symbolText(n)) case "dyna_symbol": return ssabuild.Str("") case "var_ref": @@ -541,6 +693,9 @@ func (fs *funcState) lowerExpr(n interface{}) *ir.Value { if fs.isKnownMethod(name) { return fs.lowerCallExpr(fs.localCallee(name), nil, n) } + if callee, ok := fs.cellTemplateCallee(name); ok { + return fs.lowerCallExpr(callee, nil, n) + } return fs.lookup(name) case "paren": inner := at(n, 1) @@ -576,6 +731,24 @@ func (fs *funcState) lowerExpr(n interface{}) *ir.Value { return fs.lowerCondMod(n) case "while_mod", "until_mod": return fs.lowerLoopMod(n) + case "bare_assoc_hash", "hash": + // A keyword-argument or brace hash. Lower each key and value so a source or + // sink inside still fires, and leave the hash itself untainted like `array`. + // + // Untainted is not a shortcut here, it is the point: ActiveRecord's hash + // form (`where(name: params[:q])`) is parameterized by construction, so a + // hash that carried its values' taint would make every one of them a false + // positive. + for _, pair := range assocPairs(n) { + fs.lowerExpr(at(pair, 1)) + fs.lowerExpr(at(pair, 2)) + } + // A `**splat` entry too, so a hash in VALUE position sees what one in + // argument position does (appendArgList). + for _, sp := range assocSplats(n) { + fs.lowerExpr(at(sp, 1)) + } + return ssabuild.Str("") case "array": // Lower elements (so a source/sink inside fires); the container itself is // left untainted, matching the other frontends' list handling. @@ -905,13 +1078,123 @@ var requestDotBases = map[string]bool{"request": true, "req": true, "params": tr // indexed as `base[:x]` (Rails/Sinatra `params[...]`, `cookies[...]`). var requestIndexBases = map[string]bool{"params": true, "cookies": true} +// cellOptionSource names a read of a cell's `options[...]` -- the arguments its +// CALLER passed. Unlike params it is not request input by construction: a cell is +// invoked from a controller or another view, and the value may be a request +// parameter or an internal object. That is the whole reason the rule sourcing it +// ships at `severity: low`, advisory under the default gate, rather than joining +// ruby-xss. +// +// Synthetic and `@`-marked so it can never collide with a real method named +// `options`, and emitted only inside a cell (isCellsPath), where `options` has +// this one meaning. +const cellOptionSource = "ruby:@cell.options" + +// cellMethodMarker prefixes a callee emitted for a bare name in a cell TEMPLATE, +// which Ruby resolves against the cell object the template renders in. The suffix +// is "|"; resolveCellTemplateCalls rewrites it once +// every file is lowered, since the class may not be parsed yet. +// +// Without it the two halves of a cell never meet. A template's `<%= label %>` is a +// bare name in its OWN module with no def of that name, so it lowered to an inert +// free identifier -- decidim CVE-2024-41673 puts the source in the class and the +// unescaped sink in the template, and the flow simply stopped at the file +// boundary. +const cellMethodMarker = "ruby:@cellmethod:" + +// cellTemplateCallee returns the marker callee for a bare name in a cell +// template, or ok=false when this file is not one. Emitted only for a template +// (an .erb under a cells directory): in a cell CLASS a bare name is already +// resolved by localCallee, and outside cells the convention does not hold. +func (fs *funcState) cellTemplateCallee(name string) (string, bool) { + if !isCellsPath(fs.filename) || !IsERBFile(fs.filename) { + return "", false + } + mod, ok := pairedCellModule(fs.moduleName) + if !ok { + return "", false + } + return cellMethodMarker + mod + "|" + name, true +} + +// pairedCellModule maps a cell template's module name to its class's. The pairing +// is the cells convention: templates live in a directory named after the cell, and +// the class sits beside that directory with a `_cell` suffix -- +// `app/cells/foo/show` is rendered by `app/cells/foo_cell`, and decidim's +// `.../decidim/version/show` by `.../decidim/version_cell`. +func pairedCellModule(templateModule string) (string, bool) { + i := strings.LastIndex(templateModule, "/") + if i < 0 { + return "", false + } + return templateModule[:i] + "_cell", true +} + +// resolveCellTemplateCalls rewrites every cellMethodMarker callee to the paired +// cell class's method, once all files are lowered. The engine resolves calls by +// EXACT canonical name, so an unrewritten marker links to nothing. +// +// A name the paired class does not define -- a Rails helper, or a method from an +// included module -- is stripped back to the plain bare name it would have been +// without the marker, so it can still match a rule glob (`raw`, `link_to`) instead +// of dangling. An AMBIGUOUS name is stripped too: one file may hold several cell +// classes, and picking either would be a guess. +func resolveCellTemplateCalls(prog *ir.Program) { + byMod := map[string]map[string]string{} + for _, m := range prog.GetModules() { + if m == nil { + continue + } + for _, f := range m.GetFunctions() { + if f == nil || f.GetCanonicalName() == "" { + continue + } + method := f.GetName() + if i := strings.LastIndex(method, "."); i >= 0 { + method = method[i+1:] + } + mm := byMod[m.GetName()] + if mm == nil { + mm = map[string]string{} + byMod[m.GetName()] = mm + } + if _, dup := mm[method]; dup { + mm[method] = "" // ambiguous within one file: resolve to nothing + continue + } + mm[method] = f.GetCanonicalName() + } + } + for cc := range irwalk.Calls(prog) { + callee := cc.GetCallee() + if !strings.HasPrefix(callee, cellMethodMarker) { + continue + } + mod, name, found := strings.Cut(strings.TrimPrefix(callee, cellMethodMarker), "|") + if !found { + irwalk.SetCallee(cc, "ruby:"+mod) + continue + } + if target := byMod[mod][name]; target != "" { + irwalk.SetCallee(cc, target) + continue + } + irwalk.SetCallee(cc, "ruby:"+name) + } +} + // lowerAref lowers `base[index]`. When the base is an opaque request hash // (`params[:x]`, `cookies['x']`), it becomes a synthetic source CALL so the // engine seeds taint; otherwise it is an INDEX whose taint flows from the base. func (fs *funcState) lowerAref(n interface{}) *ir.Value { base := at(n, 1) - if name, ok := fs.isOpaqueBase(base); ok && requestIndexBases[name] { - return fs.lowerCallExprVals("ruby:"+name, nil, n) + if name, ok := fs.isOpaqueBase(base); ok { + if requestIndexBases[name] { + return fs.lowerCallExprVals("ruby:"+name, nil, n) + } + if name == "options" && isCellsPath(fs.filename) { + return fs.lowerCallExprVals(cellOptionSource, nil, n) + } } baseVal := fs.lowerExpr(base) inst := fs.newValueInst(n) @@ -942,10 +1225,8 @@ func (fs *funcState) lowerDotCall(n interface{}, args []interface{}) *ir.Value { callee = "ruby:" + base + "." + method } } - argVals := []*ir.Value{recvVal} // receiver as operand 0 (rules pin the tainted arg with #1) - for _, a := range args { - argVals = append(argVals, fs.lowerExpr(a)) - } + // Receiver as operand 0 (rules pin the tainted arg with #1). + argVals := fs.appendArgList(append(make([]*ir.Value, 0, len(args)+1), recvVal), args) return fs.lowerCallExprVals(callee, argVals, n) } @@ -1083,12 +1364,56 @@ func extractArgs(n interface{}) []interface{} { return nil } -func (fs *funcState) lowerCallExpr(callee string, args []interface{}, n interface{}) *ir.Value { - var argVals []*ir.Value +func (fs *funcState) emitKwargMarker(name string, v *ir.Value, n interface{}) *ir.Value { + inst := fs.newValueInst(n) + ssabuild.SetKwargMarker(inst, name, v) + fs.emit(inst) + return ssabuild.Reg(inst.Name) +} + +// appendArgList lowers a call's argument nodes into dst, turning a trailing +// keyword list into markers APPENDED after every positional argument. +// +// The hash keeps its own inert placeholder in the positional slot, and that is +// load-bearing rather than tidy: a rule pins an injection point by logical index +// (`ruby:*.where#1`), and `User.where(name: params[:q])` puts its hash at exactly +// that index. Let a tainted value occupy the slot and every parameterized +// ActiveRecord query becomes an injection finding. Appending leaves each existing +// pin looking at the value it looks at today, which is the same reason the Python +// frontend appends its `**`-splat markers. +// +// Values are lowered ONCE. Routing a pair through lowerExpr and then re-lowering +// it for the marker would emit a second copy of any synthetic source call inside +// it, and duplicate a finding. +func (fs *funcState) appendArgList(dst []*ir.Value, args []interface{}) []*ir.Value { + var markers []*ir.Value for _, a := range args { - argVals = append(argVals, fs.lowerExpr(a)) + if t := tag(a); t != "bare_assoc_hash" && t != "hash" { + dst = append(dst, fs.lowerExpr(a)) + continue + } + for _, pair := range assocPairs(a) { + name := assocKeyName(pair) + if name == "" { + // Only a COMPUTED key needs lowering, for a source or sink inside it. + // A literal one lowers to a constant nothing reads. + fs.lowerExpr(at(pair, 1)) + } + markers = append(markers, fs.emitKwargMarker(name, fs.lowerExpr(at(pair, 2)), pair)) + } + // `**rest` names no single keyword, so it stays a plain value -- the same + // treatment Python gives an unexpandable splat. Without it the forwarding + // idiom `def m(**o) = t(k, **o)` drops taint at the forward. + for _, sp := range assocSplats(a) { + markers = append(markers, fs.lowerExpr(at(sp, 1))) + } + dst = append(dst, ssabuild.Str("")) } - return fs.lowerCallExprVals(callee, argVals, n) + return append(dst, markers...) +} + +func (fs *funcState) lowerCallExpr(callee string, args []interface{}, n interface{}) *ir.Value { + return fs.lowerCallExprVals(callee, fs.appendArgList(nil, args), n) } func (fs *funcState) lowerCallExprVals(callee string, args []*ir.Value, n interface{}) *ir.Value { diff --git a/converters/rust/converter.go b/converters/rust/converter.go index 200af71..ffc49b2 100644 --- a/converters/rust/converter.go +++ b/converters/rust/converter.go @@ -117,12 +117,12 @@ func (c *Converter) batch() *frontend.Batch[rsFileResult] { warnIfMIRDrifted() return nil, nil }, - Parse: frontend.PerFile(func(_, f string) rsFileResult { + Parse: frontend.PerFile(func(root, f string) rsFileResult { mir, err := emitMIR(f) if err != nil { return rsFileResult{err: err} } - return rsFileResult{mod: lowerMIR(mir, f)} + return rsFileResult{mod: lowerMIR(mir, f, root)} }), Result: func(r *rsFileResult) (*ir.Module, error) { return r.mod, r.err }, } @@ -210,7 +210,7 @@ func convertCargo(dir string) (*ir.Program, error) { } continue } - prog.Modules = append(prog.Modules, lowerMIR(data, srcPath)) + prog.Modules = append(prog.Modules, lowerMIR(data, srcPath, dir)) } if len(prog.Modules) == 0 { if firstErr != nil { diff --git a/converters/rust/converter_test.go b/converters/rust/converter_test.go index 8767d26..2728c5f 100644 --- a/converters/rust/converter_test.go +++ b/converters/rust/converter_test.go @@ -1,10 +1,12 @@ package rust_converter import ( + "path/filepath" "strings" "testing" "github.com/bytevet/godzilla/internal/analysis" + "github.com/bytevet/godzilla/internal/buildpolicy" "github.com/bytevet/godzilla/internal/irwalk" "github.com/bytevet/godzilla/internal/rules/loader" "github.com/bytevet/godzilla/internal/testsupport" @@ -145,7 +147,7 @@ func TestLowerMIR_AxumSourceSynthesis(t *testing.T) { mir := "fn handler(_1: axum::extract::Query) -> () {\n" + " _0 = Command::new(move _1) -> [return: bb1, unwind continue];\n" + "}\n" - mod := lowerMIR(mir, "handler.rs") + mod := lowerMIR(mir, "handler.rs", "") // The synthetic source CALL must be present. prog := &ir.Program{Modules: []*ir.Module{mod}} @@ -161,7 +163,7 @@ func TestAxumTaintFlow_EndToEnd(t *testing.T) { mir := "fn handler(_1: axum::extract::Query) -> () {\n" + " _0 = Command::new(move _1) -> [return: bb1, unwind continue];\n" + "}\n" - prog := &ir.Program{Modules: []*ir.Module{lowerMIR(mir, "handler.rs")}} + prog := &ir.Program{Modules: []*ir.Module{lowerMIR(mir, "handler.rs", "")}} rs, err := loader.Builtin() if err != nil { @@ -201,7 +203,7 @@ func TestLowerMIR_BranchMergeKeepsTaint(t *testing.T) { " _0 = Command::new(move _1) -> [return: bb4, unwind continue];\n" + " }\n" + "}\n" - prog := &ir.Program{Modules: []*ir.Module{lowerMIR(mir, "run.rs")}} + prog := &ir.Program{Modules: []*ir.Module{lowerMIR(mir, "run.rs", "")}} rs, err := loader.Builtin() if err != nil { @@ -348,7 +350,7 @@ func TestLowerMIR_UserCommandLookalikeNotAliased(t *testing.T) { " _0 = sink(move _2) -> [return: bb2, unwind continue];\n" + " }\n" + "}\n" - prog := &ir.Program{Modules: []*ir.Module{lowerMIR(mir, "f.rs")}} + prog := &ir.Program{Modules: []*ir.Module{lowerMIR(mir, "f.rs", "")}} for _, fn := range irwalk.Funcs(prog) { for inst := range irwalk.Instrs(fn) { cc := inst.GetCall() @@ -390,3 +392,84 @@ func TestLowerMIR_UserCommandLookalikeNotAliased(t *testing.T) { t.Errorf("sink arg = %q, the call's own result — std Command step lost its receiver aliasing", got) } } + +// TestConvertCorpusIsFullyModeled converts every Rust sample the way the corpus +// does — one crate at a time, so cargo resolves each crate's dependencies — and +// requires that nothing lowered to a fallback intrinsic. +// +// An unmodelled rvalue used to become an empty string constant, i.e. CLEAN DATA: +// taint died at it and the result read as a legitimately safe value, with no +// error and nothing failing. This is the check that makes the marker worth +// emitting. +func TestConvertCorpusIsFullyModeled(t *testing.T) { + requireRustc(t) + testsupport.RequireTool(t, "cargo") + t.Setenv(buildpolicy.EnvAllowBuild, "1") + + dirs, err := filepath.Glob(filepath.Join("..", "..", "test", "rust", "*")) + if err != nil || len(dirs) == 0 { + t.Fatalf("no rust samples found: %v", err) + } + for _, dir := range dirs { + t.Run(filepath.Base(dir), func(t *testing.T) { + c := NewConverter() + prog, err := c.ConvertFile(dir) + if err != nil { + t.Fatalf("ConvertFile(%s): %v", dir, err) + } + if c.Skipped() != 0 { + t.Errorf("Skipped() = %d, want 0", c.Skipped()) + } + testsupport.RequireNoFallbackIntrinsic(t, prog, "rust.unsupported", dir) + }) + } +} + +// TestUnmodelledRvalueIsVisible pins the fallback itself: a form assignRvalue +// does not understand must leave a marker, not a constant. +func TestUnmodelledRvalueIsVisible(t *testing.T) { + st := &lowerState{env: map[string]*ir.Value{}} + // Any rvalue form the parser does not recognize; the shape of the fallback is + // what is being pinned, not this particular string. + st.assignOperator("_1", "some_future_rvalue", nil) + if v := st.env["_1"]; v.GetConstant() != nil { + t.Fatalf("unmodelled rvalue became the constant %q — taint dies at it", v.GetConstant().GetStringVal()) + } + if len(st.instrs) != 1 || st.instrs[0].Intrinsic != "rust.unsupported" { + t.Fatalf("expected one rust.unsupported instruction, got %v", st.instrs) + } +} + +// TestSpansOutsideScanRootAreRejected pins that a span pointing into rustc's own +// sysroot never reaches a position. +// +// Everything expanded from a macro carries one — `/rustc//library/alloc/ +// src/macros.rs` for anything through `format!`, i.e. most string-building Rust. +// The file does not exist on the scanning machine, so a taint-path step or a +// SARIF artifact URI pinned there is unresolvable: GitHub code scanning cannot +// annotate it and a reviewer cannot open it. The macro's call site is the last +// span accepted from user code, which is the line the reader wants anyway. +func TestSpansOutsideScanRootAreRejected(t *testing.T) { + root := t.TempDir() + user := filepath.Join(root, "main.rs") + mir := "fn run() -> () {\n" + + " bb0: {\n" + + " _1 = std::env::args() -> [return: bb1]; // scope 0 at " + user + ":7:13\n" + + " }\n" + + " bb1: {\n" + + " _2 = Vec::new(move _1) -> [return: bb2]; " + + "// scope 0 at /rustc/8bab26f4/library/alloc/src/macros.rs:114:33\n" + + " }\n" + + "}\n" + mod := lowerMIR(mir, user, root) + + for _, fn := range mod.GetFunctions() { + for _, b := range fn.GetBlocks() { + for _, in := range b.GetInstrs() { + if f := in.GetPos().GetFilename(); f != "" && f != user { + t.Errorf("instruction %q got position in %s, want the user file or none", in.GetName(), f) + } + } + } + } +} diff --git a/converters/rust/fmt_template_test.go b/converters/rust/fmt_template_test.go index 2e92bd1..b1a6718 100644 --- a/converters/rust/fmt_template_test.go +++ b/converters/rust/fmt_template_test.go @@ -18,6 +18,13 @@ func TestDecodeFmtTemplate(t *testing.T) { {"arg-first", `const b"\xc0\x0b.host.com/x\x00"`, "{}.host.com/x", true}, {"arg+suffix", `const b"\thttp://h/\xc0\x05/tail\x00"`, "http://h/{}/tail", true}, {"two-args", `const b"\nhttps://h/\xc0\x01/\xc0\x00"`, "https://h/{}/{}", true}, + // A spec-bearing or explicit-index argument uses a control byte this decoder + // does not model. What precedes it is literal text rustc emitted and stands; + // the rest becomes one insertion, which reads as dynamic. Dropping the + // literal prefix instead is what made a safe `format!("https://h/v1/{:>10}")` + // a high-confidence CWE-918 -- an empty template claims "no host here". + {"spec arg keeps the literal prefix", `const b"\x14https://host.com/v1/\xc3 \x00"`, "https://host.com/v1/{}", true}, + {"spec arg before the host proves nothing", `const b"\xc3 \x00\x05/tail"`, "{}", true}, {"no b-prefix (plain string)", `"https://h/"`, "", false}, {"truncated length run", `const b"\x14https://h/"`, "", false}, {"bad hex escape", `const b"\xzz"`, "", false}, diff --git a/converters/rust/format_marker_test.go b/converters/rust/format_marker_test.go index 057793f..2ad7119 100644 --- a/converters/rust/format_marker_test.go +++ b/converters/rust/format_marker_test.go @@ -66,7 +66,7 @@ func TestFormatMarkerAnchoredMatch(t *testing.T) { " return;\n" + " }\n" + "}\n" - marks := calleeMarks(lowerMIR(std, "std.rs")) + marks := calleeMarks(lowerMIR(std, "std.rs", "")) requireMark(t, marks, "rust:Arguments::new", "builtin.format") // format(Arguments) is only recognized by its ARGUMENT being the tagged // Arguments::new result — the name alone cannot be anchored. @@ -94,7 +94,7 @@ func TestFormatMarkerAnchoredMatch(t *testing.T) { " return;\n" + " }\n" + "}\n" - marks = calleeMarks(lowerMIR(user, "user.rs")) + marks = calleeMarks(lowerMIR(user, "user.rs", "")) requireMark(t, marks, "rust:my_into", "") requireMark(t, marks, "rust:W::clone", "") requireMark(t, marks, "rust:format", "") diff --git a/converters/rust/fuzz_test.go b/converters/rust/fuzz_test.go index ae02298..9f9a8b1 100644 --- a/converters/rust/fuzz_test.go +++ b/converters/rust/fuzz_test.go @@ -11,7 +11,7 @@ func FuzzLowerMIR(f *testing.F) { f.Add("fn f() {\n let _1: i32;\n _1 = const 5_i32;\n _0 = move _1;\n}\n") f.Add("bb0: {\n _2 = Add(move _3, const b\"\\xc0\");\n}\n") f.Fuzz(func(t *testing.T, text string) { - _ = lowerMIR(text, "fuzz.rs") // must not panic + _ = lowerMIR(text, "fuzz.rs", "") // must not panic }) } diff --git a/converters/rust/mir.go b/converters/rust/mir.go index 5a1aa50..83f6e2b 100644 --- a/converters/rust/mir.go +++ b/converters/rust/mir.go @@ -3,6 +3,7 @@ package rust_converter import ( "fmt" "maps" + "path/filepath" "regexp" "strconv" "strings" @@ -25,10 +26,15 @@ import ( // for the straight-line source→sink handler shape that matters for taint. // lowerMIR parses the MIR dump `text` for source file `filename` into a module. -func lowerMIR(text, filename string) *ir.Module { +// `root` is the tree being scanned; spans outside it are rustc's own and are not +// reported (see lowerState.span). An empty root disables that filter. +func lowerMIR(text, filename, root string) *ir.Module { mod := &ir.Module{Name: filename, Language: "rust"} + if abs, err := filepath.Abs(root); root != "" && err == nil { + root = abs + } for _, body := range splitFns(text) { - if fn := lowerFn(body, filename); fn != nil { + if fn := lowerFn(body, filename, root); fn != nil { mod.Functions = append(mod.Functions, fn) } } @@ -64,12 +70,14 @@ func splitFns(text string) [][]string { type lowerState struct { filename string + root string // absolute scan root; a span outside it is rustc's own counter int env map[string]*ir.Value // MIR local ("_5") -> current gIR value agg map[string][]*ir.Value // MIR local -> aggregate element values (for field folding) intr map[string]string // gIR reg name -> builtin.* marker its defining call carries instrs []*ir.Instruction firstPos *ir.Position + lastPos *ir.Position // last span accepted as user code } var ( @@ -95,12 +103,12 @@ var ( unOps = map[string]bool{"Neg": true, "Not": true, "PtrMetadata": true} ) -func lowerFn(body []string, filename string) *ir.Function { +func lowerFn(body []string, filename, root string) *ir.Function { name, params := parseHeader(body[0]) if name == "" { return nil } - st := &lowerState{filename: filename, env: map[string]*ir.Value{}, agg: map[string][]*ir.Value{}, intr: map[string]string{}} + st := &lowerState{filename: filename, root: root, env: map[string]*ir.Value{}, agg: map[string][]*ir.Value{}, intr: map[string]string{}} fn := &ir.Function{ Name: name, ObjectName: name, @@ -446,7 +454,15 @@ func (st *lowerState) assignOperator(dst, expr string, pos *ir.Position) { st.setAgg(dst, structFields(expr[brace:]), "builtin.aggregate", pos) return } - st.env[dst] = ssabuild.Str("") + // Unmodelled rvalue. It must NOT become a constant: a constant is clean data, + // so taint dies here and the result reads as a legitimately safe value — + // silence that costs findings. The intrinsic is what the coverage check sees. + name := st.reg() + st.instrs = append(st.instrs, &ir.Instruction{ + Name: name, Op: ir.OpCode_OP_CODE_INTRINSIC, + Intrinsic: "rust.unsupported", Comment: expr, Pos: pos, + }) + st.env[dst] = ssabuild.Reg(name) } // emitCall lowers a MIR call terminator. Method and free-function calls alike @@ -758,14 +774,45 @@ func (st *lowerState) span(comment string) *ir.Position { file := m[1] if file == "" { file = st.filename + } else if !filepath.IsAbs(file) && st.root != "" { + // A cargo build runs rustc IN the crate directory, so its spans are + // crate-relative ("src/lib.rs"). Stored verbatim that names no particular + // file -- every crate in a workspace has one -- and neither srclines nor a + // SARIF consumer can open it. Resolving against the crate directory (the + // root this module was lowered with) is what makes the built path agree + // with the source-lowered one, which reports absolute paths. + file = filepath.Join(st.root, file) + } + // Anything expanded from a macro carries a span into rustc's OWN sysroot + // (`/rustc//library/alloc/src/macros.rs` for everything through + // `format!`, i.e. most string-building Rust). That file does not exist on the + // scanning machine, so a finding pinned there is unreadable and GitHub code + // scanning cannot annotate it. The last accepted span is the macro's call + // site, which is the line the reader wants. + if !st.underRoot(file) { + return st.lastPos } pos := &ir.Position{Filename: file, Line: int32(atoi(m[2])), Column: int32(atoi(m[3]))} if st.firstPos == nil { st.firstPos = pos } + st.lastPos = pos return pos } +// underRoot reports whether a span's file lies inside the tree being scanned. +func (st *lowerState) underRoot(file string) bool { + if st.root == "" { + return true + } + abs, err := filepath.Abs(file) + if err != nil { + return false + } + rel, err := filepath.Rel(st.root, abs) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + // --- text helpers --- // splitCodeComment splits a MIR line into its code and trailing `//` comment, @@ -1001,9 +1048,18 @@ func constFromLiteral(lit string) *ir.Value { // argument position. The template (rustc's `fmt::rt` encoding) is a sequence of // tokens: the byte 0xC0 marks an argument insertion, and a byte < 0x80 is the // length of a literal run that immediately follows. tok is the raw MIR operand, -// e.g. `const b"\x14https://h/v1/\xc0\x00"`. Returns ok=false for anything that -// is not a well-formed, UTF-8-clean byte-string template (the caller then leaves -// the original empty constant, so a decode miss can never invent a fixed host). +// e.g. `const b"\x14https://h/v1/\xc0\x00"`. Returns ok=false only when the +// operand is not a decodable byte string at all. +// +// A token the encoding uses but this decoder does not model -- an explicit-index +// or spec-bearing argument, `{0}` or `{:>10}` -- ends the decode at that point +// rather than failing it: the text decoded so far came from literal-run tokens, +// which are compile-time text and cannot carry taint, and the remainder is +// rendered as one argument insertion, i.e. dynamic. Failing the whole decode +// instead left an EMPTY template, which reads downstream as the positive claim +// that the format string contains no host -- and so turned a provably safe +// `format!("https://api.example.com/v1/{:>10}", p)` into a high-confidence +// CWE-918 that the same code without the width specifier does not produce. func decodeFmtTemplate(tok string) (string, bool) { tok = strings.TrimSpace(tok) tok = strings.TrimSpace(strings.TrimPrefix(tok, "const ")) @@ -1031,8 +1087,9 @@ func decodeFmtTemplate(tok string) (string, bool) { } sb.Write(raw[i : i+int(b)]) i += int(b) - default: // unrecognized control byte (e.g. an explicit-index/spec arg) - return "", false + default: // a token this decoder does not model; the rest is unknown + sb.WriteString("{}") + i = len(raw) } } if !utf8.ValidString(sb.String()) { diff --git a/converters/rust/smoke.go b/converters/rust/smoke.go index 3f2894c..6708ad5 100644 --- a/converters/rust/smoke.go +++ b/converters/rust/smoke.go @@ -64,7 +64,7 @@ func warnIfMIRDrifted() { if err != nil { return // compile failed (no/old rustc) — surfaced elsewhere } - prog := &ir.Program{Mode: "mir", Modules: []*ir.Module{lowerMIR(mir, tmp.Name())}} + prog := &ir.Program{Mode: "mir", Modules: []*ir.Module{lowerMIR(mir, tmp.Name(), "")}} if !verifyMIRShape(prog) { fmt.Fprintf(os.Stderr, "warning: rust: this rustc's MIR did not lower to the expected shapes — "+ "the MIR text format may have changed and Rust findings could be silently incomplete; "+ diff --git a/converters/rust/spanpath_test.go b/converters/rust/spanpath_test.go new file mode 100644 index 0000000..c97a2be --- /dev/null +++ b/converters/rust/spanpath_test.go @@ -0,0 +1,45 @@ +package rust_converter + +import ( + "path/filepath" + "testing" +) + +// A cargo build runs rustc IN the crate directory, so its MIR spans are +// crate-relative while a direct source lowering reports absolute paths. Stored +// verbatim, "src/lib.rs" names no particular file — every crate in a workspace +// has one — so srclines cannot open it, SARIF cannot annotate it, and the two +// lowering paths disagree about the same finding. That disagreement is what made +// the rust position golden depend on whether cargo happened to run. +func TestSpanResolvesCrateRelativePath(t *testing.T) { + const root = "/repo/crate" + for _, tc := range []struct { + name, comment, want string + }{ + {"crate-relative span resolves against the crate dir", + "// scope 0 at src/lib.rs:17:5: ~", filepath.Join(root, "src/lib.rs")}, + {"an absolute span is left alone", + "// scope 0 at /repo/crate/src/lib.rs:17:5: ~", "/repo/crate/src/lib.rs"}, + } { + t.Run(tc.name, func(t *testing.T) { + st := &lowerState{root: root} + pos := st.span(tc.comment) + if pos == nil { + t.Fatalf("span(%q) = nil, want a position", tc.comment) + } + if pos.GetFilename() != tc.want { + t.Errorf("filename = %q, want %q", pos.GetFilename(), tc.want) + } + }) + } +} + +// The other half: rustc's own sysroot spans (everything expanded from format!) +// are absolute and OUTSIDE the tree, and must still be rejected rather than +// joined onto the crate dir. +func TestSpanStillRejectsSysrootPath(t *testing.T) { + st := &lowerState{root: "/repo/crate"} + if pos := st.span("// scope 0 at /rustc/abc123/library/alloc/src/macros.rs:9:1: ~"); pos != nil { + t.Errorf("span of a sysroot path = %q, want nil (no last position to fall back to)", pos.GetFilename()) + } +} diff --git a/converters/ssabuild/values.go b/converters/ssabuild/values.go index 3d829c1..9025987 100644 --- a/converters/ssabuild/values.go +++ b/converters/ssabuild/values.go @@ -24,3 +24,20 @@ func Global(name string) *ir.Value { func Nil() *ir.Value { return &ir.Value{Kind: &ir.Value_Constant{Constant: &ir.Constant{IsNil: true}}} } + +// SetKwargMarker stamps inst as a `builtin.kwarg(, )` marker: the +// intrinsic a frontend emits to tag a keyword argument with the name it was +// passed under, since gIR carries positional arguments only. +// +// TWO channels, and dropping either fails silently. Operands is what the engine's +// markTaintFromOperands reads, and builtin.kwarg is an intrinsic propagator, so +// without it every `f(x=tainted)` loses its taint. Call.Args is what unwrapKwarg +// reads to give a rule guard `kwargs.`, so without it a guard can see that +// SOME argument is set but not which. Shared so the pairing is stated once rather +// than re-derived per frontend. +func SetKwargMarker(inst *ir.Instruction, name string, v *ir.Value) { + inst.Op = ir.OpCode_OP_CODE_INTRINSIC + inst.Intrinsic = "builtin.kwarg" + inst.Operands = []*ir.Value{v} + inst.Call = &ir.CallCommon{Callee: "builtin.kwarg", Args: []*ir.Value{Str(name), v}} +} diff --git a/docs/writing-rules.md b/docs/writing-rules.md index df5f57a..90619df 100644 --- a/docs/writing-rules.md +++ b/docs/writing-rules.md @@ -54,6 +54,15 @@ rules: - **Sink pinning** `#` fires only when taint reaches that logical (receiver-excluded) argument; a bare pattern treats every argument as an injection point. This keeps parameterized queries clean. + - Ruby is the exception to "receiver-excluded": its frontend sets no + `MethodName`, so a dot-call's receiver stays at index 0 and its first real + argument is `#1`, while a bare call's is `#0` (`ruby:open#0` next to + `ruby:Kernel.open#1`). A wrong index fails silently — it selects a real + argument, just not the intended one. + - A pin names a POSITION, so it excludes keyword arguments, which frontends + append after the positional ones. That is what keeps + `send_data csv, filename: x` off a path-traversal sink. To key on a keyword + instead, guard the sink with `when: 'kwargs..Tainted'`. - **Sanitizers** return a cleaned value (taint stops); **validators** are boolean guards (e.g. `filepath.IsLocal`) that clear taint on the path they dominate; **propagators** pass taint arg → result (`+` and `fmt.Sprintf` propagate by diff --git a/go.mod b/go.mod index fb4c9a0..6ac7e7b 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.5 require ( github.com/anthropics/anthropic-sdk-go v1.55.0 - github.com/bytevet/esbuild-jsast v0.0.0-20260813172304-fb79e35b0a1e + github.com/bytevet/esbuild-jsast v0.0.0-20260815021104-e020fc022d6a github.com/expr-lang/expr v1.17.8 github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f golang.org/x/net v0.50.0 diff --git a/go.sum b/go.sum index e18816a..72c5e9e 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytevet/esbuild-jsast v0.0.0-20260813172304-fb79e35b0a1e h1:X3nuIeze89KV6dymCo+Dh8lY13cSurF1KFTcESXG4yU= github.com/bytevet/esbuild-jsast v0.0.0-20260813172304-fb79e35b0a1e/go.mod h1:n5TorIjizb/BEpXkAbxNx2gWnJH2STg2lXbY4xTkkzo= +github.com/bytevet/esbuild-jsast v0.0.0-20260815021104-e020fc022d6a h1:D2D6Y/QfIYh42NahUZ9XswnfVbyZyhUmorqDyQTnlYM= +github.com/bytevet/esbuild-jsast v0.0.0-20260815021104-e020fc022d6a/go.mod h1:n5TorIjizb/BEpXkAbxNx2gWnJH2STg2lXbY4xTkkzo= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= diff --git a/internal/analysis/determinism_test.go b/internal/analysis/determinism_test.go new file mode 100644 index 0000000..071f639 --- /dev/null +++ b/internal/analysis/determinism_test.go @@ -0,0 +1,100 @@ +package analysis + +import ( + "slices" + "testing" + + go_converter "github.com/bytevet/godzilla/converters/go" + ir "github.com/bytevet/godzilla/pkg/ir/v1" +) + +// The engine's worklist order decides which cross-function summary is recorded +// first, and every summary channel is first-seen-wins, so an index built by +// ranging over a map made a scan's finding count vary run to run (a 4x swing on a +// real project). Each index below is therefore sorted at construction; these tests +// pin that, because the symptom of losing it is a flaky number, not a failure. + +func assertSorted(t *testing.T, what string, m map[string][]string) { + t.Helper() + for k, v := range m { + if !slices.IsSorted(v) { + t.Errorf("%s[%q] is not sorted: %v", what, k, v) + } + } +} + +func TestSharedIndexesAreSorted(t *testing.T) { + conv := go_converter.NewConverter() + prog, err := conv.ConvertFile("../../test/go/sql_injection/main.go") + if err != nil { + t.Fatalf("convert sample: %v", err) + } + byKey, _ := buildFuncIndex(prog) + impls := buildMethodImpls(byKey) + assertSorted(t, "methodImpls", impls) + assertSorted(t, "callers", buildCallers(buildCallGraph(byKey, impls))) + assertSorted(t, "globalReaders", buildGlobalReaders(byKey)) +} + +// TestStringParamOriginsAllMatches pins the contract that made the worklist +// deterministic: one origin can seed SEVERAL parameters — a call site passing the +// same tainted value twice, `f(x, x)` — and every string one is returned, in +// ascending order. Returning just one had no deterministic answer to return. +func TestStringParamOriginsAllMatches(t *testing.T) { + str := &ir.Type{Kind: ir.TypeKind_TYPE_KIND_BASIC, BasicKind: ir.BasicTypeKind_BASIC_TYPE_KIND_STRING} + num := &ir.Type{Kind: ir.TypeKind_TYPE_KIND_BASIC, BasicKind: ir.BasicTypeKind_BASIC_TYPE_KIND_INT} + fn := &ir.Function{Signature: &ir.Signature{Params: []*ir.Type{str, num, str}}} + + origin := &ir.Position{Filename: "a.go", Line: 7} + other := &ir.Position{Filename: "a.go", Line: 9} + seeds := paramPositions{0: origin, 1: origin, 2: origin, 3: other} + + // Param 1 is an int and param 3 carries a different origin, so neither is a + // wrapper parameter for this flow. + if got := stringParamOrigins(fn, seeds, origin); !slices.Equal(got, []int{0, 2}) { + t.Errorf("stringParamOrigins = %v, want [0 2]", got) + } + if got := stringParamOrigins(fn, paramPositions{1: origin}, origin); len(got) != 0 { + t.Errorf("a non-string parameter must not be summarized, got %v", got) + } + if got := stringParamOrigins(fn, seeds, &ir.Position{}); len(got) != 0 { + t.Errorf("an unseeded origin must match nothing, got %v", got) + } +} + +// A method's receiver occupies fn.Params[0] but is absent from Signature.Params, +// so the index has to shift — and the receiver itself is never a wrapper param. +func TestStringParamOriginsSkipsReceiver(t *testing.T) { + str := &ir.Type{Kind: ir.TypeKind_TYPE_KIND_BASIC, BasicKind: ir.BasicTypeKind_BASIC_TYPE_KIND_STRING} + fn := &ir.Function{Signature: &ir.Signature{ + Recv: &ir.Type{Kind: ir.TypeKind_TYPE_KIND_POINTER}, + Params: []*ir.Type{str}, + }} + origin := &ir.Position{Filename: "a.go", Line: 3} + if got := stringParamOrigins(fn, paramPositions{0: origin, 1: origin}, origin); !slices.Equal(got, []int{1}) { + t.Errorf("stringParamOrigins = %v, want [1]", got) + } +} + +// Guards TestSharedIndexesAreSorted against becoming vacuous: a sortedness +// assertion over slices that are all length 1 proves nothing. +func TestSharedIndexesHaveMultiEntryLists(t *testing.T) { + conv := go_converter.NewConverter() + prog, err := conv.ConvertFile("../../test/go/sql_injection/main.go") + if err != nil { + t.Fatalf("convert sample: %v", err) + } + byKey, _ := buildFuncIndex(prog) + impls := buildMethodImpls(byKey) + for _, m := range []map[string][]string{impls, buildCallers(buildCallGraph(byKey, impls))} { + multi := 0 + for _, v := range m { + if len(v) > 1 { + multi++ + } + } + if multi == 0 { + t.Error("every list has at most one entry, so the sortedness assertion is vacuous") + } + } +} diff --git a/internal/analysis/interproc.go b/internal/analysis/interproc.go index fbd0b8a..f1632ad 100644 --- a/internal/analysis/interproc.go +++ b/internal/analysis/interproc.go @@ -566,6 +566,14 @@ func buildFuncIndex(prog *ir.Program) (map[string]*ir.Function, map[string]*ir.M // canonical name. The DISPATCH policy (fan out to all implementers vs. resolve // only when the name is unambiguous) is likewise chosen from IR at the call site, // via CallCommon.untyped_dispatch, not from any language check here. +// +// Each implementer list is SORTED, and so is every other name list the worklist +// iterates (buildCallers, buildGlobalReaders). These are built by ranging over a +// map, so the append order is Go's randomized iteration order, and the worklist +// visits functions in it; because every summary channel is first-seen-wins +// (returnTaint, paramTaint), the order decides which origin a callee's summary +// keeps and therefore whether a flow reports at all. Unsorted, a scan returned a +// different finding count on each run. func buildMethodImpls(byKey map[string]*ir.Function) map[string][]string { methodImpls := map[string][]string{} for name, fn := range byKey { @@ -573,6 +581,9 @@ func buildMethodImpls(byKey map[string]*ir.Function) map[string][]string { methodImpls[bare] = append(methodImpls[bare], name) } } + for _, impls := range methodImpls { + slices.Sort(impls) + } return methodImpls } @@ -772,6 +783,11 @@ func buildCallers(cg *CallGraph) map[string][]string { callers[callee] = append(callers[callee], caller) } } + // Sorted, for the determinism reason on buildMethodImpls: the worklist + // enqueues a callee's callers in this order once the callee returns taint. + for _, cs := range callers { + slices.Sort(cs) + } return callers } @@ -799,6 +815,12 @@ func buildGlobalReaders(byKey map[string]*ir.Function) map[string][]string { } } } + // Sorted for the determinism reason on buildMethodImpls, and deduplicated + // because a function reading the same global twice would otherwise appear twice. + for g, rs := range globalReaders { + slices.Sort(rs) + globalReaders[g] = slices.Compact(rs) + } return globalReaders } @@ -1078,34 +1100,41 @@ func isStringType(t *ir.Type) bool { } } -// stringParamOrigin reports whether a tainted value with origin `pos` entered fn -// through a STRING parameter, returning that parameter's index. It attributes the -// value back to the seed it arrived on (origins are preserved across propagators), -// then checks that parameter's declared type. fn.Params carries the SSA receiver -// at index 0 for a method while Signature.Params excludes it, so the receiver is -// shifted out. -func stringParamOrigin(fn *ir.Function, seeds paramPositions, pos *ir.Position) (int, bool) { - idx := -1 - for i, origin := range seeds { - if origin == pos { - idx = i - break - } - } - if idx < 0 { - return 0, false - } +// stringParamOrigins returns the STRING parameters of fn that a tainted value +// with origin `pos` could have entered through, in ascending index order. It +// attributes the value back to the seeds it arrived on (origins are preserved +// across propagators), then keeps those whose declared type is a string. +// fn.Params carries the SSA receiver at index 0 for a method while +// Signature.Params excludes it, so the receiver is shifted out. +// +// EVERY match, not one: a single origin seeds several parameters whenever one +// call site passed the same tainted value twice (`f(x, x)`), and the engine +// cannot tell which one carried it to the sink, so summarizing only one drops a +// caller that taints the other. There is also no deterministic one to pick — +// seeds is a map, so stopping at the first match returned a different parameter +// on each run. +func stringParamOrigins(fn *ir.Function, seeds paramPositions, pos *ir.Position) []int { sig := fn.GetSignature() off := 0 if sig.GetRecv() != nil { off = 1 } sp := sig.GetParams() - si := idx - off - if si < 0 || si >= len(sp) { - return 0, false // receiver or captured free variable: not a wrapper param + var out []int + for i, origin := range seeds { + if origin != pos { + continue + } + si := i - off + if si < 0 || si >= len(sp) { + continue // receiver or captured free variable: not a wrapper param + } + if isStringType(sp[si]) { + out = append(out, i) + } } - return idx, isStringType(sp[si]) + slices.Sort(out) + return out } // recordSinkParam summarizes a dependency sink wrapper: when a tainted value that @@ -1115,8 +1144,8 @@ func stringParamOrigin(fn *ir.Function, seeds paramPositions, pos *ir.Position) // that is itself a modeled sink (its direct call site already fires, so summarizing // would double-report). func recordSinkParam(res *funcResult, fn *ir.Function, rule *rules.Rule, seeds paramPositions, pos, sinkPos *ir.Position) { - k, ok := stringParamOrigin(fn, seeds, pos) - if !ok { + ks := stringParamOrigins(fn, seeds, pos) + if len(ks) == 0 { return } if rule.IsSink(fn.CanonicalName) { @@ -1125,8 +1154,10 @@ func recordSinkParam(res *funcResult, fn *ir.Function, rule *rules.Rule, seeds p if res.taintsParamSink == nil { res.taintsParamSink = paramPositions{} } - if _, exists := res.taintsParamSink[k]; !exists { - res.taintsParamSink[k] = sinkPos + for _, k := range ks { + if _, exists := res.taintsParamSink[k]; !exists { + res.taintsParamSink[k] = sinkPos + } } } diff --git a/internal/scan/bench_test.go b/internal/scan/bench_test.go index 6f7c4a6..cbb4d1b 100644 --- a/internal/scan/bench_test.go +++ b/internal/scan/bench_test.go @@ -23,7 +23,18 @@ func benchScanLang(b *testing.B, dir, tool string) { if err != nil { b.Fatal(err) } + // One scan OUTSIDE the loop, because the first scan in a process pays + // once-per-process costs the rest do not: the Java frontend compiles its dump + // helper and caches it, and every subprocess frontend memoizes its toolchain + // probe. A scan this slow never gets past b.N=1 or 2, so that fixed cost is + // charged whole at b.N=1 and halved at b.N=2 — reported B/op swings ~2x on + // iteration count alone (3.6Mi vs 1.9Mi for Java), which benchstat reads as a + // regression when the two revisions happen to land on different b.N. + if _, err := Scan(dir, rs); err != nil { + b.Fatal(err) + } b.ReportAllocs() + b.ResetTimer() for i := 0; i < b.N; i++ { if _, err := Scan(dir, rs); err != nil { b.Fatal(err) diff --git a/internal/testsupport/testsupport.go b/internal/testsupport/testsupport.go index 38ade18..8971914 100644 --- a/internal/testsupport/testsupport.go +++ b/internal/testsupport/testsupport.go @@ -9,6 +9,8 @@ package testsupport import ( + "github.com/bytevet/godzilla/internal/irwalk" + ir "github.com/bytevet/godzilla/pkg/ir/v1" "os/exec" "testing" @@ -83,3 +85,22 @@ func OneRuleSet(t testing.TB, id, lang, cwe string, sources, sinks []string, opt } return &rules.RuleSet{DefaultPropagators: DefaultPropagators(t), Rules: []rules.Rule{r}} } + +// RequireNoFallbackIntrinsic asserts that no instruction in prog lowered to a +// frontend's fallback intrinsic ("js.unsupported", "py.unsupported", …). +// +// A fallback marks a construct the lowering does not model, and it is SILENT: +// the file still converts and the language still reports coverage=ok, so a +// dropped construct costs findings with nothing failing. Call this over a whole +// tree rather than a named file list — the constructs that trip it are the ones +// nobody thought to name. +func RequireNoFallbackIntrinsic(t testing.TB, prog *ir.Program, intrinsic, what string) { + t.Helper() + for _, fn := range irwalk.Funcs(prog) { + for inst := range irwalk.Instrs(fn) { + if inst.Op == ir.OpCode_OP_CODE_INTRINSIC && inst.Intrinsic == intrinsic { + t.Errorf("%s: %s in %s: %s", what, intrinsic, fn.CanonicalName, inst.Comment) + } + } + } +} diff --git a/rulepacks/_go-common.yaml b/rulepacks/_go-common.yaml index 2bc30eb..a7f2245 100644 --- a/rulepacks/_go-common.yaml +++ b/rulepacks/_go-common.yaml @@ -24,6 +24,11 @@ sources: - "go:*go-chi/chi*.URLParam" - "go:*gorilla/mux.Vars" - "go:*web.Params" # macaron/grafana free-function route params + # macaron's own name for the same accessor. Both spellings are needed: a caller + # may reach it through a re-exporting variable (`var Params = macaron.Params`, + # grafana's pkg/web), and callee names are SEMANTIC, so such a call resolves to + # the function rather than to the variable the source wrote. + - "go:*macaron.Params" - "go:*gofiber/fiber*.Ctx*.Query" - "go:*gofiber/fiber*.Ctx*.Params" - "go:*gofiber/fiber*.Ctx*.FormValue" diff --git a/rulepacks/_js-html-sanitizers.yaml b/rulepacks/_js-html-sanitizers.yaml new file mode 100644 index 0000000..72948f0 --- /dev/null +++ b/rulepacks/_js-html-sanitizers.yaml @@ -0,0 +1,11 @@ +# HTML sanitizers that neutralize a value before it is bound as raw HTML. Shared +# by every JS/TS pack whose sink renders unescaped markup, SOURCES INCLUDED OR +# NOT: react-xss-props deliberately inherits no sources (see its own comment) but +# accepts exactly these, and a sanitizer known to one raw-HTML rule and not +# another is a false positive on the same file in the same scan. +sanitizers: + - "js:*DOMPurify*" + - "js:*.sanitize" + - "js:*sanitizeHtml" + - "js:*escapeHtml" + - "js:*xss" diff --git a/rulepacks/_js-sfc-xss.yaml b/rulepacks/_js-sfc-xss.yaml index 6f9287b..678eac9 100644 --- a/rulepacks/_js-sfc-xss.yaml +++ b/rulepacks/_js-sfc-xss.yaml @@ -1,6 +1,7 @@ -# Shared browser-controlled sources and HTML sanitizers for the SFC template-XSS -# packs (vue-xss, svelte-xss), which model the same browser attack surface and -# accept the same HTML sanitizers. Pulled in with `extend: $_js-sfc-xss.yaml`; +# Shared browser-controlled sources for the SFC template-XSS packs (vue-xss, +# svelte-xss, react-xss), which model the same browser attack surface. Sanitizers +# live in _js-html-sanitizers.yaml, which packs with no shared sources still want. +# Pulled in with `extend: $_js-sfc-xss.yaml`; # each pack keeps its framework-specific sources (Vue props/router, Svelte $page) # inline. sources: @@ -12,10 +13,3 @@ sources: - "js:document.URL" - "js:document.cookie" - "js:*URLSearchParams*" -sanitizers: - # HTML sanitizers neutralize the value before it is bound as raw HTML. - - "js:*DOMPurify*" - - "js:*.sanitize" - - "js:*sanitizeHtml" - - "js:*escapeHtml" - - "js:*xss" diff --git a/rulepacks/react-xss-props.yaml b/rulepacks/react-xss-props.yaml new file mode 100644 index 0000000..fa51756 --- /dev/null +++ b/rulepacks/react-xss-props.yaml @@ -0,0 +1,55 @@ +# XSS through a React component's own boundary: a value the PARENT handed this +# component — a prop, or context — reaching dangerouslySetInnerHTML. +# +# This is the source half of the React model whose sink half is in react-xss. +# Ghost CVE-2026-24778 is the shape: a site setting arrives as +# `this.context.site.portal_signup_terms_html` and is rendered as raw HTML. +# +# Why this is a SEPARATE rule at severity: low rather than sources added to +# react-xss. A prop is not request input by construction — a component is invoked +# by another component, so the value may be request-derived or entirely internal, +# and the frontend cannot tell which. This is the same trade +# ruby-xss-cell-option makes for a cell argument, and it keeps a component that +# renders its own argument out of the default `-fail-on medium` gate while still +# reporting it. A cross-function flow lands at Medium confidence, so the LLM +# reviewer adjudicates it. +# +rules: + - id: react-xss-props + languages: [javascript] + severity: low + cwe: CWE-79 + message: >- + A value passed into this component — a prop or React context — reaches + `dangerouslySetInnerHTML`, which bypasses JSX's auto-escaping and renders + it as raw HTML. If the parent passes request-derived data, this is + Cross-Site Scripting. Render it as ordinary JSX children ({x}), which + escapes, or sanitize it (e.g. DOMPurify.sanitize) first. + extend: $_js-html-sanitizers.yaml + # Deliberately NO `extend: $_js-sfc-xss.yaml`. That fragment carries the + # ordinary browser sources, and inheriting them would make this rule a + # duplicate of react-xss on every one -- the mistake ruby-xss-cell-option + # made. The component boundary is the ONLY source here; that is the split. + sources: + # A class component's external inputs. `this` lowers to a global, so the + # FIRST hop off it is a synthetic property-read CALL and the rest are field + # reads carrying the base's taint -- which is why one exact glob covers + # `this.context.site.terms_html`. Exact, not prefixed: the callee is always + # this whole string, so a `*` would only add `this.propsCache`. + # + # `this.state` is absent on purpose. State is derived -- usually from props + # or a fetch this rule already sees at its origin -- so sourcing it + # double-counts; what is genuinely fresh in it is the component's own form + # input, which is self-XSS. + - "js:this.props" + - "js:this.context" + # A function component's props, named (`function C(props)`) or destructured + # (`function C({html})`, bound to this root by the frontend). A prefix + # match, so it reaches only a property read rooted at a base named `props`. + # + # NOT `js:*props*`: a glob's `*` crosses `/` and `.` and is matched against + # every callee, so that spelling would turn any module path or method + # containing "props" -- `js:src/utils/props.format` -- into a taint source. + - "js:props.*" + sinks: + - "js:__godzilla_react_html" diff --git a/rulepacks/react-xss.yaml b/rulepacks/react-xss.yaml index 5c82d80..f7943e1 100644 --- a/rulepacks/react-xss.yaml +++ b/rulepacks/react-xss.yaml @@ -23,7 +23,7 @@ rules: passing it as HTML. # Shared browser sources + HTML sanitizers come from the fragment; only the # React-specific sources stay inline. - extend: $_js-sfc-xss.yaml + extend: [$_js-sfc-xss.yaml, $_js-html-sanitizers.yaml] sources: # react-router route params/query — the framework's request input. - "js:*useParams" diff --git a/rulepacks/ruby-open-redirect.yaml b/rulepacks/ruby-open-redirect.yaml index 9e29394..1203ae4 100644 --- a/rulepacks/ruby-open-redirect.yaml +++ b/rulepacks/ruby-open-redirect.yaml @@ -14,6 +14,8 @@ rules: redirecting to a user-controlled URL. extend: [$_ruby-sources.yaml, $_ruby-propagators.yaml] sinks: - - "ruby:redirect_to" # Rails ActionController#redirect_to - - "ruby:redirect" # Sinatra redirect + # Pinned to the destination: unpinned, a `notice:` flash message reads as a + # redirect target (test/ruby/kwarg_taint_safe). + - "ruby:redirect_to#0" # Rails ActionController#redirect_to + - "ruby:redirect#0" # Sinatra redirect sanitizers: [] diff --git a/rulepacks/ruby-path-traversal.yaml b/rulepacks/ruby-path-traversal.yaml index 8721e8a..599b185 100644 --- a/rulepacks/ruby-path-traversal.yaml +++ b/rulepacks/ruby-path-traversal.yaml @@ -24,8 +24,10 @@ rules: - "ruby:IO.read" - "ruby:IO.binread" - "ruby:IO.readlines" - - "ruby:send_file" # Rails ActionController#send_file - - "ruby:send_data" + # Pinned to the path/body: `filename:` is a Content-Disposition value, not a + # path (test/ruby/kwarg_taint_safe). + - "ruby:send_file#0" # Rails ActionController#send_file + - "ruby:send_data#0" - "ruby:open" # Kernel#open(path) - "ruby:Kernel.open" - "ruby:FileUtils.*" # cp/mv/rm/copy/move/... all take path args diff --git a/rulepacks/ruby-ssrf.yaml b/rulepacks/ruby-ssrf.yaml index 5d4e625..8e10f6e 100644 --- a/rulepacks/ruby-ssrf.yaml +++ b/rulepacks/ruby-ssrf.yaml @@ -14,24 +14,28 @@ rules: request. extend: [$_ruby-sources.yaml, $_ruby-propagators.yaml] when: 'not hostFixed()' + # Each sink pins the URL. Unpinned, a keyword like `query:` is an injection + # point too, and the `not hostFixed()` guard then reads it as a controllable + # host (test/ruby/kwarg_taint_safe). The bare `open` is #0 and the dot-calls + # are #1 -- see the receiver convention in docs/writing-rules.md. sinks: # net/http (canonical const-path names are scoped by the frontend). - - "ruby:Net::HTTP.get" - - "ruby:Net::HTTP.post" - - "ruby:Net::HTTP.get_response" - - "ruby:Net::HTTP.start" + - "ruby:Net::HTTP.get#1" + - "ruby:Net::HTTP.post#1" + - "ruby:Net::HTTP.get_response#1" + - "ruby:Net::HTTP.start#1" # open-uri (URI.open / Kernel#open with an http(s) URL). - - "ruby:URI.open" - - "ruby:URI.parse" - - "ruby:open" - - "ruby:Kernel.open" + - "ruby:URI.open#1" + - "ruby:URI.parse#1" + - "ruby:open#0" + - "ruby:Kernel.open#1" # Popular HTTP client gems. - - "ruby:HTTParty.get" - - "ruby:HTTParty.post" - - "ruby:Faraday.get" - - "ruby:Faraday.post" - - "ruby:RestClient.get" - - "ruby:RestClient.post" + - "ruby:HTTParty.get#1" + - "ruby:HTTParty.post#1" + - "ruby:Faraday.get#1" + - "ruby:Faraday.post#1" + - "ruby:RestClient.get#1" + - "ruby:RestClient.post#1" # No built-in sanitizer neutralizes tainted input for a request URL; the fix # is allow-listing the destination host, not escaping. sanitizers: [] diff --git a/rulepacks/ruby-xss-cell-option.yaml b/rulepacks/ruby-xss-cell-option.yaml new file mode 100644 index 0000000..519d0fc --- /dev/null +++ b/rulepacks/ruby-xss-cell-option.yaml @@ -0,0 +1,64 @@ +# Stored/passed-in XSS through a cell argument: a value the CALLER handed the +# cell reaching an unescaped template interpolation. +# +# This is the source half of the cells model whose sink half is in ruby-xss (a +# cells template does not auto-escape, so `<%= %>` there is a raw sink). Decidim +# CVE-2024-41673 is the shape: a controller passes a request-derived value as a +# cell option, the cell interpolates it into a translation, and the template +# renders it unescaped. +# +# Why this is a SEPARATE rule at severity: low rather than a source added to +# ruby-xss. `options[...]` is not request input by construction -- a cell is +# invoked from a controller or another view, so the value may be a request +# parameter or an entirely internal object, and the frontend cannot tell which. +# Seeding it inside ruby-xss would put every cell that renders an argument into +# the default `-fail-on medium` gate; decidim-core alone interpolates in 137 cell +# templates. severity: low keeps these advisory under that gate while still +# reporting them, the same trade py-insecure-config makes, and a cross-function +# flow lands at Medium confidence so the LLM reviewer adjudicates it. +# +# Raising this to medium is a gate change, not a tuning knob: measure the finding +# count on a large cells app first. +rules: + - id: ruby-xss-cell-option + languages: [ruby] + severity: low + cwe: CWE-79 + message: >- + A value passed into this cell (`options[...]`) reaches an unescaped + template interpolation. A cells template is compiled by Erbse, which has + no SafeBuffer, so `<%= %>` renders raw HTML — if the caller passes + request-derived data, this is Cross-Site Scripting. Escape it at the point + of use (e.g. `decidim_html_escape`) rather than relying on the template. + # Deliberately NO `extend: $_ruby-sources.yaml`. That fragment carries the + # ordinary request sources (params, cookies), and pulling it in made this rule + # a duplicate of ruby-xss on every `raw(params[:x])` -- six corpus false + # positives, in files that are not cells at all. The cell option is the ONLY + # source here; that is the entire point of the split. + sources: + - "ruby:@cell.options" + sinks: + - "ruby:raw" + - "ruby:html_safe" + - "ruby:raw#1" + sanitizers: + - "ruby:*html_escape" + - "ruby:*sanitize" + - "ruby:*escape_javascript" + - "ruby:*ERB::Util.html_escape" + # A translation carries what is interpolated into it, so `t(x)` returns a + # string containing x. Scoped to this rule rather than the shared + # default-propagators fragment, which has no Ruby section and documents itself + # as pure string/encoding transforms; ruby-xss wants the same entry, but adding + # it there changes a GATING rule and belongs with its own measurement. + # + # This carries the POSITIONAL form only. `t("key", name: x)` -- the form + # decidim CVE-2024-41673 actually uses -- does not flow, because a hash + # argument is lowered pairwise with the CONTAINER left untainted on purpose + # (ActiveRecord's hash form is parameterized by construction, so tainting it + # would fire on every `where(name: params[:q])`). Interpolating a translation + # and parameterizing a query are the same shape to the frontend, and telling + # them apart needs per-key taint rather than a propagator here. + propagators: + - "ruby:t" + - "ruby:translate" diff --git a/rulepacks/ruby-xss.yaml b/rulepacks/ruby-xss.yaml index 6fd48ad..9bfd903 100644 --- a/rulepacks/ruby-xss.yaml +++ b/rulepacks/ruby-xss.yaml @@ -22,9 +22,15 @@ rules: - "ruby:*.html_safe" - "ruby:concat" # ERB/helper concat(user_html) - "ruby:safe_concat" - # render html:/inline:/text: interpolates the value straight into the body - # (unlike render :template, which passes a symbol constant, not taint). - - "ruby:render" + # Only these keywords put the value in the response body as HTML; `locals:` + # feeds an auto-escaping template and `json:` is not HTML (test/ruby/ + # kwarg_taint). A `when:` is required-confirmation, so this also drops the + # positional `render(x)` -- a tainted template NAME, which is template + # injection and ruby-code-injection's business, not XSS. + - sink: "ruby:render" + when: >- + kwargs.html.Tainted or kwargs.inline.Tainted or kwargs.plain.Tainted + or kwargs.text.Tainted or kwargs.body.Tainted # Rails/Ruby output-escaping helpers neutralize the value for HTML. sanitizers: - "ruby:h" # ERB::Util#h shorthand diff --git a/rulepacks/svelte-xss.yaml b/rulepacks/svelte-xss.yaml index 7050796..5da4572 100644 --- a/rulepacks/svelte-xss.yaml +++ b/rulepacks/svelte-xss.yaml @@ -19,7 +19,7 @@ rules: # SvelteKit-specific sources stay inline. (A prop declared with `export let` # lowers as an ordinary module-scope local with no source glob to match, so # the reliable untrusted sources are the browser URL, $page, and query parsing.) - extend: $_js-sfc-xss.yaml + extend: [$_js-sfc-xss.yaml, $_js-html-sanitizers.yaml] sources: - "js:*$page*" - "js:*useSearchParams*" diff --git a/rulepacks/vue-xss.yaml b/rulepacks/vue-xss.yaml index 05b637e..4a80d8b 100644 --- a/rulepacks/vue-xss.yaml +++ b/rulepacks/vue-xss.yaml @@ -20,7 +20,7 @@ rules: sanitize it (e.g. DOMPurify.sanitize) before binding it as HTML. # Shared browser sources + HTML sanitizers come from the fragment; only the # Vue-specific sources stay inline. - extend: $_js-sfc-xss.yaml + extend: [$_js-sfc-xss.yaml, $_js-html-sanitizers.yaml] sources: # Component props (data passed from a parent — external to this component). - "js:*defineProps" diff --git a/test/corpus/corpus_test.go b/test/corpus/corpus_test.go index 55a26f5..54a6e4d 100644 --- a/test/corpus/corpus_test.go +++ b/test/corpus/corpus_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + java_converter "github.com/bytevet/godzilla/converters/java" "github.com/bytevet/godzilla/internal/rules/loader" "github.com/bytevet/godzilla/internal/scan" ) @@ -28,8 +29,10 @@ func TestCorpus(t *testing.T) { _, pyErr := exec.LookPath("python3") pythonAvailable := pyErr == nil - _, javaErr := exec.LookPath("java") - javaAvailable := javaErr == nil + // Presence of `java` is not enough: the frontend's JDK 24 floor is enforced + // inside ConvertFile, so an older JDK fails every Java sample rather than + // skipping it. Ask the frontend the same question it will ask itself. + javaMajor, javaAvailable := java_converter.Usable() _, clangErr := exec.LookPath("clang") clangAvailable := clangErr == nil _, rustcErr := exec.LookPath("rustc") @@ -37,6 +40,7 @@ func TestCorpus(t *testing.T) { _, rubyErr := exec.LookPath("ruby") rubyAvailable := rubyErr == nil + positions := newPosCollector() for _, dir := range dirs { name := filepath.ToSlash(strings.TrimPrefix(dir, "../")) // e.g. "go/sql_injection" t.Run(name, func(t *testing.T) { @@ -52,7 +56,7 @@ func TestCorpus(t *testing.T) { } if strings.HasPrefix(name, "java/") { if !javaAvailable { - t.Skip("java not on PATH; skipping Java sample") + t.Skipf("no usable JDK (found Java %d); skipping Java sample", javaMajor) } // A Java sample carrying a Maven/Gradle build is compiled by that // build tool (fetching third-party deps over the network), so it is @@ -109,6 +113,7 @@ func TestCorpus(t *testing.T) { if err != nil { t.Fatalf("scan: %v", err) } + positions.add(t, name, res.Findings) got := countByRule(res.Findings) expected := map[string]bool{} @@ -135,4 +140,5 @@ func TestCorpus(t *testing.T) { } }) } + positions.assert(t) } diff --git a/test/corpus/manifest.go b/test/corpus/manifest.go index a44f94b..8b2c006 100644 --- a/test/corpus/manifest.go +++ b/test/corpus/manifest.go @@ -167,7 +167,18 @@ func countByRule(findings []analysis.Finding) map[string]int { // expectationFrom builds the Expectation that matches a scan's actual output, // used by the guarded manifest generator (see RegenerateManifests). -func expectationFrom(findings []analysis.Finding) Expectation { +// +// `prev` is the manifest being replaced. Its Max/Line/Sink are HAND-WRITTEN -- +// nothing in a scan's output implies them -- so they are carried over for every +// rule that still fires. Regenerating from output alone silently deletes the +// assertions a sample was added to make, leaving a manifest that asserts only +// what already happens. +func expectationFrom(findings []analysis.Finding, prev Expectation) Expectation { + kept := map[string]ExpectedFinding{} + for _, ef := range prev.Findings { + kept[ef.Rule] = ef + } + counts := countByRule(findings) rules := make([]string, 0, len(counts)) for r := range counts { @@ -177,7 +188,11 @@ func expectationFrom(findings []analysis.Finding) Expectation { e := Expectation{} for _, r := range rules { - e.Findings = append(e.Findings, ExpectedFinding{Rule: r, Min: counts[r]}) + ef := ExpectedFinding{Rule: r, Min: counts[r]} + if old, ok := kept[r]; ok { + ef.Max, ef.Line, ef.Sink = old.Max, old.Line, old.Sink + } + e.Findings = append(e.Findings, ef) } return e } diff --git a/test/corpus/positions_test.go b/test/corpus/positions_test.go index 3dda70b..d1430ae 100644 --- a/test/corpus/positions_test.go +++ b/test/corpus/positions_test.go @@ -9,69 +9,131 @@ import ( "testing" "github.com/bytevet/godzilla/internal/analysis" - "github.com/bytevet/godzilla/internal/rules/loader" - "github.com/bytevet/godzilla/internal/scan" ir "github.com/bytevet/godzilla/pkg/ir/v1" ) -// goldenPositions pins the EXACT source and sink line/column of every JavaScript -// finding in the corpus. +// The position golden pins the EXACT source and sink line/column of every +// corpus finding, one file per language. // -// expected.yaml asserts rule counts, and only a handful of JS samples carry a -// `line:`. That leaves a systematic position shift -- an off-by-one column, a -// line-terminator the index does not recognise -- passing the corpus silently +// expected.yaml asserts rule counts, and only a handful of samples carry a +// `line:`. That leaves a systematic position shift — an off-by-one column, a +// line-terminator the index does not recognise — passing the corpus silently // while every reported location is wrong. Positions are the part of a finding a -// human acts on, so they get an assertion of their own. +// human acts on, so they get an assertion of their own. Ruby shipped a +// zero-based column for its whole life; this is what would have caught it. +// +// Rows are keyed by sample, so a language whose toolchain is missing simply +// contributes nothing and the rest still assert. Rendering piggybacks on +// TestCorpus's scans — the oracle costs no extra work. // // Regenerate deliberately after a change that is MEANT to move positions, then -// read the diff line by line. Regeneration lives in TestRegenerateManifests -// alongside the expected.yaml rewrite, so that this test can only ever assert -- -// a gate that rewrites its own oracle when an env var is set reports green on -// exactly the run that was supposed to show you the damage. +// read the diff line by line. Regeneration lives in TestRegenerateManifests, so +// that this gate can only ever assert: one that rewrites its own oracle when an +// env var is set reports green on exactly the run that was supposed to show you +// the damage. // // GODZILLA_REGEN=1 go test ./test/corpus/ -run RegenerateManifests -const goldenPositions = "testdata/js_positions.golden" +func goldenPositions(lang string) string { + return filepath.Join("testdata", "positions", lang+".golden") +} -func TestJSFindingPositions(t *testing.T) { - rs, err := loader.Builtin() - if err != nil { - t.Fatal(err) - } - res, err := scan.Scan("../js", rs) - if err != nil { - t.Fatalf("scan ../js: %v", err) +// posCollector accumulates one rendered row per finding as the corpus is +// scanned, grouped by language. +type posCollector struct { + rows map[string][]string // language -> rows + seen map[string][]string // language -> samples actually scanned +} + +func newPosCollector() *posCollector { + return &posCollector{rows: map[string][]string{}, seen: map[string][]string{}} +} + +// langOf maps a sample name ("ruby/rails_query_sqli") to its language. +func langOf(sample string) string { return strings.SplitN(sample, "/", 2)[0] } + +func (p *posCollector) add(t *testing.T, sample string, findings []analysis.Finding) { + lang := langOf(sample) + p.seen[lang] = append(p.seen[lang], sample) + for _, f := range findings { + p.rows[lang] = append(p.rows[lang], fmt.Sprintf("%s|%s|%s|%s|src=%s|sink=%s", + sample, f.RuleID, f.Function, f.SinkCallee, relPos(t, f.SourcePos), relPos(t, f.SinkPos))) } +} - got := renderPositions(t, res.Findings) +// assert compares each language's rows against its golden, restricted to the +// samples that were actually scanned: a skipped sample must neither fail the +// gate nor let a golden row for it go unchecked forever. +func (p *posCollector) assert(t *testing.T) { + t.Helper() + for lang, scanned := range p.seen { + t.Run("positions/"+lang, func(t *testing.T) { + want, err := os.ReadFile(goldenPositions(lang)) + if err != nil { + t.Fatalf("read golden: %v (regenerate with GODZILLA_REGEN=1)", err) + } + got := p.rows[lang] + sort.Strings(got) + for _, d := range diffLines(rowsFor(splitRows(string(want)), scanned), got) { + t.Error(d) + } + }) + } +} - want, err := os.ReadFile(goldenPositions) - if err != nil { - t.Fatalf("read golden: %v (regenerate with GODZILLA_REGEN=1)", err) +// rowsFor keeps the golden rows belonging to the given samples. +func rowsFor(rows, samples []string) []string { + want := make(map[string]bool, len(samples)) + for _, s := range samples { + want[s] = true } - if got == string(want) { - return + var out []string + for _, r := range rows { + if want[strings.SplitN(r, "|", 2)[0]] { + out = append(out, r) + } } - for _, d := range diffLines(strings.Split(strings.TrimRight(string(want), "\n"), "\n"), - strings.Split(strings.TrimRight(got, "\n"), "\n")) { - t.Error(d) + return out +} + +func splitRows(s string) []string { + s = strings.TrimRight(s, "\n") + if s == "" { + return nil } + return strings.Split(s, "\n") } -// renderPositions renders one stable line per finding. Sorted, so a change in -// scan or merge ORDER never shows up as a diff -- only a real change in what was -// found or where. -func renderPositions(t *testing.T, findings []analysis.Finding) string { +// writeGoldens rewrites each language's golden, keeping the rows of samples this +// run did not scan. Dropping them would silently retire the oracle for every +// language whose toolchain the regenerating machine happens to lack. +func (p *posCollector) writeGoldens(t *testing.T) { t.Helper() - rows := make([]string, 0, len(findings)) - for _, f := range findings { - if f.Language != "javascript" { - continue + if err := os.MkdirAll(filepath.Dir(goldenPositions("x")), 0o755); err != nil { + t.Fatal(err) + } + for lang, scanned := range p.seen { + path := goldenPositions(lang) + old, _ := os.ReadFile(path) + keep := make(map[string]bool, len(scanned)) + for _, s := range scanned { + keep[s] = true + } + rows := p.rows[lang] + for _, r := range splitRows(string(old)) { + if !keep[strings.SplitN(r, "|", 2)[0]] { + rows = append(rows, r) + } + } + sort.Strings(rows) + body := "" + if len(rows) > 0 { + body = strings.Join(rows, "\n") + "\n" + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) } - rows = append(rows, fmt.Sprintf("%s|%s|%s|src=%s|sink=%s", - f.RuleID, f.Function, f.SinkCallee, relPos(t, f.SourcePos), relPos(t, f.SinkPos))) + t.Logf("wrote %s (%d rows)", path, len(rows)) } - sort.Strings(rows) - return strings.Join(rows, "\n") + "\n" } // relPos renders a position repo-relative so the golden does not encode the diff --git a/test/corpus/regen_test.go b/test/corpus/regen_test.go index 877bede..bed4f79 100644 --- a/test/corpus/regen_test.go +++ b/test/corpus/regen_test.go @@ -3,6 +3,7 @@ package corpus import ( "os" "path/filepath" + "strings" "testing" "github.com/bytevet/godzilla/internal/rules/loader" @@ -37,7 +38,13 @@ func TestRegenerateManifests(t *testing.T) { t.Errorf("scan %s: %v (leaving manifest untouched)", dir, err) continue } - exp := expectationFrom(res.Findings) + if lang, ok := unconverted(res); ok { + t.Logf("scan %s: the %s frontend did not convert (%s); leaving manifest untouched", dir, lang, coverageErr(res, lang)) + continue + } + path := filepath.Join(dir, "expected.yaml") + prev, _ := loadExpectation(path) + exp := expectationFrom(res.Findings, prev) body, err := yaml.Marshal(exp) if err != nil { t.Fatal(err) @@ -45,7 +52,6 @@ func TestRegenerateManifests(t *testing.T) { header := "# Expected findings for this sample (see test/README.md). Regenerate with:\n" + "# GODZILLA_REGEN=1 go test ./test/corpus/ -run RegenerateManifests\n" out := header + string(body) - path := filepath.Join(dir, "expected.yaml") if err := os.WriteFile(path, []byte(out), 0o644); err != nil { t.Fatal(err) } @@ -53,27 +59,57 @@ func TestRegenerateManifests(t *testing.T) { } } -// TestRegenerateJSPositionGolden rewrites the position golden TestJSFindingPositions -// asserts against. It lives here, behind the same GODZILLA_REGEN gate as the -// manifests, so the gate itself stays a pure assertion. -func TestRegenerateJSPositionGolden(t *testing.T) { +// TestRegeneratePositionGoldens rewrites the per-language position goldens the +// corpus asserts against. Separate from the manifests so each can be +// regenerated without rewriting the other, and behind the same env gate so the +// gates themselves stay pure assertions. +func TestRegeneratePositionGoldens(t *testing.T) { if os.Getenv("GODZILLA_REGEN") == "" { - t.Skip("set GODZILLA_REGEN=1 to regenerate the position golden") + t.Skip("set GODZILLA_REGEN=1 to regenerate the position goldens") } rs, err := loader.Builtin() if err != nil { t.Fatal(err) } - res, err := scan.Scan("../js", rs) + dirs, err := sampleDirs() if err != nil { t.Fatal(err) } - got := renderPositions(t, res.Findings) - if err := os.MkdirAll(filepath.Dir(goldenPositions), 0o755); err != nil { - t.Fatal(err) + positions := newPosCollector() + for _, dir := range dirs { + res, err := scan.Scan(dir, rs) + if err != nil { + t.Errorf("scan %s: %v (leaving its golden rows untouched)", dir, err) + continue + } + // A frontend that could not run reports zero findings, which is + // indistinguishable from a clean sample. Recording that would retire the + // oracle for every language whose toolchain this machine lacks. + if lang, ok := unconverted(res); ok { + t.Logf("scan %s: the %s frontend did not convert (%s); leaving its golden rows untouched", dir, lang, coverageErr(res, lang)) + continue + } + positions.add(t, filepath.ToSlash(strings.TrimPrefix(dir, "../")), res.Findings) } - if err := os.WriteFile(goldenPositions, []byte(got), 0o644); err != nil { - t.Fatal(err) + positions.writeGoldens(t) +} + +// unconverted names a language the target contains but the frontend failed to +// lower. +func unconverted(res scan.Result) (string, bool) { + for _, c := range res.Coverage { + if c.Detected && !c.Converted { + return c.Language, true + } + } + return "", false +} + +func coverageErr(res scan.Result, lang string) string { + for _, c := range res.Coverage { + if c.Language == lang { + return c.Err + } } - t.Logf("wrote %s", goldenPositions) + return "" } diff --git a/test/corpus/testdata/js_positions.golden b/test/corpus/testdata/js_positions.golden deleted file mode 100644 index ba62479..0000000 --- a/test/corpus/testdata/js_positions.golden +++ /dev/null @@ -1,60 +0,0 @@ -js-code-injection|js:code_injection/app.$anon0|js:eval|src=test/js/code_injection/app.js:5:14|sink=test/js/code_injection/app.js:6:16 -js-code-injection|js:decorators_for_await/service.SessionService.runFromRequest|js:eval|src=test/js/decorators_for_await/service.js:12:10|sink=test/js/decorators_for_await/service.js:12:5 -js-code-injection|js:decorators_for_await/service.drainAndRun|js:eval|src=test/js/decorators_for_await/service.js:20:8|sink=test/js/decorators_for_await/service.js:20:3 -js-command-injection|js:branch_merge_default/app.$anon0|js:child_process.execSync|src=test/js/branch_merge_default/app.js:11:14|sink=test/js/branch_merge_default/app.js:15:3 -js-command-injection|js:command_injection/app.handleCmd|js:child_process.exec|src=test/js/command_injection/app.js:12:13|sink=test/js/command_injection/app.js:13:3 -js-command-injection|js:command_injection_require/app.$anon0|js:child_process.exec|src=test/js/command_injection_require/app.js:10:45|sink=test/js/command_injection_require/app.js:10:37 -js-command-injection|js:command_injection_require/app.$anon1|js:child_process.exec|src=test/js/command_injection_require/app.js:11:42|sink=test/js/command_injection_require/app.js:11:37 -js-command-injection|js:command_injection_require/app.$anon2|js:child_process.execSync|src=test/js/command_injection_require/app.js:12:40|sink=test/js/command_injection_require/app.js:12:37 -js-command-injection|js:esm_module/app.$anon0|js:child_process.exec|src=test/js/esm_module/app.js:11:16|sink=test/js/esm_module/app.js:12:3 -js-command-injection|js:esmodule/app.run|js:child_process.execSync|src=test/js/esmodule/app.mjs:7:17|sink=test/js/esmodule/app.mjs:8:5 -js-command-injection|js:fastify/app.$anon1|js:child_process.exec|src=test/js/fastify/app.js:17:16|sink=test/js/fastify/app.js:18:3 -js-command-injection|js:handler_destructure/app.$anon0|js:child_process.exec|src=test/js/handler_destructure/app.js:9:18|sink=test/js/handler_destructure/app.js:10:3 -js-command-injection|js:handler_destructure/app.$anon1|js:child_process.exec|src=test/js/handler_destructure/app.js:15:20|sink=test/js/handler_destructure/app.js:16:3 -js-command-injection|js:handler_param/app.$anon0|js:child_process.exec|src=test/js/handler_param/app.js:8:15|sink=test/js/handler_param/app.js:9:3 -js-command-injection|js:header_source/app.$anon0|js:child_process.execSync|src=test/js/header_source/app.js:5:17|sink=test/js/header_source/app.js:6:5 -js-command-injection|js:koa/app.$anon1|js:child_process.exec|src=test/js/koa/app.js:22:16|sink=test/js/koa/app.js:23:3 -js-command-injection|js:loop_carried_command_injection/app.handleRun|js:child_process.exec|src=test/js/loop_carried_command_injection/app.js:21:11|sink=test/js/loop_carried_command_injection/app.js:20:5 -js-command-injection|js:loop_header_callback/app.handleBatch|js:child_process.exec|src=test/js/loop_header_callback/app.js:6:21|sink=test/js/loop_header_callback/app.js:7:5 -js-command-injection|js:propagator_default/app.$anon0|js:child_process.execSync|src=test/js/propagator_default/app.js:7:17|sink=test/js/propagator_default/app.js:8:5 -js-command-injection|js:try_catch_command_injection/app.handleLookup|js:child_process.exec|src=test/js/try_catch_command_injection/app.js:16:12|sink=test/js/try_catch_command_injection/app.js:19:5 -js-command-injection|js:typescript/app.$anon0|js:child_process.execSync|src=test/js/typescript/app.ts:11:25|sink=test/js/typescript/app.ts:12:5 -js-insecure-deserialization|js:deserialization/app.$anon0|js:node-serialize.unserialize|src=test/js/deserialization/app.js:4:16|sink=test/js/deserialization/app.js:5:3 -js-open-redirect|js:open_redirect/app.$anon0|js:res.redirect|src=test/js/open_redirect/app.js:5:16|sink=test/js/open_redirect/app.js:6:3 -js-open-redirect|js:open_redirect_guard_bypass/app.$anon0|js:res.redirect|src=test/js/open_redirect_guard_bypass/app.js:14:14|sink=test/js/open_redirect_guard_bypass/app.js:18:3 -js-path-traversal|js:express_params/app.$anon0|js:res.sendFile|src=test/js/express_params/app.js:11:14|sink=test/js/express_params/app.js:12:3 -js-path-traversal|js:path_traversal/app.handleDownload|js:fs.readFile|src=test/js/path_traversal/app.js:15:18|sink=test/js/path_traversal/app.js:16:3 -js-path-traversal|js:path_traversal/app.handleServe|js:res.sendFile|src=test/js/path_traversal/app.js:28:18|sink=test/js/path_traversal/app.js:29:3 -js-path-traversal|js:path_traversal/app.handleStream|js:fs.createReadStream|src=test/js/path_traversal/app.js:22:18|sink=test/js/path_traversal/app.js:23:16 -js-path-traversal|js:path_traversal_join/app.handleDownload|js:fs.readFile|src=test/js/path_traversal_join/app.js:13:18|sink=test/js/path_traversal_join/app.js:15:3 -js-path-traversal|js:path_traversal_requrl_memfs/app.serve|js:context.outputFileSystem.createReadStream|src=test/js/path_traversal_requrl_memfs/app.js:14:18|sink=test/js/path_traversal_requrl_memfs/app.js:20:5 -js-path-traversal|js:path_traversal_requrl_memfs/app.serve|js:context.outputFileSystem.statSync|src=test/js/path_traversal_requrl_memfs/app.js:14:18|sink=test/js/path_traversal_requrl_memfs/app.js:18:15 -js-path-traversal|js:wdm_getfilename/getFilenameFromUrl.getFilenameFromUrl|js:outputFileSystem.statSync|src=test/js/wdm_getfilename/middleware.js:14:40|sink=test/js/wdm_getfilename/getFilenameFromUrl.js:21:10 -js-sqli|js:crossfile_sfc_import/Renderer.render|js:some-db.query|src=test/js/crossfile_sfc_import/app.js:9:14|sink=test/js/crossfile_sfc_import/Renderer.vue:6:10 -js-sqli|js:fastify/app.$anon0|js:db.query|src=test/js/fastify/app.js:11:14|sink=test/js/fastify/app.js:12:22 -js-sqli|js:interproc_local_helper/app.$anon0|js:some-db.query|src=test/js/interproc_local_helper/app.js:17:13|sink=test/js/interproc_local_helper/app.js:19:12 -js-sqli|js:koa/app.$anon0|js:db.query|src=test/js/koa/app.js:15:14|sink=test/js/koa/app.js:17:22 -js-sqli|js:sql_injection/app.handleUser|js:db.query|src=test/js/sql_injection/app.js:13:12|sink=test/js/sql_injection/app.js:15:3 -js-sqli|js:sqli_array_destructure/app.$anon0|js:some-db.query|src=test/js/sqli_array_destructure/app.js:9:14|sink=test/js/sqli_array_destructure/app.js:10:12 -js-sqli|js:sqli_class_method/app.UserController.runQuery|js:some-db.query|src=test/js/sqli_class_method/app.js:13:14|sink=test/js/sqli_class_method/app.js:9:12 -js-sqli|js:sqli_destructure/app.$anon0|js:some-db.query|src=test/js/sqli_destructure/app.js:9:16|sink=test/js/sqli_destructure/app.js:10:12 -js-sqli|js:sqli_knex_raw/app.handleUser|js:knex.raw|src=test/js/sqli_knex_raw/app.js:13:12|sink=test/js/sqli_knex_raw/app.js:14:3 -js-sqli|js:sqli_optional_chain/app.$anon0|js:some-db.query|src=test/js/sqli_optional_chain/app.js:9:14|sink=test/js/sqli_optional_chain/app.js:10:12 -js-sqli|js:sqli_prisma_raw/app.handleUser|js:prisma.$queryRawUnsafe|src=test/js/sqli_prisma_raw/app.js:13:14|sink=test/js/sqli_prisma_raw/app.js:15:3 -js-sqli|js:sqli_template_literal/app.$anon0|js:some-db.query|src=test/js/sqli_template_literal/app.js:9:13|sink=test/js/sqli_template_literal/app.js:11:12 -js-ssrf|js:ssrf/app.handleProxyAxios|js:axios.get|src=test/js/ssrf/app.js:22:16|sink=test/js/ssrf/app.js:23:3 -js-ssrf|js:ssrf/app.handleProxyFetch|js:fetch|src=test/js/ssrf/app.js:29:16|sink=test/js/ssrf/app.js:30:3 -js-ssrf|js:ssrf/app.handleProxyHttp|js:http.get|src=test/js/ssrf/app.js:15:16|sink=test/js/ssrf/app.js:16:3 -js-ssrf|js:ssrf_new_url/app.directURL|js:fetch|src=test/js/ssrf_new_url/app.js:17:41|sink=test/js/ssrf_new_url/app.js:17:16 -js-ssrf|js:ssrf_new_url/app.fixedBase|js:fetch|src=test/js/ssrf_new_url/app.js:36:45|sink=test/js/ssrf_new_url/app.js:36:16 -js-ssrf|js:ssrf_new_url/app.forwardAction|js:fetch|src=test/js/ssrf_new_url/app.js:11:32|sink=test/js/ssrf_new_url/app.js:13:16 -js-ssrf|js:ssrf_new_url/app.taintedBase|js:fetch|src=test/js/ssrf_new_url/app.js:32:41|sink=test/js/ssrf_new_url/app.js:32:16 -js-xss|js:param_object_field/app.$anon0|js:res.send|src=test/js/param_object_field/app.js:10:18|sink=test/js/param_object_field/app.js:15:3 -js-xss|js:promise_then/app.$anon0$anon0|js:res.send|src=test/js/promise_then/app.js:9:10|sink=test/js/promise_then/app.js:14:5 -js-xss|js:resilience/app.handleGreeting|js:res.send|src=test/js/resilience/app.js:10:18|sink=test/js/resilience/app.js:11:3 -js-xss|js:xss/app.handleName|js:res.send|src=test/js/xss/app.js:11:14|sink=test/js/xss/app.js:12:3 -react-xss|js:react_xss/app.Bio|js:__godzilla_react_html|src=test/js/react_xss/app.jsx:7:15|sink=test/js/react_xss/app.jsx:8:65 -react-xss|js:react_xss/app.IndirectBio|js:__godzilla_react_html|src=test/js/react_xss/app.jsx:13:25|sink=test/js/react_xss/app.jsx:13:25 -secret-private-key|js:secrets/app.||src=test/js/secrets/app.js:4:1|sink=test/js/secrets/app.js:4:1 -svelte-xss|js:svelte_xss/App.|js:__godzilla_svelte_html|src=test/js/svelte_xss/App.svelte:5:13|sink=test/js/svelte_xss/App.svelte:10:8 -vue-xss|js:vue_xss/App.|js:__godzilla_vue_vhtml|src=test/js/vue_xss/App.vue:6:15|sink=test/js/vue_xss/App.vue:13:5 diff --git a/test/corpus/testdata/positions/go.golden b/test/corpus/testdata/positions/go.golden new file mode 100644 index 0000000..0a59410 --- /dev/null +++ b/test/corpus/testdata/positions/go.golden @@ -0,0 +1,59 @@ +go/byte_reconstruction|go-command-injection|go:godzilla-samples/byte_reconstruction.main$1|go:os/exec.Command|src=test/go/byte_reconstruction/main.go:27:26|sink=test/go/byte_reconstruction/main.go:31:15 +go/chi|go-command-injection|go:godzilla/test/go/chi.main$1|go:os/exec.Command|src=test/go/chi/main.go:17:23|sink=test/go/chi/main.go:18:19 +go/closure_capture|go-sql-injection|go:closurecapture.handler$1|go:(*database/sql.DB).Query|src=test/go/closure_capture/main.go:14:6|sink=test/go/closure_capture/main.go:17:18 +go/command_injection_variadic|go-command-injection|go:cmdinjvariadic.handler|go:os/exec.Command|src=test/go/command_injection_variadic/main.go:12:6|sink=test/go/command_injection_variadic/main.go:14:18 +go/command_injection|go-command-injection|go:godzilla-samples/command_injection.main$1|go:os/exec.Command|src=test/go/command_injection/main.go:10:26|sink=test/go/command_injection/main.go:13:27 +go/comparison_result_safe|go-command-injection|go:comparison_result_safe.handler|go:os/exec.Command|src=test/go/comparison_result_safe/main.go:13:6|sink=test/go/comparison_result_safe/main.go:21:18 +go/dep_sink_wrapper_nested|go-command-injection|go:godzilla/test/go/dep_sink_wrapper_nested.handler|go:example.com/svc.Fetch|src=test/go/dep_sink_wrapper_nested/main.go:15:6|sink=test/go/dep_sink_wrapper_nested/main.go:17:11 +go/dep_sink_wrapper|go-command-injection|go:godzilla/test/go/dep_sink_wrapper.handler|go:example.com/cmdutil.Run|src=test/go/dep_sink_wrapper/main.go:16:6|sink=test/go/dep_sink_wrapper/main.go:18:13 +go/dep_transit|go-command-injection|go:godzilla/test/go/dep_transit.handler|go:os/exec.Command|src=test/go/dep_transit/main.go:14:6|sink=test/go/dep_transit/main.go:17:14 +go/echo|go-sql-injection|go:godzilla/test/go/echo.main$1|go:(*database/sql.DB).Query|src=test/go/echo/main.go:18:21|sink=test/go/echo/main.go:20:24 +go/edgecases|go-command-injection|go:godzilla-samples/edgecases.main$2|go:os/exec.Command|src=test/go/edgecases/main.go:19:24|sink=test/go/edgecases/main.go:22:19 +go/edgecases|go-sql-injection|go:godzilla-samples/edgecases.main$1|go:(*database/sql.DB).Query|src=test/go/edgecases/main.go:14:24|sink=test/go/edgecases/main.go:16:3 +go/fiber|go-command-injection|go:godzilla/test/go/fiber.main$1|go:os/exec.Command|src=test/go/fiber/main.go:16:17|sink=test/go/fiber/main.go:17:15 +go/field_access_source|go-path-traversal|go:godzilla/test/go/field_access_source.main$1|go:os.Open|src=test/go/field_access_source/main.go:15:27|sink=test/go/field_access_source/main.go:17:20 +go/form_binding|go-path-traversal|go:godzilla-samples/form_binding.updateRepoFile|go:os.Remove|src=test/go/form_binding/main.go:34:6|sink=test/go/form_binding/main.go:30:11 +go/framework_dep_wrapper_nested|go-command-injection|go:godzilla/test/go/framework_dep_wrapper_nested.main$1|go:os/exec.Command|src=test/go/framework_dep_wrapper_nested/requtil/util.go:3:67|sink=test/go/framework_dep_wrapper_nested/main.go:18:25 +go/framework_dep_wrapper|go-command-injection|go:godzilla/test/go/framework_dep_wrapper.main$1|go:os/exec.Command|src=test/go/framework_dep_wrapper/requtil/util.go:8:16|sink=test/go/framework_dep_wrapper/main.go:22:25 +go/framework_field_source|go-command-injection|go:godzilla/test/go/framework_field_source.main$1|go:os/exec.Command|src=test/go/framework_field_source/beegostub/beegostub.go:17:15|sink=test/go/framework_field_source/main.go:16:15 +go/framework_route_param|go-path-traversal|go:frameworkrouteparam.getAsset|go:os.Open|src=test/go/framework_route_param/main.go:19:29|sink=test/go/framework_route_param/main.go:21:19 +go/func_alias_source|go-path-traversal|go:funcaliassource.direct|go:os.Open|src=test/go/func_alias_source/main.go:24:73|sink=test/go/func_alias_source/main.go:24:17 +go/func_alias_source|go-path-traversal|go:funcaliassource.viaAlias|go:os.Open|src=test/go/func_alias_source/main.go:18:69|sink=test/go/func_alias_source/main.go:18:17 +go/gin_gorm|go-command-injection|go:godzilla/test/go/gin_gorm.main$3|go:os/exec.Command|src=test/go/gin_gorm/main.go:42:18|sink=test/go/gin_gorm/main.go:43:25 +go/gin_gorm|go-sql-injection|go:godzilla/test/go/gin_gorm.main$1|go:(*gorm.io/gorm.DB).Raw|src=test/go/gin_gorm/main.go:26:16|sink=test/go/gin_gorm/main.go:28:9 +go/gin_gorm|go-sql-injection|go:godzilla/test/go/gin_gorm.main$2|go:(*gorm.io/gorm.DB).Where|src=test/go/gin_gorm/main.go:34:16|sink=test/go/gin_gorm/main.go:36:11 +go/global_taint|go-command-injection|go:godzilla-samples/global_taint.runHandler|go:os/exec.Command|src=test/go/global_taint/main.go:16:6|sink=test/go/global_taint/main.go:22:18 +go/gorilla_mux|go-command-injection|go:godzilla/test/go/gorilla_mux.main$1|go:os/exec.Command|src=test/go/gorilla_mux/main.go:15:19|sink=test/go/gorilla_mux/main.go:16:15 +go/header_source|go-command-injection|go:godzilla-samples/header_source.main$1|go:os/exec.Command|src=test/go/header_source/main.go:11:23|sink=test/go/header_source/main.go:13:15 +go/higher_order_callback|go-command-injection|go:godzilla-samples/higher_order_callback.runCmd|go:os/exec.Command|src=test/go/higher_order_callback/main.go:21:6|sink=test/go/higher_order_callback/main.go:14:14 +go/interface_dispatch|go-command-injection|go:(*godzilla-samples/interface_dispatch.shellRunner).Run|go:os/exec.Command|src=test/go/interface_dispatch/main.go:23:24|sink=test/go/interface_dispatch/main.go:15:14 +go/interproc|go-command-injection|go:godzilla-samples/interproc.runCommand|go:os/exec.Command|src=test/go/interproc/main.go:22:26|sink=test/go/interproc/main.go:18:14 +go/open_redirect|go-open-redirect|go:godzilla-samples/open_redirect.main$1|go:net/http.Redirect|src=test/go/open_redirect/main.go:6:31|sink=test/go/open_redirect/main.go:8:16 +go/outparam_fill|go-command-injection|go:godzilla-samples/outparam_fill.handler|go:os/exec.Command|src=test/go/outparam_fill/main.go:13:6|sink=test/go/outparam_fill/main.go:20:18 +go/path_traversal_servefile|go-path-traversal|go:servefilept.handler|go:net/http.ServeFile|src=test/go/path_traversal_servefile/main.go:9:6|sink=test/go/path_traversal_servefile/main.go:11:16 +go/path_traversal|go-path-traversal|go:godzilla/test/go/path_traversal.main$1|go:os.Open|src=test/go/path_traversal/main.go:12:27|sink=test/go/path_traversal/main.go:16:20 +go/propagator_default|go-command-injection|go:godzilla-samples/propagator_default.main$1|go:os/exec.Command|src=test/go/propagator_default/main.go:15:26|sink=test/go/propagator_default/main.go:17:15 +go/request_helper|go-command-injection|go:godzilla/test/go/request_helper.handler|go:os/exec.Command|src=test/go/request_helper/main.go:16:6|sink=test/go/request_helper/main.go:17:14 +go/return_flow|go-command-injection|go:godzilla-samples/return_flow.main$1|go:os/exec.Command|src=test/go/return_flow/main.go:19:26|sink=test/go/return_flow/main.go:21:15 +go/return_struct_field|go-sql-injection|go:godzilla-samples/return_struct_field.handler|go:(*database/sql.DB).Query|src=test/go/return_struct_field/main.go:17:6|sink=test/go/return_struct_field/main.go:25:17 +go/sanitizer_bypass|go-command-injection|go:godzilla-samples/sanitizer_bypass.main$1|go:os/exec.Command|src=test/go/sanitizer_bypass/main.go:20:26|sink=test/go/sanitizer_bypass/main.go:23:15 +go/secrets|secret-aws-access-key|go:godzilla-samples/secrets.main||src=-|sink=- +go/secrets|secret-jwt|go:godzilla-samples/secrets.main||src=-|sink=- +go/sql_injection|go-sql-injection|go:(*godzilla/test/go/sql_injection.User).GetByID|go:(*database/sql.DB).QueryRow|src=test/go/sql_injection/main.go:43:6|sink=test/go/sql_injection/main.go:40:20 +go/sql_injection|go-sql-injection|go:godzilla/test/go/sql_injection.main$1|go:(*database/sql.DB).Query|src=test/go/sql_injection/main.go:58:27|sink=test/go/sql_injection/main.go:62:24 +go/sqli_pathvalue|go-sql-injection|go:sqlipathvalue.handler|go:(*database/sql.DB).Query|src=test/go/sqli_pathvalue/main.go:13:6|sink=test/go/sqli_pathvalue/main.go:15:23 +go/ssrf|go-ssrf|go:godzilla-samples/ssrf.main$1|go:net/http.Get|src=test/go/ssrf/main.go:10:28|sink=test/go/ssrf/main.go:13:24 +go/stdlib_wrap|go-command-injection|go:godzilla/test/go/stdlib_wrap.main$1|go:os/exec.Command|src=test/go/stdlib_wrap/webctx/webctx.go:19:12|sink=test/go/stdlib_wrap/main.go:19:15 +go/termination_stress|go-command-injection|go:godzilla/test/go/termination_stress.handler|go:os/exec.Command|src=test/go/termination_stress/main.go:111:6|sink=test/go/termination_stress/main.go:113:14 +go/termination_stress|go-command-injection|go:godzilla/test/go/termination_stress.handler|go:os/exec.Command|src=test/go/termination_stress/main.go:111:6|sink=test/go/termination_stress/main.go:114:14 +go/termination_stress|go-command-injection|go:godzilla/test/go/termination_stress.handler|go:os/exec.Command|src=test/go/termination_stress/main.go:111:6|sink=test/go/termination_stress/main.go:115:14 +go/termination_stress|go-command-injection|go:godzilla/test/go/termination_stress.handler|go:os/exec.Command|src=test/go/termination_stress/main.go:111:6|sink=test/go/termination_stress/main.go:116:14 +go/termination_stress|go-command-injection|go:godzilla/test/go/termination_stress.handler|go:os/exec.Command|src=test/go/termination_stress/main.go:111:6|sink=test/go/termination_stress/main.go:118:14 +go/type_assertion|go-sql-injection|go:typeassert.handler|go:(*database/sql.DB).Query|src=test/go/type_assertion/main.go:14:6|sink=test/go/type_assertion/main.go:17:23 +go/unknown_framework|go-command-injection|go:godzilla/test/go/unknown_framework.main$1|go:os/exec.Command|src=test/go/unknown_framework/main.go:19:16|sink=test/go/unknown_framework/main.go:21:15 +go/unknown_framework|go-sql-injection|go:godzilla/test/go/unknown_framework.main$1|go:(*database/sql.DB).Query|src=test/go/unknown_framework/main.go:19:16|sink=test/go/unknown_framework/main.go:25:18 +go/url_parse_traversal|go-path-traversal|go:godzilla/test/go/url_parse_traversal.handler|go:os.ReadFile|src=test/go/url_parse_traversal/main.go:14:6|sink=test/go/url_parse_traversal/main.go:20:24 +go/weak_crypto|go-weak-cipher|go:godzilla-samples/weak_crypto.main|go:crypto/des.NewCipher|src=-|sink=test/go/weak_crypto/main.go:13:23 +go/weak_crypto|go-weak-hash|go:godzilla-samples/weak_crypto.main|go:crypto/md5.New|src=-|sink=test/go/weak_crypto/main.go:12:14 +go/xss_template_html|go-xss|go:godzilla/test/go/xss_template_html.handler|go:html/template.HTML|src=test/go/xss_template_html/main.go:12:6|sink=test/go/xss_template_html/main.go:16:35 +go/xss|go-xss|go:godzilla-samples/xss.main$1|go:fmt.Fprintf|src=test/go/xss/main.go:9:28|sink=test/go/xss/main.go:12:14 diff --git a/test/corpus/testdata/positions/java.golden b/test/corpus/testdata/positions/java.golden new file mode 100644 index 0000000..5fce9dc --- /dev/null +++ b/test/corpus/testdata/positions/java.golden @@ -0,0 +1,15 @@ +java/branch_merge_ternary|java-command-injection|java:Handler.handle|java:java/lang/Runtime.exec|src=test/java/branch_merge_ternary/Handler.java:12:0|sink=test/java/branch_merge_ternary/Handler.java:14:0 +java/command_injection|java-command-injection|java:Handler.handle|java:java/lang/Runtime.exec|src=test/java/command_injection/Handler.java:7:0|sink=test/java/command_injection/Handler.java:8:0 +java/insecure_deserialization|java-insecure-deserialization|java:Handler.handle|java:java/io/ObjectInputStream.|src=test/java/insecure_deserialization/Handler.java:7:0|sink=test/java/insecure_deserialization/Handler.java:7:0 +java/interproc_instance|java-command-injection|java:Runner.run|java:java/lang/Runtime.exec|src=test/java/interproc_instance/Handler.java:8:0|sink=test/java/interproc_instance/Runner.java:8:0 +java/jaxrs|java-command-injection|java:Handler.ping|java:java/lang/Runtime.exec|src=test/java/jaxrs/Handler.java:19:0|sink=test/java/jaxrs/Handler.java:19:0 +java/jaxrs|java-sql-injection|java:Handler.getUser|java:java/sql/Statement.executeQuery|src=test/java/jaxrs/Handler.java:15:0|sink=test/java/jaxrs/Handler.java:15:0 +java/open_redirect|java-open-redirect|java:Handler.handle|java:javax/servlet/http/HttpServletResponse.sendRedirect|src=test/java/open_redirect/Handler.java:6:0|sink=test/java/open_redirect/Handler.java:7:0 +java/path_traversal|java-path-traversal|java:Handler.readNio|java:java/nio/file/Files.readAllBytes|src=test/java/path_traversal/Handler.java:16:0|sink=test/java/path_traversal/Handler.java:17:0 +java/path_traversal|java-path-traversal|java:Handler.readStream|java:java/io/FileInputStream.|src=test/java/path_traversal/Handler.java:10:0|sink=test/java/path_traversal/Handler.java:11:0 +java/spring_annotation|java-command-injection|java:Handler.ping|java:java/lang/Runtime.exec|src=test/java/spring_annotation/Handler.java:19:0|sink=test/java/spring_annotation/Handler.java:19:0 +java/spring_annotation|java-sql-injection|java:Handler.getUser|java:java/sql/Statement.executeQuery|src=test/java/spring_annotation/Handler.java:15:0|sink=test/java/spring_annotation/Handler.java:15:0 +java/sql_injection|java-sql-injection|java:Dao.run|java:java/sql/Statement.executeQuery|src=test/java/sql_injection/Dao.java:7:0|sink=test/java/sql_injection/Dao.java:8:0 +java/ssrf|java-ssrf|java:Handler.handle|java:java/net/URL.|src=test/java/ssrf/Handler.java:7:0|sink=test/java/ssrf/Handler.java:8:0 +java/weak_cipher_ecb|java-ecb-mode|java:EcbCipher.weak|java:javax/crypto/Cipher.getInstance|src=-|sink=test/java/weak_cipher_ecb/EcbCipher.java:9:0 +java/xss|java-xss|java:Handler.handle|java:java/io/PrintWriter.println|src=test/java/xss/Handler.java:7:0|sink=test/java/xss/Handler.java:8:0 diff --git a/test/corpus/testdata/positions/js.golden b/test/corpus/testdata/positions/js.golden new file mode 100644 index 0000000..4512e24 --- /dev/null +++ b/test/corpus/testdata/positions/js.golden @@ -0,0 +1,65 @@ +js/branch_merge_default|js-command-injection|js:app.$anon0|js:child_process.execSync|src=test/js/branch_merge_default/app.js:11:14|sink=test/js/branch_merge_default/app.js:15:3 +js/code_injection|js-code-injection|js:app.$anon0|js:eval|src=test/js/code_injection/app.js:5:14|sink=test/js/code_injection/app.js:6:16 +js/command_injection_require|js-command-injection|js:app.$anon0|js:child_process.exec|src=test/js/command_injection_require/app.js:10:45|sink=test/js/command_injection_require/app.js:10:37 +js/command_injection_require|js-command-injection|js:app.$anon1|js:child_process.exec|src=test/js/command_injection_require/app.js:11:42|sink=test/js/command_injection_require/app.js:11:37 +js/command_injection_require|js-command-injection|js:app.$anon2|js:child_process.execSync|src=test/js/command_injection_require/app.js:12:40|sink=test/js/command_injection_require/app.js:12:37 +js/command_injection|js-command-injection|js:app.handleCmd|js:child_process.exec|src=test/js/command_injection/app.js:12:13|sink=test/js/command_injection/app.js:13:3 +js/crossfile_interproc|js-sqli|js:db.run|js:some-db.query|src=test/js/crossfile_interproc/app.js:8:13|sink=test/js/crossfile_interproc/db.js:5:10 +js/crossfile_sfc_import|js-sqli|js:Renderer.render|js:some-db.query|src=test/js/crossfile_sfc_import/app.js:9:14|sink=test/js/crossfile_sfc_import/Renderer.vue:6:10 +js/decorators_for_await|js-code-injection|js:service.SessionService.runFromRequest|js:eval|src=test/js/decorators_for_await/service.js:12:10|sink=test/js/decorators_for_await/service.js:12:5 +js/decorators_for_await|js-code-injection|js:service.drainAndRun|js:eval|src=test/js/decorators_for_await/service.js:20:8|sink=test/js/decorators_for_await/service.js:20:3 +js/deserialization|js-insecure-deserialization|js:app.$anon0|js:node-serialize.unserialize|src=test/js/deserialization/app.js:4:16|sink=test/js/deserialization/app.js:5:3 +js/esm_module|js-command-injection|js:app.$anon0|js:child_process.exec|src=test/js/esm_module/app.js:11:16|sink=test/js/esm_module/app.js:12:3 +js/esmodule|js-command-injection|js:app.run|js:child_process.execSync|src=test/js/esmodule/app.mjs:7:17|sink=test/js/esmodule/app.mjs:8:5 +js/express_params|js-path-traversal|js:app.$anon0|js:res.sendFile|src=test/js/express_params/app.js:11:14|sink=test/js/express_params/app.js:12:3 +js/fastify|js-command-injection|js:app.$anon1|js:child_process.exec|src=test/js/fastify/app.js:17:16|sink=test/js/fastify/app.js:18:3 +js/fastify|js-sqli|js:app.$anon0|js:db.query|src=test/js/fastify/app.js:11:14|sink=test/js/fastify/app.js:12:22 +js/handler_destructure|js-command-injection|js:app.$anon0|js:child_process.exec|src=test/js/handler_destructure/app.js:9:18|sink=test/js/handler_destructure/app.js:10:3 +js/handler_destructure|js-command-injection|js:app.$anon1|js:child_process.exec|src=test/js/handler_destructure/app.js:15:20|sink=test/js/handler_destructure/app.js:16:3 +js/handler_param|js-command-injection|js:app.$anon0|js:child_process.exec|src=test/js/handler_param/app.js:8:15|sink=test/js/handler_param/app.js:9:3 +js/header_source|js-command-injection|js:app.$anon0|js:child_process.execSync|src=test/js/header_source/app.js:5:17|sink=test/js/header_source/app.js:6:5 +js/interproc_local_helper|js-sqli|js:app.$anon0|js:some-db.query|src=test/js/interproc_local_helper/app.js:17:13|sink=test/js/interproc_local_helper/app.js:19:12 +js/koa|js-command-injection|js:app.$anon1|js:child_process.exec|src=test/js/koa/app.js:22:16|sink=test/js/koa/app.js:23:3 +js/koa|js-sqli|js:app.$anon0|js:db.query|src=test/js/koa/app.js:15:14|sink=test/js/koa/app.js:17:22 +js/loop_carried_command_injection|js-command-injection|js:app.handleRun|js:child_process.exec|src=test/js/loop_carried_command_injection/app.js:21:11|sink=test/js/loop_carried_command_injection/app.js:20:5 +js/loop_header_callback|js-command-injection|js:app.handleBatch|js:child_process.exec|src=test/js/loop_header_callback/app.js:6:21|sink=test/js/loop_header_callback/app.js:7:5 +js/open_redirect_guard_bypass|js-open-redirect|js:app.$anon0|js:res.redirect|src=test/js/open_redirect_guard_bypass/app.js:14:14|sink=test/js/open_redirect_guard_bypass/app.js:18:3 +js/open_redirect|js-open-redirect|js:app.$anon0|js:res.redirect|src=test/js/open_redirect/app.js:5:16|sink=test/js/open_redirect/app.js:6:3 +js/param_object_field|js-xss|js:app.$anon0|js:res.send|src=test/js/param_object_field/app.js:10:18|sink=test/js/param_object_field/app.js:15:3 +js/path_traversal_join|js-path-traversal|js:app.handleDownload|js:fs.readFile|src=test/js/path_traversal_join/app.js:13:18|sink=test/js/path_traversal_join/app.js:15:3 +js/path_traversal_requrl_memfs|js-path-traversal|js:app.serve|js:context.outputFileSystem.createReadStream|src=test/js/path_traversal_requrl_memfs/app.js:14:18|sink=test/js/path_traversal_requrl_memfs/app.js:20:5 +js/path_traversal_requrl_memfs|js-path-traversal|js:app.serve|js:context.outputFileSystem.statSync|src=test/js/path_traversal_requrl_memfs/app.js:14:18|sink=test/js/path_traversal_requrl_memfs/app.js:18:15 +js/path_traversal|js-path-traversal|js:app.handleDownload|js:fs.readFile|src=test/js/path_traversal/app.js:15:18|sink=test/js/path_traversal/app.js:16:3 +js/path_traversal|js-path-traversal|js:app.handleServe|js:res.sendFile|src=test/js/path_traversal/app.js:28:18|sink=test/js/path_traversal/app.js:29:3 +js/path_traversal|js-path-traversal|js:app.handleStream|js:fs.createReadStream|src=test/js/path_traversal/app.js:22:18|sink=test/js/path_traversal/app.js:23:16 +js/promise_then|js-xss|js:app.$anon0$anon0|js:res.send|src=test/js/promise_then/app.js:9:10|sink=test/js/promise_then/app.js:14:5 +js/propagator_default|js-command-injection|js:app.$anon0|js:child_process.execSync|src=test/js/propagator_default/app.js:7:17|sink=test/js/propagator_default/app.js:8:5 +js/react_props_xss|react-xss-props|js:app.Bio.render|js:__godzilla_react_html|src=test/js/react_props_xss/app.jsx:18:51|sink=test/js/react_props_xss/app.jsx:18:51 +js/react_props_xss|react-xss-props|js:app.Card|js:__godzilla_react_html|src=test/js/react_props_xss/app.jsx:31:49|sink=test/js/react_props_xss/app.jsx:31:49 +js/react_props_xss|react-xss-props|js:app.Note|js:__godzilla_react_html|src=test/js/react_props_xss/app.jsx:25:22|sink=test/js/react_props_xss/app.jsx:26:49 +js/react_props_xss|react-xss-props|js:app.SignupPage.render|js:__godzilla_react_html|src=test/js/react_props_xss/app.jsx:10:42|sink=test/js/react_props_xss/app.jsx:11:51 +js/react_xss|react-xss|js:app.Bio|js:__godzilla_react_html|src=test/js/react_xss/app.jsx:7:15|sink=test/js/react_xss/app.jsx:8:65 +js/react_xss|react-xss|js:app.IndirectBio|js:__godzilla_react_html|src=test/js/react_xss/app.jsx:13:25|sink=test/js/react_xss/app.jsx:13:25 +js/resilience|js-xss|js:app.handleGreeting|js:res.send|src=test/js/resilience/app.js:10:18|sink=test/js/resilience/app.js:11:3 +js/secrets|secret-private-key|js:app.||src=test/js/secrets/app.js:4:1|sink=test/js/secrets/app.js:4:1 +js/sql_injection|js-sqli|js:app.handleUser|js:db.query|src=test/js/sql_injection/app.js:13:12|sink=test/js/sql_injection/app.js:15:3 +js/sqli_array_destructure|js-sqli|js:app.$anon0|js:some-db.query|src=test/js/sqli_array_destructure/app.js:9:14|sink=test/js/sqli_array_destructure/app.js:10:12 +js/sqli_class_method|js-sqli|js:app.UserController.runQuery|js:some-db.query|src=test/js/sqli_class_method/app.js:13:14|sink=test/js/sqli_class_method/app.js:9:12 +js/sqli_destructure|js-sqli|js:app.$anon0|js:some-db.query|src=test/js/sqli_destructure/app.js:9:16|sink=test/js/sqli_destructure/app.js:10:12 +js/sqli_knex_raw|js-sqli|js:app.handleUser|js:knex.raw|src=test/js/sqli_knex_raw/app.js:13:12|sink=test/js/sqli_knex_raw/app.js:14:3 +js/sqli_optional_chain|js-sqli|js:app.$anon0|js:some-db.query|src=test/js/sqli_optional_chain/app.js:9:14|sink=test/js/sqli_optional_chain/app.js:10:12 +js/sqli_prisma_raw|js-sqli|js:app.handleUser|js:prisma.$queryRawUnsafe|src=test/js/sqli_prisma_raw/app.js:13:14|sink=test/js/sqli_prisma_raw/app.js:15:3 +js/sqli_template_literal|js-sqli|js:app.$anon0|js:some-db.query|src=test/js/sqli_template_literal/app.js:9:13|sink=test/js/sqli_template_literal/app.js:11:12 +js/ssrf_new_url|js-ssrf|js:app.directURL|js:fetch|src=test/js/ssrf_new_url/app.js:17:41|sink=test/js/ssrf_new_url/app.js:17:16 +js/ssrf_new_url|js-ssrf|js:app.fixedBase|js:fetch|src=test/js/ssrf_new_url/app.js:36:45|sink=test/js/ssrf_new_url/app.js:36:16 +js/ssrf_new_url|js-ssrf|js:app.forwardAction|js:fetch|src=test/js/ssrf_new_url/app.js:11:32|sink=test/js/ssrf_new_url/app.js:13:16 +js/ssrf_new_url|js-ssrf|js:app.taintedBase|js:fetch|src=test/js/ssrf_new_url/app.js:32:41|sink=test/js/ssrf_new_url/app.js:32:16 +js/ssrf|js-ssrf|js:app.handleProxyAxios|js:axios.get|src=test/js/ssrf/app.js:22:16|sink=test/js/ssrf/app.js:23:3 +js/ssrf|js-ssrf|js:app.handleProxyFetch|js:fetch|src=test/js/ssrf/app.js:29:16|sink=test/js/ssrf/app.js:30:3 +js/ssrf|js-ssrf|js:app.handleProxyHttp|js:http.get|src=test/js/ssrf/app.js:15:16|sink=test/js/ssrf/app.js:16:3 +js/svelte_xss|svelte-xss|js:App.|js:__godzilla_svelte_html|src=test/js/svelte_xss/App.svelte:5:13|sink=test/js/svelte_xss/App.svelte:10:8 +js/try_catch_command_injection|js-command-injection|js:app.handleLookup|js:child_process.exec|src=test/js/try_catch_command_injection/app.js:16:12|sink=test/js/try_catch_command_injection/app.js:19:5 +js/typescript|js-command-injection|js:app.$anon0|js:child_process.execSync|src=test/js/typescript/app.ts:11:25|sink=test/js/typescript/app.ts:12:5 +js/vue_xss|vue-xss|js:App.|js:__godzilla_vue_vhtml|src=test/js/vue_xss/App.vue:6:15|sink=test/js/vue_xss/App.vue:13:5 +js/wdm_getfilename|js-path-traversal|js:getFilenameFromUrl.getFilenameFromUrl|js:outputFileSystem.statSync|src=test/js/wdm_getfilename/middleware.js:14:40|sink=test/js/wdm_getfilename/getFilenameFromUrl.js:21:10 +js/xss|js-xss|js:app.handleName|js:res.send|src=test/js/xss/app.js:11:14|sink=test/js/xss/app.js:12:3 diff --git a/test/corpus/testdata/positions/python.golden b/test/corpus/testdata/positions/python.golden new file mode 100644 index 0000000..b8c6d84 --- /dev/null +++ b/test/corpus/testdata/positions/python.golden @@ -0,0 +1,98 @@ +python/branch_merge_default|py-command-injection|py:app.ping|py:os.system|src=test/python/branch_merge_default/app.py:13:12|sink=test/python/branch_merge_default/app.py:16:5 +python/code_injection|py-code-injection|py:app.calc|py:eval|src=test/python/code_injection/app.py:13:12|sink=test/python/code_injection/app.py:14:14 +python/command_injection_aliased|py-command-injection|py:app.a|py:subprocess.call|src=test/python/command_injection_aliased/app.py:13:11|sink=test/python/command_injection_aliased/app.py:14:5 +python/command_injection_aliased|py-command-injection|py:app.b|py:os.system|src=test/python/command_injection_aliased/app.py:20:12|sink=test/python/command_injection_aliased/app.py:21:5 +python/command_injection_subprocess|py-command-injection|py:app.run|py:subprocess.check_output|src=test/python/command_injection_subprocess/app.py:14:11|sink=test/python/command_injection_subprocess/app.py:15:5 +python/command_injection_subscript|py-command-injection|py:app.ping_via_subscript|py:os.system|src=test/python/command_injection_subscript/app.py:25:11|sink=test/python/command_injection_subscript/app.py:26:5 +python/command_injection|py-command-injection|py:app.ping|py:os.system|src=test/python/command_injection/app.py:15:11|sink=test/python/command_injection/app.py:16:5 +python/container_store_roundtrip|py-command-injection|py:app.index|py:os.system|src=test/python/container_store_roundtrip/app.py:20:12|sink=test/python/container_store_roundtrip/app.py:22:5 +python/container_store_roundtrip|py-command-injection|py:app.store|py:os.system|src=test/python/container_store_roundtrip/app.py:11:12|sink=test/python/container_store_roundtrip/app.py:14:5 +python/crossfile_interproc|py-sql-injection|py:db.run|py:_cursor.execute|src=test/python/crossfile_interproc/app.py:17:11|sink=test/python/crossfile_interproc/db.py:6:5 +python/crossmodule_nested|py-ssrf|py:src/pkg/net/client.fetch|py:requests.get|src=test/python/crossmodule_nested/src/pkg/api.py:13:11|sink=test/python/crossmodule_nested/src/pkg/net/client.py:6:12 +python/deserialize_cloudpickle|py-insecure-deserialization|py:app.run_task|py:cloudpickle.loads|src=test/python/deserialize_cloudpickle/app.py:16:12|sink=test/python/deserialize_cloudpickle/app.py:17:10 +python/deserialize_upload|py-insecure-deserialization|py:app.import_model|py:pickle.loads|src=test/python/deserialize_upload/app.py:17:15|sink=test/python/deserialize_upload/app.py:18:11 +python/dict_literal_sink|py-command-injection|py:app.x|py:os.system|src=test/python/dict_literal_sink/app.py:11:43|sink=test/python/dict_literal_sink/app.py:11:23 +python/django_cbv|py-sql-injection|py:app.UserView.get|py:cursor.execute|src=test/python/django_cbv/app.py:12:5|sink=test/python/django_cbv/app.py:14:9 +python/django|py-command-injection|py:app.run_ping|py:os.system|src=test/python/django/app.py:18:12|sink=test/python/django/app.py:19:5 +python/django|py-sql-injection|py:app.get_user|py:cursor.execute|src=test/python/django/app.py:11:10|sink=test/python/django/app.py:13:5 +python/drf_action_route|py-command-injection|py:app.ReportViewSet.run_report|py:os.system|src=test/python/drf_action_route/app.py:13:5|sink=test/python/drf_action_route/app.py:14:9 +python/drf|py-command-injection|py:app.run_task|py:os.system|src=test/python/drf/app.py:19:11|sink=test/python/drf/app.py:20:5 +python/drf|py-sql-injection|py:app.create_user|py:cursor.execute|src=test/python/drf/app.py:12:12|sink=test/python/drf/app.py:14:5 +python/dynamic_code_exec|py-dynamic-code-exec|py:app.load_limits|py:eval|src=-|sink=test/python/dynamic_code_exec/app.py:19:12 +python/exec_family|py-command-injection|py:app.run_chosen|py:os.execv|src=test/python/exec_family/app.py:7:12|sink=test/python/exec_family/app.py:8:5 +python/exec_family|py-command-injection|py:app.spawn_chosen|py:os.spawnl|src=test/python/exec_family/app.py:12:12|sink=test/python/exec_family/app.py:13:5 +python/fab_expose_route|py-ssrf|py:app.ProxyApi.fetch|py:requests.get|src=test/python/fab_expose_route/app.py:12:5|sink=test/python/fab_expose_route/app.py:13:16 +python/fastapi_code_injection|py-code-injection|py:utils/validate.validate_code|py:compile|src=test/python/fastapi_code_injection/api/validate.py:15:1|sink=test/python/fastapi_code_injection/utils/validate.py:9:24 +python/fastapi_code_injection|py-dynamic-code-exec|py:utils/validate.validate_code|py:exec|src=-|sink=test/python/fastapi_code_injection/utils/validate.py:10:13 +python/fastapi_file_response|py-path-traversal|py:app.serve|py:fastapi.responses.FileResponse|src=test/python/fastapi_file_response/app.py:12:1|sink=test/python/fastapi_file_response/app.py:14:12 +python/fastapi_path_traversal|py-path-traversal|py:app.read_file|py:open|src=test/python/fastapi_path_traversal/app.py:20:1|sink=test/python/fastapi_path_traversal/app.py:22:9 +python/fastapi|py-command-injection|py:app.run_cmd|py:os.system|src=test/python/fastapi/app.py:18:18|sink=test/python/fastapi/app.py:19:5 +python/fastapi|py-sql-injection|py:app.get_user|py:cursor.execute|src=test/python/fastapi/app.py:11:10|sink=test/python/fastapi/app.py:13:5 +python/flask_route_path_traversal|py-path-traversal|py:app.read_file|py:open|src=test/python/flask_route_path_traversal/app.py:16:1|sink=test/python/flask_route_path_traversal/app.py:17:9 +python/header_source|py-command-injection|py:app.run|py:os.system|src=test/python/header_source/app.py:10:11|sink=test/python/header_source/app.py:11:5 +python/hf_dynamic_module|py-hf-dynamic-module|py:app.load_optional|py:transformers.dynamic_module_utils.try_get_class_from_dynamic_module|src=-|sink=test/python/hf_dynamic_module/app.py:17:12 +python/hf_dynamic_module|py-hf-dynamic-module|py:app.load_pinned|py:transformers.dynamic_module_utils.get_class_from_dynamic_module|src=-|sink=test/python/hf_dynamic_module/app.py:22:12 +python/higher_order_callback|py-command-injection|py:app.run_cmd|py:os.system|src=test/python/higher_order_callback/app.py:22:9|sink=test/python/higher_order_callback/app.py:13:5 +python/insecure_config|py-flask-debug-enabled|py:app.serve|py:app.run|src=-|sink=test/python/insecure_config/app.py:32:5 +python/insecure_config|py-insecure-tls-verify|py:app.fetch|py:requests.get|src=-|sink=test/python/insecure_config/app.py:17:12 +python/insecure_config|py-jinja-autoescape-off|py:app.render|py:jinja2.Environment|src=-|sink=test/python/insecure_config/app.py:22:12 +python/insecure_config|py-langchain-dangerous-code|py:app.agent|py:langchain_experimental.agents.create_csv_agent|src=-|sink=test/python/insecure_config/app.py:27:12 +python/insecure_deserialization|py-insecure-deserialization|py:app.load|py:pickle.loads|src=test/python/insecure_deserialization/app.py:10:12|sink=test/python/insecure_deserialization/app.py:11:11 +python/instance_field|py-command-injection|py:app.Job.execute|py:subprocess.run|src=test/python/instance_field/app.py:13:20|sink=test/python/instance_field/app.py:16:9 +python/interproc_local_helper|py-sql-injection|py:app.u|py:_cursor.execute|src=test/python/interproc_local_helper/app.py:21:11|sink=test/python/interproc_local_helper/app.py:23:5 +python/kwarg_splat_flag|py-langchain-dangerous-code|py:app.dangerous|py:langchain_experimental.agents.agent_toolkits.csv.base.create_csv_agent|src=-|sink=test/python/kwarg_splat_flag/app.py:7:12 +python/kwarg_taint|py-command-injection|py:app.keyword_only|py:subprocess.run|src=test/python/kwarg_taint/app.py:8:25|sink=test/python/kwarg_taint/app.py:8:5 +python/kwarg_taint|py-command-injection|py:app.splat|py:subprocess.run|src=test/python/kwarg_taint/app.py:13:21|sink=test/python/kwarg_taint/app.py:14:5 +python/ldap_injection|py-ldap-injection|py:app.login|py:conn.search_s|src=test/python/ldap_injection/app.py:12:12|sink=test/python/ldap_injection/app.py:14:12 +python/loop_carried_command_injection|py-command-injection|py:app.run|py:os.system|src=test/python/loop_carried_command_injection/app.py:22:15|sink=test/python/loop_carried_command_injection/app.py:21:9 +python/method_chain|py-command-injection|py:app.run|py:subprocess.run|src=test/python/method_chain/app.py:13:1|sink=test/python/method_chain/app.py:15:5 +python/method_dispatch|py-command-injection|py:app.Runner.go|py:subprocess.run|src=test/python/method_dispatch/app.py:21:11|sink=test/python/method_dispatch/app.py:13:9 +python/open_redirect|py-open-redirect|py:app.open_redirect|py:flask.redirect|src=test/python/open_redirect/app.py:8:14|sink=test/python/open_redirect/app.py:9:12 +python/path_traversal_join|py-path-traversal|py:app.read_file|py:open|src=test/python/path_traversal_join/app.py:20:16|sink=test/python/path_traversal_join/app.py:22:9 +python/path_traversal|py-path-traversal|py:app.download|py:flask.send_file|src=test/python/path_traversal/app.py:30:16|sink=test/python/path_traversal/app.py:31:12 +python/path_traversal|py-path-traversal|py:app.read_file|py:open|src=test/python/path_traversal/app.py:21:16|sink=test/python/path_traversal/app.py:22:9 +python/redirect_guard_bypass|py-open-redirect|py:app.go|py:flask.redirect|src=test/python/redirect_guard_bypass/app.py:16:11|sink=test/python/redirect_guard_bypass/app.py:19:12 +python/resilience|py-command-injection|py:app.resilience_ping|py:os.system|src=test/python/resilience/app.py:15:11|sink=test/python/resilience/app.py:16:5 +python/route_bare_decorator|py-ssrf|py:app.Api.parse_urls|py:requests.get|src=test/python/route_bare_decorator/app.py:30:5|sink=test/python/route_bare_decorator/app.py:31:16 +python/route_bare_decorator|py-ssrf|py:app.Api.preview|py:requests.get|src=test/python/route_bare_decorator/app.py:37:5|sink=test/python/route_bare_decorator/app.py:38:16 +python/route_bare_decorator|py-ssrf|py:app.UploadApi.upload|py:requests.get|src=test/python/route_bare_decorator/app.py:46:5|sink=test/python/route_bare_decorator/app.py:47:16 +python/secrets|secret-aws-access-key|py:app.connect||src=test/python/secrets/app.py:12:5|sink=test/python/secrets/app.py:12:5 +python/secrets|secret-aws-access-key|||src=test/python/secrets/app.py:8:21|sink=test/python/secrets/app.py:8:21 +python/sql_injection|py-sql-injection|py:app.get_user|py:cursor.execute|src=test/python/sql_injection/app.py:15:10|sink=test/python/sql_injection/app.py:17:5 +python/sqli_await|py-sql-injection|py:app.u|py:_cursor.execute|src=test/python/sqli_await/app.py:12:12|sink=test/python/sqli_await/app.py:18:5 +python/sqli_class_method|py-sql-injection|py:app.UserService.query|py:_cursor.execute|src=test/python/sqli_class_method/app.py:15:15|sink=test/python/sqli_class_method/app.py:12:9 +python/sqli_comprehension|py-sql-injection|py:app.u|py:_cursor.execute|src=test/python/sqli_comprehension/app.py:14:11|sink=test/python/sqli_comprehension/app.py:16:6 +python/sqli_for_iter|py-sql-injection|py:app.u|py:_cursor.execute|src=test/python/sqli_for_iter/app.py:14:11|sink=test/python/sqli_for_iter/app.py:16:9 +python/sqli_fstring|py-sql-injection|py:app.u|py:_cursor.execute|src=test/python/sqli_fstring/app.py:13:11|sink=test/python/sqli_fstring/app.py:14:5 +python/sqli_json_body|py-sql-injection|py:app.u|py:_cursor.execute|src=test/python/sqli_json_body/app.py:13:12|sink=test/python/sqli_json_body/app.py:15:5 +python/sqli_or_default|py-sql-injection|py:app.u|py:_cursor.execute|src=test/python/sqli_or_default/app.py:13:11|sink=test/python/sqli_or_default/app.py:14:5 +python/sqli_orm_wrapper|py-sql-injection|py:app.get_user|py:session.execute|src=test/python/sqli_orm_wrapper/app.py:18:12|sink=test/python/sqli_orm_wrapper/app.py:19:5 +python/sqli_ternary|py-sql-injection|py:app.u|py:_cursor.execute|src=test/python/sqli_ternary/app.py:12:11|sink=test/python/sqli_ternary/app.py:14:5 +python/sqli_tuple_unpack|py-sql-injection|py:app.u|py:_cursor.execute|src=test/python/sqli_tuple_unpack/app.py:13:12|sink=test/python/sqli_tuple_unpack/app.py:14:5 +python/sqli_walrus|py-sql-injection|py:app.u|py:_cursor.execute|src=test/python/sqli_walrus/app.py:13:15|sink=test/python/sqli_walrus/app.py:14:9 +python/ssrf_chained|py-ssrf|py:app.fetch|py:requests.get|src=test/python/ssrf_chained/app.py:14:11|sink=test/python/ssrf_chained/app.py:15:16 +python/ssrf_httpx_stream|py-ssrf|py:app.download|py:client.stream|src=test/python/ssrf_httpx_stream/app.py:18:11|sink=test/python/ssrf_httpx_stream/app.py:19:10 +python/ssrf_pycurl|py-ssrf|py:app.fetch|py:c.setopt|src=test/python/ssrf_pycurl/app.py:9:11|sink=test/python/ssrf_pycurl/app.py:11:5 +python/ssrf_request_alias|py-ssrf|py:app.gateway_proxy|py:requests.request|src=test/python/ssrf_request_alias/app.py:21:20|sink=test/python/ssrf_request_alias/app.py:23:12 +python/ssrf_requests_request|py-ssrf|py:app.proxy|py:requests.request|src=test/python/ssrf_requests_request/app.py:19:20|sink=test/python/ssrf_requests_request/app.py:21:12 +python/ssrf|py-ssrf|py:app.fetch|py:requests.get|src=test/python/ssrf/app.py:16:11|sink=test/python/ssrf/app.py:17:12 +python/ssti|py-ssti|py:app.greet|py:flask.render_template_string|src=test/python/ssti/app.py:12:12|sink=test/python/ssti/app.py:13:12 +python/ssti|py-xss|py:app.greet|py:flask.render_template_string|src=test/python/ssti/app.py:12:12|sink=test/python/ssti/app.py:13:12 +python/subprocess_argv_shell_program|py-command-injection|py:app.run_abs|py:subprocess.run|src=test/python/subprocess_argv_shell_program/app.py:22:11|sink=test/python/subprocess_argv_shell_program/app.py:23:5 +python/subprocess_argv_shell_program|py-command-injection|py:app.run|py:subprocess.run|src=test/python/subprocess_argv_shell_program/app.py:15:11|sink=test/python/subprocess_argv_shell_program/app.py:16:5 +python/subprocess_argv_shell|py-command-injection|py:app.ls|py:subprocess.run|src=test/python/subprocess_argv_shell/app.py:14:12|sink=test/python/subprocess_argv_shell/app.py:15:5 +python/thread_dispatch_selfmethod|py-command-injection|py:app.Worker.run|py:os.system|src=test/python/thread_dispatch_selfmethod/app.py:30:21|sink=test/python/thread_dispatch_selfmethod/app.py:19:9 +python/thread_dispatch|py-sql-injection|py:app.run_proc|py:.execute|src=test/python/thread_dispatch/app.py:34:13|sink=test/python/thread_dispatch/app.py:22:5 +python/thread_dispatch|py-sql-injection|py:app.run_thread|py:.execute|src=test/python/thread_dispatch/app.py:27:13|sink=test/python/thread_dispatch/app.py:18:5 +python/torch_unsafe_load|py-torch-unsafe-load|py:app.decode_shard|py:torch.load|src=-|sink=test/python/torch_unsafe_load/app.py:18:24 +python/tornado_command_injection|py-command-injection|py:controls/v4l2ctl.list_resolutions|py:subprocess.run|src=test/python/tornado_command_injection/handlers/camera.py:14:37|sink=test/python/tornado_command_injection/controls/v4l2ctl.py:7:12 +python/tornado_path_traversal|py-path-traversal|py:app.FileHandler.get|py:open|src=test/python/tornado_path_traversal/app.py:18:5|sink=test/python/tornado_path_traversal/app.py:20:13 +python/tornado_with_open|py-path-traversal|py:app.FileHandler.get|py:open|src=test/python/tornado_with_open/app.py:12:5|sink=test/python/tornado_with_open/app.py:13:14 +python/try_except_command_injection|py-command-injection|py:app.lookup|py:os.system|src=test/python/try_except_command_injection/app.py:16:16|sink=test/python/try_except_command_injection/app.py:19:9 +python/try_return_command_injection|py-command-injection|py:app.run|py:subprocess.run|src=test/python/try_return_command_injection/app.py:18:11|sink=test/python/try_return_command_injection/app.py:22:9 +python/xpath_injection|py-xpath-injection|py:app.find|py:tree.xpath|src=test/python/xpath_injection/app.py:12:12|sink=test/python/xpath_injection/app.py:14:12 +python/xss_json_dict|py-xss|py:app.echo|py:django.http.HttpResponse|src=test/python/xss_json_dict/app.py:11:13|sink=test/python/xss_json_dict/app.py:13:12 +python/xss_json|py-xss|py:app.echo|py:django.http.HttpResponse|src=test/python/xss_json/app.py:18:13|sink=test/python/xss_json/app.py:19:12 +python/xss|py-ssti|py:app.greet|py:flask.render_template_string|src=test/python/xss/app.py:15:12|sink=test/python/xss/app.py:16:12 +python/xss|py-xss|py:app.greet|py:flask.render_template_string|src=test/python/xss/app.py:15:12|sink=test/python/xss/app.py:16:12 +python/zip_slip|py-zip-slip|py:app.extract|py:open|src=test/python/zip_slip/app.py:10:17|sink=test/python/zip_slip/app.py:12:14 diff --git a/test/corpus/testdata/positions/ruby.golden b/test/corpus/testdata/positions/ruby.golden new file mode 100644 index 0000000..4a07d8b --- /dev/null +++ b/test/corpus/testdata/positions/ruby.golden @@ -0,0 +1,38 @@ +ruby/backtick_injection|ruby-command-injection|ruby:app.|ruby:%x|src=test/ruby/backtick_injection/app.rb:4:10|sink=test/ruby/backtick_injection/app.rb:5:4 +ruby/cell_option_source|ruby-xss-cell-option|ruby:app/cells/report/report_cell.ReportCell.unsafe_title|ruby:raw|src=test/ruby/cell_option_source/app/cells/report/report_cell.rb:5:9|sink=test/ruby/cell_option_source/app/cells/report/report_cell.rb:5:5 +ruby/cell_option_source|ruby-xss-cell-option|ruby:app/cells/report/report_cell.ReportCell.unsafe_translated|ruby:raw|src=test/ruby/cell_option_source/app/cells/report/report_cell.rb:23:36|sink=test/ruby/cell_option_source/app/cells/report/report_cell.rb:23:5 +ruby/cell_template_linkage|ruby-xss-cell-option|ruby:app/cells/report/show.|ruby:raw|src=test/ruby/cell_template_linkage/app/cells/report_cell.rb:11:7|sink=test/ruby/cell_template_linkage/app/cells/report/show.erb:2:4 +ruby/cell_template_linkage|ruby-xss-cell-option|ruby:app/cells/report/show.|ruby:raw|src=test/ruby/cell_template_linkage/app/cells/report_cell.rb:6:5|sink=test/ruby/cell_template_linkage/app/cells/report/show.erb:1:5 +ruby/cells_xss|ruby-xss|ruby:app/cells/report/show.|ruby:raw|src=test/ruby/cells_xss/app/cells/report/show.erb:2:11|sink=test/ruby/cells_xss/app/cells/report/show.erb:2:7 +ruby/code_injection|ruby-code-injection|ruby:app.AdminController.dispatch|ruby:send|src=test/ruby/code_injection/app.rb:10:18|sink=test/ruby/code_injection/app.rb:10:5 +ruby/code_injection|ruby-code-injection|ruby:app.AdminController.render_template|ruby:ERB.new|src=test/ruby/code_injection/app.rb:16:13|sink=test/ruby/code_injection/app.rb:16:5 +ruby/code_injection|ruby-code-injection|ruby:app.AdminController.run|ruby:eval|src=test/ruby/code_injection/app.rb:4:10|sink=test/ruby/code_injection/app.rb:4:5 +ruby/command_injection|ruby-command-injection|ruby:app.handle|ruby:system|src=test/ruby/command_injection/app.rb:4:10|sink=test/ruby/command_injection/app.rb:5:3 +ruby/comparison_result_safe|ruby-command-injection|ruby:app.run|ruby:system|src=test/ruby/comparison_result_safe/app.rb:17:20|sink=test/ruby/comparison_result_safe/app.rb:17:3 +ruby/control_flow_if|ruby-command-injection|ruby:app.handle|ruby:system|src=test/ruby/control_flow_if/app.rb:7:12|sink=test/ruby/control_flow_if/app.rb:8:5 +ruby/control_flow_while|ruby-command-injection|ruby:app.handle|ruby:system|src=test/ruby/control_flow_while/app.rb:7:27|sink=test/ruby/control_flow_while/app.rb:7:5 +ruby/deserialization|ruby-insecure-deserialization|ruby:app.ImportsController.marshal_blob|ruby:Marshal.load|src=test/ruby/deserialization/app.rb:5:18|sink=test/ruby/deserialization/app.rb:5:5 +ruby/deserialization|ruby-insecure-deserialization|ruby:app.ImportsController.unsafe_yaml|ruby:YAML.load|src=test/ruby/deserialization/app.rb:9:15|sink=test/ruby/deserialization/app.rb:9:5 +ruby/erb_template_xss|ruby-xss|ruby:layout.html.|ruby:raw|src=test/ruby/erb_template_xss/layout.html.erb:6:40|sink=test/ruby/erb_template_xss/layout.html.erb:6:35 +ruby/erb_template_xss|ruby-xss|ruby:layout.html.|ruby:raw|src=test/ruby/erb_template_xss/layout.html.erb:7:8|sink=test/ruby/erb_template_xss/layout.html.erb:7:3 +ruby/erb_template_xss|ruby-xss|ruby:show.html.|ruby:raw|src=test/ruby/erb_template_xss/show.html.erb:4:11|sink=test/ruby/erb_template_xss/show.html.erb:4:6 +ruby/erb_template_xss|ruby-xss|ruby:show.html.|ruby:raw|src=test/ruby/erb_template_xss/show.html.erb:5:14|sink=test/ruby/erb_template_xss/show.html.erb:5:10 +ruby/fileutils_path_traversal|ruby-path-traversal|ruby:app.BackupController.list|ruby:Dir.glob|src=test/ruby/fileutils_path_traversal/app.rb:12:15|sink=test/ruby/fileutils_path_traversal/app.rb:13:5 +ruby/fileutils_path_traversal|ruby-path-traversal|ruby:app.BackupController.restore|ruby:FileUtils.cp|src=test/ruby/fileutils_path_traversal/app.rb:7:11|sink=test/ruby/fileutils_path_traversal/app.rb:8:5 +ruby/format_string_ssrf|ruby-ssrf|ruby:app.fetch|ruby:Net::HTTP.get|src=test/ruby/format_string_ssrf/app.rb:12:52|sink=test/ruby/format_string_ssrf/app.rb:13:3 +ruby/interproc_helper|ruby-path-traversal|ruby:app.FilesController.download|ruby:File.read|src=test/ruby/interproc_helper/app.rb:21:5|sink=test/ruby/interproc_helper/app.rb:17:5 +ruby/interproc_helper|ruby-path-traversal|ruby:app.FilesController.read_file|ruby:File.read|src=test/ruby/interproc_helper/app.rb:7:12|sink=test/ruby/interproc_helper/app.rb:12:5 +ruby/interproc_ivar|ruby-path-traversal|ruby:app.UploadController.commit|ruby:File.open|src=test/ruby/interproc_ivar/app.rb:6:13|sink=test/ruby/interproc_ivar/app.rb:10:5 +ruby/kwarg_taint|ruby-xss|ruby:app.ReportsController.unsafe_html|ruby:render|src=test/ruby/kwarg_taint/app.rb:9:18|sink=test/ruby/kwarg_taint/app.rb:9:5 +ruby/loop_carried_command_injection|ruby-command-injection|ruby:app.handle|ruby:system|src=test/ruby/loop_carried_command_injection/app.rb:15:11|sink=test/ruby/loop_carried_command_injection/app.rb:14:5 +ruby/nested_if_in_loop|ruby-command-injection|ruby:app.handle|ruby:system|src=test/ruby/nested_if_in_loop/app.rb:11:14|sink=test/ruby/nested_if_in_loop/app.rb:12:7 +ruby/open_redirect|ruby-open-redirect|ruby:app.go|ruby:redirect_to|src=test/ruby/open_redirect/app.rb:4:12|sink=test/ruby/open_redirect/app.rb:5:3 +ruby/path_traversal_guard_bypass|ruby-path-traversal|ruby:app.show|ruby:File.read|src=test/ruby/path_traversal_guard_bypass/app.rb:7:10|sink=test/ruby/path_traversal_guard_bypass/app.rb:9:3 +ruby/path_traversal|ruby-path-traversal|ruby:app.download|ruby:File.read|src=test/ruby/path_traversal/app.rb:5:10|sink=test/ruby/path_traversal/app.rb:6:3 +ruby/rails_query_sqli|ruby-sql-injection|ruby:app.ReportsController.interpolated|ruby:User.where|src=test/ruby/rails_query_sqli/app.rb:5:27|sink=test/ruby/rails_query_sqli/app.rb:5:5 +ruby/rails_query_sqli|ruby-sql-injection|ruby:app.ReportsController.sortable|ruby:User.order|src=test/ruby/rails_query_sqli/app.rb:10:16|sink=test/ruby/rails_query_sqli/app.rb:10:5 +ruby/sinatra_command_injection|ruby-command-injection|ruby:app.|ruby:system|src=test/ruby/sinatra_command_injection/app.rb:7:12|sink=test/ruby/sinatra_command_injection/app.rb:8:3 +ruby/sql_injection_arel|ruby-sql-injection|ruby:Order.by_role|ruby:Arel.sql|src=test/ruby/sql_injection_arel/controller.rb:8:58|sink=test/ruby/sql_injection_arel/model.rb:5:18 +ruby/sql_injection|ruby-sql-injection|ruby:app.show|ruby:Base.execute|src=test/ruby/sql_injection/app.rb:4:8|sink=test/ruby/sql_injection/app.rb:5:3 +ruby/ssrf|ruby-ssrf|ruby:app.fetch|ruby:Net::HTTP.get|src=test/ruby/ssrf/app.rb:4:9|sink=test/ruby/ssrf/app.rb:5:3 +ruby/xss|ruby-xss|ruby:app.render_comment|ruby:raw|src=test/ruby/xss/app.rb:6:13|sink=test/ruby/xss/app.rb:7:3 diff --git a/test/corpus/testdata/positions/rust.golden b/test/corpus/testdata/positions/rust.golden new file mode 100644 index 0000000..54e492e --- /dev/null +++ b/test/corpus/testdata/positions/rust.golden @@ -0,0 +1,15 @@ +rust/axum_rawquery|rust-command-injection|rust:handle|rust:Command::arg|src=test/rust/axum_rawquery/main.rs:16:15|sink=test/rust/axum_rawquery/main.rs:18:18 +rust/branch_merge_default|rust-command-injection|rust:main|rust:Command::arg|src=test/rust/branch_merge_default/main.rs:10:20|sink=test/rust/branch_merge_default/main.rs:14:5 +rust/command_injection_args|rust-command-injection|rust:handle|rust:Command::arg|src=test/rust/command_injection_args/main.rs:17:15|sink=test/rust/command_injection_args/main.rs:19:5 +rust/command_injection_argv|rust-command-injection|rust:handle|rust:Command::args|src=test/rust/command_injection_argv/main.rs:21:15|sink=test/rust/command_injection_argv/main.rs:22:5 +rust/command_injection_helper|rust-command-injection|rust:run_shell|rust:Command::arg|src=test/rust/command_injection_helper/main.rs:23:22|sink=test/rust/command_injection_helper/main.rs:19:5 +rust/command_injection|rust-command-injection|rust:handle|rust:Command::arg|src=test/rust/command_injection/main.rs:23:16|sink=test/rust/command_injection/main.rs:24:18 +rust/db_rusqlite|rust-sql-injection|rust:handle|rust:Connection::execute|src=-|sink=- +rust/path_traversal_open|rust-path-traversal|rust:handle|rust:File::open|src=test/rust/path_traversal_open/main.rs:19:16|sink=test/rust/path_traversal_open/main.rs:20:17 +rust/path_traversal|rust-path-traversal|rust:handle|rust:std::fs::read_to_string|src=test/rust/path_traversal/main.rs:18:16|sink=test/rust/path_traversal/main.rs:20:20 +rust/sql_injection|rust-sql-injection|rust:handle|rust:Connection::execute|src=test/rust/sql_injection/main.rs:20:14|sink=test/rust/sql_injection/main.rs:22:5 +rust/ssrf_reqwest|rust-ssrf|rust:handle|rust:reqwest::blocking::get|src=-|sink=- +rust/ssrf|rust-ssrf|rust:handle|rust:Client::get|src=test/rust/ssrf/main.rs:16:18|sink=test/rust/ssrf/main.rs:17:5 +rust/web_bin_command_injection|rust-command-injection|rust:main|rust:Command::arg|src=test/rust/web_bin_command_injection/src/main.rs:6:15|sink=test/rust/web_bin_command_injection/src/main.rs:7:13 +rust/web_command_injection|rust-command-injection|rust:handle|rust:Command::arg|src=test/rust/web_command_injection/src/lib.rs:16:16|sink=test/rust/web_command_injection/src/lib.rs:17:5 +rust/web_rouille|rust-command-injection|rust:handle|rust:Command::arg|src=-|sink=- diff --git a/test/go/func_alias_source/expected.yaml b/test/go/func_alias_source/expected.yaml new file mode 100644 index 0000000..c163dd0 --- /dev/null +++ b/test/go/func_alias_source/expected.yaml @@ -0,0 +1,7 @@ +# Expected findings for this sample (see test/README.md). Regenerate with: +# GODZILLA_REGEN=1 go test ./test/corpus/ -run RegenerateManifests +findings: + - rule: go-path-traversal + min: 2 + max: 2 + sink: go:os.Open diff --git a/test/go/func_alias_source/go.mod b/test/go/func_alias_source/go.mod new file mode 100644 index 0000000..01e166b --- /dev/null +++ b/test/go/func_alias_source/go.mod @@ -0,0 +1,3 @@ +module funcaliassource + +go 1.21 diff --git a/test/go/func_alias_source/macaron/macaron.go b/test/go/func_alias_source/macaron/macaron.go new file mode 100644 index 0000000..1450fd7 --- /dev/null +++ b/test/go/func_alias_source/macaron/macaron.go @@ -0,0 +1,7 @@ +package macaron + +import "net/http" + +// Stands in for macaron's route-param accessor: a free function taking the +// request, which grafana and gogs both reach through a re-exporting variable. +func Params(r *http.Request) map[string]string { return map[string]string{} } diff --git a/test/go/func_alias_source/main.go b/test/go/func_alias_source/main.go new file mode 100644 index 0000000..3f09fe2 --- /dev/null +++ b/test/go/func_alias_source/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "net/http" + "os" + "path/filepath" + + "funcaliassource/macaron" + "funcaliassource/web" +) + +// A framework request context: the *http.Request is a field, not a parameter. +type ReqContext struct{ Req *http.Request } + +// Through the alias. Before the alias was resolved this call had NO callee name, +// so it matched no source glob and the whole flow was invisible. +func viaAlias(c *ReqContext) { + f, _ := os.Open(filepath.Join("/plugins", filepath.Clean(web.Params(c.Req)["*"]))) + defer f.Close() +} + +// Direct call to the same function, for contrast. +func direct(c *ReqContext) { + f, _ := os.Open(filepath.Join("/plugins", filepath.Clean(macaron.Params(c.Req)["*"]))) + defer f.Close() +} + +// Reassigned, so it has no single answer and must stay unresolved -- this is the +// control that keeps the resolution sound rather than merely useful. +var Hook = macaron.Params + +func init() { + Hook = func(r *http.Request) map[string]string { return nil } +} + +func viaReassigned(c *ReqContext) { + f, _ := os.Open(filepath.Join("/plugins", filepath.Clean(Hook(c.Req)["*"]))) + defer f.Close() +} + +func main() {} diff --git a/test/go/func_alias_source/web/web.go b/test/go/func_alias_source/web/web.go new file mode 100644 index 0000000..1192b56 --- /dev/null +++ b/test/go/func_alias_source/web/web.go @@ -0,0 +1,7 @@ +package web + +import "funcaliassource/macaron" + +// The shape that hid grafana CVE-2021-43798: re-exporting a function as a +// package-level VARIABLE, which makes every call through it an indirect call. +var Params = macaron.Params diff --git a/test/js/react_props_xss/app.jsx b/test/js/react_props_xss/app.jsx new file mode 100644 index 0000000..bd7b795 --- /dev/null +++ b/test/js/react_props_xss/app.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import DOMPurify from 'dompurify'; + +// ghost CVE-2026-24778: a site setting reaches the component as React CONTEXT +// and is rendered raw. Destructured off a field chain on purpose -- only the +// first hop off `this` becomes a matchable callee, so this pins that the +// remaining field reads still carry the base's taint. +export class SignupPage extends React.Component { + render() { + const { portal_signup_terms_html } = this.context.site; + return
; + } +} + +// A class component's props, read directly at the sink. +export class Bio extends React.Component { + render() { + return
; + } +} + +// The dominant React idiom: props destructured in the signature. The pattern +// binds no identifier of its own, so without the frontend binding each property +// the prop is not a value this rule can see at all. +export function Note({html}) { + return
; +} + +// A named props parameter, the other function-component spelling. +export function Card(props) { + return
; +} + +// Two shapes that must NOT fire: a sanitized prop, and escaped JSX children. +export function SafeNote({html}) { + return ( +
+ {html} +
+
+ ); +} diff --git a/test/js/react_props_xss/expected.yaml b/test/js/react_props_xss/expected.yaml new file mode 100644 index 0000000..2833f93 --- /dev/null +++ b/test/js/react_props_xss/expected.yaml @@ -0,0 +1,11 @@ +# Expected findings for this sample (see test/README.md). +# max: 4 is the assertion: the four component-boundary spellings fire (context +# through a field chain, this.props, a destructured prop, a named props param) +# while the sanitized prop and the escaped JSX children in the same file stay +# silent. severity is low by design (see the rulepack): a prop may or may not be +# request-derived. +findings: + - rule: react-xss-props + min: 4 + max: 4 + sink: "js:__godzilla_react_html" diff --git a/test/js/react_props_xss_safe/app.jsx b/test/js/react_props_xss_safe/app.jsx new file mode 100644 index 0000000..cfbdf32 --- /dev/null +++ b/test/js/react_props_xss_safe/app.jsx @@ -0,0 +1,14 @@ +import React from 'react'; + +// A lowercase-named helper is not a component -- JSX compiles a lowercase tag to +// a host-element string, so this can never be rendered as one and its options bag +// is not props. This is the sample that pins the capital-initial predicate: a +// broader one ("first parameter is a destructured object") passes CI without it. +export function buildMarkup({html}) { + return
; +} + +// A component whose props reach only escaped children. +export function Bio({bio}) { + return {bio}; +} diff --git a/test/js/react_props_xss_safe/expected.yaml b/test/js/react_props_xss_safe/expected.yaml new file mode 100644 index 0000000..ed91d60 --- /dev/null +++ b/test/js/react_props_xss_safe/expected.yaml @@ -0,0 +1,5 @@ +# Expected findings for this sample (see test/README.md). +# The false-positive control for react-xss-props: a lowercase helper's parameter +# is not a props source, and a prop that only reaches escaped JSX children emits +# no sink at all. +findings: [] diff --git a/test/ruby/cell_option_source/app/cells/report/report_cell.rb b/test/ruby/cell_option_source/app/cells/report/report_cell.rb new file mode 100644 index 0000000..fffc203 --- /dev/null +++ b/test/ruby/cell_option_source/app/cells/report/report_cell.rb @@ -0,0 +1,29 @@ +# A cell's options are the arguments its CALLER passed. Decidim CVE-2024-41673 +# is a request value handed in that way and interpolated unescaped. +class ReportCell < Decidim::ViewModel + def unsafe_title + raw(options[:title]) + end + + # Escaping at the point of use is the fix the CVE applied. + def safe_title + raw(html_escape(options[:title])) + end + + # A cell's own model is not sourced here -- only options. + def from_model + raw(model.name) + end + + # The decidim CVE-2024-41673 chain in full: the option is handed on as a + # KEYWORD, forwarded again through a `**` splat, and only then interpolated. + # Both hops erased the value until the frontend carried keyword taint, which is + # why this rule could describe the CVE without detecting it. + def unsafe_translated + raw(i18n("report.title", name: options[:title])) + end + + def i18n(key, **params) + t(key, **params) + end +end diff --git a/test/ruby/cell_option_source/app/helpers/plain_helper.rb b/test/ruby/cell_option_source/app/helpers/plain_helper.rb new file mode 100644 index 0000000..a2f6f6e --- /dev/null +++ b/test/ruby/cell_option_source/app/helpers/plain_helper.rb @@ -0,0 +1,12 @@ +# The scoping control: `options` outside a cells directory is an ordinary method +# name, not a cell argument, and must NOT be seeded. If this fires, the source has +# escaped the one directory where the name has that meaning. +class PlainHelper + def initialize(options) + @options = options + end + + def render_title(options) + raw(options[:title]) + end +end diff --git a/test/ruby/cell_option_source/expected.yaml b/test/ruby/cell_option_source/expected.yaml new file mode 100644 index 0000000..82312e3 --- /dev/null +++ b/test/ruby/cell_option_source/expected.yaml @@ -0,0 +1,14 @@ +# Expected findings for this sample (see test/README.md). +# max: 2 is the assertion: the option reaching `raw` directly, and the same value +# reaching it through a keyword argument and a `**` splat forward -- the decidim +# CVE-2024-41673 shape (keyword taint itself is pinned by test/ruby/kwarg_taint). +# The negatives are what the max protects: +# html_escape neutralizes the value, and `options` in app/helpers is an ordinary +# parameter that must not be seeded -- the source is emitted only under app/cells, +# where the name has exactly one meaning. severity is low by design (see the +# rulepack): a cell argument may or may not be request-derived. +findings: + - rule: ruby-xss-cell-option + min: 2 + max: 2 + sink: "ruby:raw" diff --git a/test/ruby/cell_template_linkage/app/cells/report/show.erb b/test/ruby/cell_template_linkage/app/cells/report/show.erb new file mode 100644 index 0000000..5bab36a --- /dev/null +++ b/test/ruby/cell_template_linkage/app/cells/report/show.erb @@ -0,0 +1,3 @@ +

<%= title %>

+

<%= translated %>

+

<%= escaped %>

diff --git a/test/ruby/cell_template_linkage/app/cells/report_cell.rb b/test/ruby/cell_template_linkage/app/cells/report_cell.rb new file mode 100644 index 0000000..404a820 --- /dev/null +++ b/test/ruby/cell_template_linkage/app/cells/report_cell.rb @@ -0,0 +1,17 @@ +# A cell's class and its template are separate files, so the flow that matters +# crosses a module boundary: the option is read here, the unescaped interpolation +# is in show.erb. Decidim CVE-2024-41673 is that shape. +class ReportCell < Decidim::ViewModel + def title + options[:title] + end + + # The positional translation form carries what it interpolates. + def translated + t(options[:title]) + end + + def escaped + html_escape(options[:title]) + end +end diff --git a/test/ruby/cell_template_linkage/app/views/report/show.html.erb b/test/ruby/cell_template_linkage/app/views/report/show.html.erb new file mode 100644 index 0000000..2f0be49 --- /dev/null +++ b/test/ruby/cell_template_linkage/app/views/report/show.html.erb @@ -0,0 +1,3 @@ + +

<%= title %>

diff --git a/test/ruby/cell_template_linkage/expected.yaml b/test/ruby/cell_template_linkage/expected.yaml new file mode 100644 index 0000000..59dffc6 --- /dev/null +++ b/test/ruby/cell_template_linkage/expected.yaml @@ -0,0 +1,10 @@ +# Expected findings for this sample (see test/README.md). +# max: 2 is the assertion. `title` and `translated` cross from the cell class into +# its template and fire; the other two are the controls. `escaped` is neutralized, +# and the identical `<%= title %>` under app/views must stay inert -- it is an +# ActionView template with no paired cell, so the linkage must not reach it. +findings: + - rule: ruby-xss-cell-option + min: 2 + max: 2 + sink: "ruby:raw" diff --git a/test/ruby/kwarg_taint/app.rb b/test/ruby/kwarg_taint/app.rb new file mode 100644 index 0000000..22f2bd2 --- /dev/null +++ b/test/ruby/kwarg_taint/app.rb @@ -0,0 +1,21 @@ +# A keyword argument's VALUE carries taint (the frontend appends a builtin.kwarg +# marker per pair); the hash itself stays an inert placeholder in the positional +# slot so an #idx-pinned sink keeps seeing what it saw before. +class ReportsController < ApplicationController + # render html: interpolates straight into the response body. This is the + # detection ruby-xss always described and could not make while Ruby erased + # hashes -- the keyword is the ONLY route from source to sink. + def unsafe_html + render html: params[:q] + end + + # Two keyword shapes on the same sink that must NOT fire: locals feed an + # auto-escaping template, and a JSON body is not an HTML one. + def safe_locals + render partial: "row", locals: params[:opts] + end + + def safe_json + render json: params[:q] + end +end diff --git a/test/ruby/kwarg_taint/expected.yaml b/test/ruby/kwarg_taint/expected.yaml new file mode 100644 index 0000000..104a5bf --- /dev/null +++ b/test/ruby/kwarg_taint/expected.yaml @@ -0,0 +1,10 @@ +# Expected findings for this sample (see test/README.md). +# max: 1 is the assertion. `render` is Rails' most keyword-heavy call, so the +# sink's `when:` guard naming html/inline/plain/text/body is what separates the +# one HTML-output keyword from locals: and json: in the same file. Dropping or +# widening that guard fails here rather than in a real application. +findings: + - rule: ruby-xss + min: 1 + max: 1 + sink: "ruby:render" diff --git a/test/ruby/kwarg_taint_safe/app.rb b/test/ruby/kwarg_taint_safe/app.rb new file mode 100644 index 0000000..fc7e6f0 --- /dev/null +++ b/test/ruby/kwarg_taint_safe/app.rb @@ -0,0 +1,32 @@ +require 'httparty' + +# The Rails keyword idioms that made every unpinned sink a false positive once +# keyword values started carrying taint. Each pins one sink's #idx or guard. +class SafeController < ApplicationController + # A flash message is not the redirect destination. + def flash_redirect + redirect_to root_path, notice: params[:msg] + end + + # filename: is a Content-Disposition header value, not a filesystem path. + def download + send_data report_csv, filename: params[:name] + end + + def serve + send_file "/srv/report.csv", filename: params[:name] + end + + # The host is a constant; the tainted value is a query parameter ON it, which + # is the parameterized form. The rule's `not hostFixed()` guard reads a tainted + # keyword as a controllable host, so an unpinned sink fires here. + def api + HTTParty.get("https://api.example.com/v1/items", query: params[:q]) + end + + # The hash form is parameterized by construction -- the case the inert + # positional placeholder exists to protect. + def hash_condition + User.where(name: params[:q]) + end +end diff --git a/test/ruby/kwarg_taint_safe/expected.yaml b/test/ruby/kwarg_taint_safe/expected.yaml new file mode 100644 index 0000000..c0011ca --- /dev/null +++ b/test/ruby/kwarg_taint_safe/expected.yaml @@ -0,0 +1,6 @@ +# Expected findings for this sample (see test/README.md). +# The false-positive control for Ruby keyword taint: five ordinary Rails idioms +# pass a request value as a KEYWORD to a sink whose dangerous argument is +# positional. Each is a different rule (open-redirect, path-traversal x2, ssrf, +# sql-injection), so this fails as soon as any one of their #idx pins is dropped. +findings: [] diff --git a/test/rust/ssrf_safe_format_spec/expected.yaml b/test/rust/ssrf_safe_format_spec/expected.yaml new file mode 100644 index 0000000..ea98b87 --- /dev/null +++ b/test/rust/ssrf_safe_format_spec/expected.yaml @@ -0,0 +1,3 @@ +# No findings: the host is constant. A spec-bearing placeholder must not cost the +# decoder the literal prefix that proves it. +findings: [] diff --git a/test/rust/ssrf_safe_format_spec/main.rs b/test/rust/ssrf_safe_format_spec/main.rs new file mode 100644 index 0000000..17fff6b --- /dev/null +++ b/test/rust/ssrf_safe_format_spec/main.rs @@ -0,0 +1,21 @@ +// Safe SSRF sentinel, guarding the FORMAT-TEMPLATE DECODE rather than the taint +// logic: identical to ssrf_safe_format except the placeholder carries a width +// specifier. rustc encodes a spec-bearing argument with a control byte the MIR +// decoder does not model, and treating that as a failed decode left an empty +// template — which reads as "this format string contains no host" and fired a +// high-confidence CWE-918 on code the plain `{}` form proves safe. Must produce +// ZERO findings. +mod http { + pub struct Request; + impl Request { pub fn query(&self, _n: &str) -> String { String::new() } } +} +mod http_client { + pub struct Client; + impl Client { pub fn get(&self, _url: &str) {} } +} + +pub fn handle(req: &http::Request, client: &http_client::Client) { + let p = req.query("path"); // untrusted, but only reaches the path + let url = format!("https://api.internal.example.com/v1/{:>10}", p); + client.get(&url); // fixed host; taint confined to the path +}