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.