Inspiration
Static analysis security tools today have two failure modes that cancel each other out:
- Pattern scanners (
grep-style, regex SAST) flag everyeval(, everysubprocess.run(shell=True), everycursor.execute— most of which take literal arguments and are not exploitable. Developers drown. - Heavyweight dataflow engines ignore most of the codebase to keep analysis tractable, miss bugs that cross even one file boundary, or require expensive runtime instrumentation.
What's missing is a tool that knows which dangerous calls are actually reachable from untrusted input, end to end, across files. That's exactly what a code knowledge graph is good at — and Orbit just shipped one.
What it does
Taint-Flow Auditor finds interprocedural reachability paths from untrusted sources (HTTP request handlers, CLI args, env vars) to dangerous sinks (SQL exec, shell, deserialize, filesystem) in Python codebases. For every path it finds, it emits:
- The full call chain (every function on the path, by FQN).
- A severity (high / medium / low) — automatically demoted when the path
crosses a known sanitizer (
shlex.quote,html.escape, parameterised SQL, …). - A one-line remediation suggestion drawn from a YAML catalog.
Output formats: a colourised CLI report, a SARIF 2.1.0 file that plugs into GitLab's SAST report ingest, and an optional threaded discussion on the active merge request.
How we built it
Most of this project is what we didn't have to build, because Orbit gave it to us for free.
orbit index . produces a local DuckDB graph with one Definition row per
function/method/class across 11 languages, plus a Definition CALLS
Definition edge for every interprocedural call — already name-resolved,
already import-tracked, already incremental. That is tens of thousands of
lines of compiler frontend (parsers, name resolution, import graph,
inheritance) off our plate. Writing the equivalent ourselves for a single
language is a multi-month project; for 11 languages it is the work of a
team-year. Orbit collapses it to one command.
What we wrote on top is small — a single Python package, ~700 LoC:
A read-only DuckDB client that pulls three slices: the
Definitionrows we care about (Python today, more languages tomorrow), theCALLSedges between them, and the source on disk for AST inspection.A small AST classifier. Orbit's call edges tell us what is reachable; AST tells us what kind of thing each function is. For each Definition body we ask: does it carry an
@app.routedecorator? Does it readrequest.args? Does it callcursor.executewith a non-literal first argument? Does it callshlex.quote? Three classifications — source / sink / sanitizer — written into a node table.A bounded BFS over Orbit's call graph. Starting from each source node, BFS outward (max depth 8, configurable). When the BFS reaches a sink, emit a finding. Carry a "sanitized" bit forward so paths that traverse a sanitizer demote to low severity. De-duplicate by
(source, sink, sink-pattern, path)content hash.
The demo SQLi path is the punchline: views.search → db.run_search →
db.run_query → cursor.execute. Pattern tools see cursor.execute(sql) and
either flag every call site or none of them; they cannot prove the two-hop
reach back to a Flask route because they have no call graph. Orbit has
the call graph; we just walk it.
The catalog of sources, sinks, and sanitizers lives in three YAML files so users can extend coverage without touching Python.
We also ship:
- A GitLab Duo Agent Platform skill (
skills/taint-flow-auditor/SKILL.md) so Duo will invoke the auditor for security-flavoured questions and/taint-audit. - A GitLab CI job that runs the auditor and uploads SARIF as a SAST report on every push.
- A vulnerable Flask demo app with planted SQLi, command injection, path traversal, a sanitizer negative case, and a CLI decoy — so anyone can reproduce the result in 30 seconds.
Challenges we ran into
- Docs vs. reality on Orbit's schema. The public schema docs describe a
contentcolumn onDefinitionnodes and per-relation edge tables. The real DuckDB schema (v0.78.0) has neither — it stores all edges in a singlegl_edgetable keyed byrelationship_kind, and source content is not on the row. We pivoted on day 1 after runningorbit schemaagainst a real index, dropped a planned AST-based call-edge synthesiser when we discovered Orbit does emit realDefinition CALLS Definitionedges, and re-hydrate source content from disk using line ranges. - Resisting scope creep. It was tempting to add JS/TS, then taint at the argument level, then auto-fix MRs. We deliberately scoped v1 to Python + function-level reachability, documented the limits honestly, and shipped.
- Demo realism. Our demo repo is small enough to land in 3 minutes but
has a deliberate decoy (
cli.dump_table → db.run_query, reaches the sink but is not a request handler so must not be flagged) to show the auditor is doing more than grep.
Accomplishments we're proud of
- The auditor finds a real multi-hop SQL injection in the demo (2 hops
across 2 files:
views.search → db.run_search → db.run_query) without any per-codebase configuration. - The sanitizer-demotion path produces a
LOWfinding rather than no finding — useful information without alert fatigue. - The decoy (
cli.dump_table, which reaches the samecursor.executesink but is not a request handler) is correctly suppressed. - SARIF output is GitLab-SAST-compatible: drop it in a CI job and findings show up in the MR UI.
- Zero exemptions for itself. When pointed at its own repository, the
auditor flags its own
httpx.postcall insidegitlab_post.pyas a MEDIUM finding (env-vars → HTTP call). This is operator-configured CI input, not a vulnerability — but the scanner has no special allow-list for itself. Honest tools are trustworthy tools.
What we learned
- A code knowledge graph removes ~80% of the engineering needed to build cross-file analysers. The remaining 20% is choosing the right predicates.
- Function-level reachability is a useful sweet spot: precise enough to matter, cheap enough to run on every push, honest enough to admit when it's over-approximate.
What's next
- More languages. The Python AST classifier is ~120 lines. JS/TS and Go are next; Orbit already indexes them, so all that's needed is per-language AST predicates.
- Argument-level taint. Track which parameter is tainted across each hop so we can reduce false positives further.
- Flow-aware sanitiser semantics. Today we demote on sanitiser presence; v2 should require the sanitiser to wrap the tainted value.
- Auto-fix MRs. For canonical patterns (string-formatted SQL → parameter
binding;
shell=True→ list args), open a follow-up MR with the patch. - IDE integration. Render the call path as a foldable graph in VS Code's Problems panel.
Built With
- gitlab
Log in or sign up for Devpost to join the conversation.