← All writeups
ToolingApr 22, 2026· 6 min read

Introducing Sift: A Cross-Language Taint Query Engine

We are open-sourcing Sift, the query engine behind a lot of our findings. Write one taint rule, run it across C, Go, Python and TypeScript, and get sources-to-sinks paths instead of a wall of lint noise.

NU

@nu11der3f

LoSec Research

Why another static analyzer

Every serious codebase we look at is polyglot. A request enters a TypeScript edge handler, crosses into a Go service, and bottoms out in a C library doing the actual parsing. Traditional analyzers stop at language boundaries, so the bug that spans three of them is exactly the bug nobody's tool can see.

Sift is our answer. It lowers each language into a common dataflow IR, then lets you write a single taint query that runs across all of them. Today we are releasing it under Apache-2.0 at github.com/Losec-io/sift.

The model

Sift is built around three primitives you already know: sources (where untrusted data enters), sinks (where it becomes dangerous), and sanitizers (what makes it safe again). The difference is that all three are expressed in one DSL over the shared IR, so a source in Node and a sink in C participate in the same reachability query.

rule preauth_command_injection {
  source http.request.body
  source http.request.query

  sink   process.exec($cmd)
  sink   libc.system($cmd)

  sanitizer shell.quote($x) -> $x
  sanitizer allowlist.match($x) -> $x

  report when source ~> sink
}

The ~> operator means "taint-reachable, respecting sanitizers." Sift walks the interprocedural graph and only reports a finding when there is an actual path with no sanitizer dominating the sink.

Crossing the boundary

The interesting part is the FFI/RPC bridge. Sift models boundary crossings as summary edges. When a Go function calls into cgo, or a service issues a gRPC call, Sift links the argument taint on one side to the parameter on the other:

bridge cgo {
  from go.call($fn, $args)
  to   c.func($fn).params
  propagate taint on $args
}

bridge grpc {
  from ts.client.$method($msg)
  to   go.handler.$method.request
  propagate taint on $msg
}

With those bridges declared, a single query traces user input from a browser-facing TypeScript route all the way to a system() call in a vendored C dependency — a path no single-language tool would ever connect.

Output that respects your time

Sift does not emit lines; it emits paths. Each finding is the full chain from source to sink, which is what a human actually needs to judge exploitability:

[HIGH] preauth_command_injection
  source  api/routes/upload.ts:41  req.query.name
    ~>    services/media/handler.go:88  ConvertRequest.Name
    ~>    cgo bridge  media_convert()
    sink  vendor/libconv/convert.c:210  system(cmd)
  sanitizers on path: none

Because the path is explicit, triage is a matter of reading four frames, not reverse-engineering why a linter is upset.

Running it

Sift ships a single static binary. Point it at a repo and a ruleset:

sift analyze ./ --rules rules/ --format sarif > findings.sarif

# or gate CI on a specific rule
sift analyze ./ --rules rules/preauth.sift --fail-on HIGH

SARIF output means it drops straight into code scanning dashboards. There is also a --explain mode that prints the IR for a given function so you can debug why a path did or did not connect.

What it is and isn't

Sift is a sound-leaning taint engine: it favors finding real paths over proving their absence. It will miss things behind reflection, dynamic eval, and dispatch it cannot resolve — we would rather under-report than drown you. It is not a replacement for fuzzing or for a human reading the code; it is the thing that tells the human where to look across a boundary they otherwise couldn't follow.

Writing your own rules

The ruleset is data, not code, so you can encode institutional knowledge fast. Found a nasty deserialization gadget in your platform? Turn it into a sink and every service inherits the check:

rule unsafe_deserialization {
  source mq.message.payload
  sink   pickle.loads($x)
  sink   yaml.unsafe_load($x)
  report when source ~> sink
}

Roadmap

  • Rust and Java frontends (in progress).
  • Incremental analysis so PR diffs re-query in seconds.
  • A shared community rule pack — send PRs.

We built Sift because the bugs that matter to us live in the seams between languages, and we were tired of tools that pretend those seams don't exist. Clone it, break it, and open issues. The seams are where we'll all keep finding the good stuff.

#static-analysis#tooling#open-source#taint#dataflow
Share