Companion paperThis is the engineering write-up. The sister essay, “Trust Me” Is Not a Finding: The Taint Microkernel, argues why the deterministic engine should be the kernel and the language model the coprocessor.

The short version

Most interesting vulnerabilities don’t politely stay in one file. Somebody reads an untrusted upload in routes.py, hands it to a helper in services.py, and the helper does something regrettable with torch.load. Each file, read on its own, looks fine. The bug lives in the gap between them, the seam, and most open-source taint tools can’t see seams.

This paper is about teaching one to. We took OpenGrep, which does excellent intra-file taint analysis, and bolted a cross-file pass on top so taint can follow a call from one file into another. The result is Open-Rowan. It’s Python-only, it’s pre-release, and (as you’ll see below) it spent part of its early life confidently reporting nothing at all. We’ll get to that. It’s the best part.

Where things actually stand (June 2026). Open-Rowan is in private beta and isn’t published yet: no PyPI package, no public repo to check this paper against. So claims about Open-Rowan describe its current design and our intent to open-source it. Everything I say about OpenGrep, Semgrep, CodeQL, and Pysa is cited, because those you can check.

1. The gap

Taint analysis answers one question: can attacker-controlled data reach somewhere it shouldn’t? The honest version of that question almost never fits in a single file.

Python: the bug that spans two files
# routes.py
from services import handle_upload

@app.post("/import")
def import_endpoint():
    payload = request.files["model"].read()   # SOURCE
    handle_upload(payload)

# services.py
import torch

def handle_upload(blob):
    return torch.load(io.BytesIO(blob))        # SINK: arbitrary code execution
                                               # if the pickle data is untrusted

Point OpenGrep at services.py and it sees torch.load chewing on a parameter of mysterious origin. Point it at routes.py and it sees a source being handed to some imported function it never opens up. Each half is a shrug. The vulnerability is the handoff, and a one-file-at-a-time analysis is never looking at both ends of it.

This isn’t some exotic corner case. It’s the default shape of basically every app anyone ships: controllers call services, services call repositories, repositories call drivers. Taint rides the call graph, and the call graph is spread across files. If your analysis stops at the file boundary, it stops exactly where the interesting bugs start.

What the ecosystem actually gives you

ToolCross-file taintLicenseNotes
OpenGrepPlanned [4][5]LGPL (open source)Intra-file cross-function taint today via --taint-intrafile [2]
Semgrep CodeYes [1][7]Commercial (free tier available)Cross-file dataflow + taint traces in the paid product
CodeQLYesOpen tooling, proprietary hostingDeclarative QL, database build step. Heavier workflow
PysaYesOpen source (Meta)Python only; wants type annotations + .pysa model files
Open-RowanYes (pre-release)Intended open sourcePython-only; cross-file taint layered on OpenGrep (this paper)

To be clear about what I’m not claiming: it’s not that cross-file taint is some unsolved mystery. Pysa is open source and does it. Semgrep Code and CodeQL do it well and lots of people use them. The narrower, more annoying truth is that if you want cross-file taint in a general-purpose, low-ceremony, fully open-source scanner, something you can just run, the options thin out fast. That’s the gap Open-Rowan aims at, and it’s a gap, not a canyon.

2. Architecture: index, resolve, propagate

Open-Rowan uses OpenGrep as its intra-file taint engine and adds a cross-file pass on top. Three phases.

Phase 1: index

Walk every source file, parse it to an AST, and build two things:

  • An import graph: module → the modules it imports from, with name resolution so that from a.b import c as d correctly maps the local d back to a.b.c. Relative imports and star imports included, because real code is rude.
  • A set of function summaries, one per function, recording its parameters, the calls it makes, and (the part that matters) how taint moves through it (§4).

Only the function names stay resident in memory; the heavier summaries get paged through a disk cache (§5), so indexing a big monorepo doesn’t quietly eat all your RAM.

Phase 2: resolve

Now annotate those summaries with what the earlier passes found. OpenGrep’s intra-file taint tells us which functions hold a source and which hold a sink. The lightweight regex engine seeds a few more sinks. Out the other end comes a call graph whose nodes are tagged as sources, sinks, propagators, or some combination.

One constraint shaped this whole design, and it’s worth saying out loud because it surprised us too. What OpenGrep hands you from --taint-intrafile is the finished source-to-sink flows, not the reusable per-function summaries it built internally to get there. So we can’t just grab OpenGrep’s signatures and stitch them across files; we have to rebuild the call-graph structure ourselves from the AST and lay OpenGrep’s results on top. That “consume the results, re-derive the structure” split isn’t a choice we’d have made for fun. It falls out of the fact that an intra-file engine only publishes its conclusions, not its scaffolding. It’s also, more broadly, why cross-file taint keeps getting re-implemented instead of cheaply composed from existing tools: everyone’s building blocks stay locked inside their own binary.

Phase 3: propagate

Run a fixpoint over the call graph until the taint facts stop changing, and emit a finding whenever a tainted source and a reachable sink end up on a path that crosses a file boundary. This is where the actual engineering happens, so it gets its own section.

3. From naïve fixpoint to SCC convergence

The textbook recipe for propagating taint across a call graph is iterate-to-fixpoint: keep sweeping over every function, push taint from callees up to callers (and sources back down), and stop when a full pass changes nothing.

The textbook recipe also has a textbook problem: circular imports. Any real Python codebase has them. The models ↔ utils mutual import is practically a rite of passage. Cycles make a naive whole-graph sweep churn: it reprocesses the same functions over and over and converges slowly and unpredictably. Our first cut capped the loop at a flat 50 iterations, which is the software equivalent of setting a kitchen timer and hoping. On a cyclic graph you either burn the budget or quit early with taint half-propagated. Neither is a good look for a security tool.

The fix is old compiler theory: break the call graph into strongly connected components with Tarjan’s algorithm, then process the components leaves-first, in reverse topological order.

SCC-converged propagation
import graph ──Tarjan──▶ [SCC₀, SCC₁, …, SCCₙ]   (reverse-topological)

for each SCC in order:
    worklist ← members(SCC)
    bound    ← 3 × |SCC|            # generous convergence budget
    while worklist and iterations < bound:
        m ← worklist.pop()
        if propagate(m) changed something:
            worklist ← worklist ∪ (callers(m) ∩ SCC)   # re-queue within SCC only

Two things make this behave:

  1. Between components, it’s acyclic. Taint flows from one SCC to the next in a single direction, so a component never needs revisiting once it’s done. Process each once and move on.
  2. Inside a component, it’s bounded. The only place churn can happen is inside a cycle, and there the worklist is capped by the cycle’s size, not the whole repo’s. A two-function models ↔ utils knot converges in a few iterations no matter how enormous the surrounding codebase is.

The naïve O(functions × iterations) global loop collapses into Σ O(|SCCᵢ| × bound), and since the overwhelming majority of functions sit in singleton SCCs all by themselves, the common case is close to linear in practice. This one change is what took the engine from “fine on a toy repo” to “converges predictably on a big one full of import cycles.”

4. Precision: parameter-indexed summaries

“This function touches a sink” is too blunt an instrument. Watch:

Python: why “has a sink” isn’t enough
def write_record(safe_key, user_value):
    cache[safe_key] = sanitize(user_value)
    log.debug(user_value)
    db.execute(f"... {safe_key}")    # sink reached only via safe_key

If we just flag write_record as “has a sink,” then every call to it taints, including calls that pass a harmless constant as safe_key. Congratulations, you’ve invented false positives, the thing that quietly kills adoption of every SAST tool ever shipped.

The fix is parameter-indexed summaries, the same shape Pysa and the IFDS/IDE literature have used for years:

Python: the summary that remembers which argument
@dataclass
class FunctionSummary:
    params_to_return: set[int]                      # which params flow to the return value
    params_to_sinks:  list[tuple[int, str, int]]    # (param_index, sink_pattern, line)
    is_source: bool

Now propagation cares which argument. At a call site, taint only flows in for the specific parameter positions the summary says actually reach a sink or the return. Pass a constant to safe_key (param 0) and nothing happens, because only param 1 (user_value) is in params_to_sinks. And taint that reaches the return value (params_to_return) flows back up into the caller’s variable, which is what lets a chain hop through several files instead of dead-ending at the first call.

This is compositional analysis: summarize each function once, on its own terms, then replay that summary at every call site. Compositionality is what makes it scale, and, conveniently, it’s also what makes the cache in the next section possible.

5. Scale: the incremental summary cache

Re-parsing every file on every run is wasteful, because in CI roughly 99% of files didn’t change since last time. And since summaries are compositional, they’re also cacheable.

We key each cached summary on everything that could legitimately change it:

Cache key
(relative_path, content_hash, rule_corpus_version, engine_version, language_config)
        → serialized summaries

The content_hash is what actually proves a file is unchanged; mtime, if it shows up at all, is just a cheap “should I even bother hashing this” pre-check, never a correctness input on its own. The other terms earn their place too: if the rules change (rule_corpus_version), or the analyzer changes (engine_version), or the parser settings change (language_config), then yesterday’s summaries are stale even though the file is byte-for-byte identical. Drop any of those terms and you’d let a stale summary survive an engine upgrade, which, in a security tool, is precisely the kind of silent wrongness you don’t want.

Hard rule

Serialize summaries to something safe (JSON, or SQLite values) and emphatically not pickle. Deserializing pickle an attacker could influence would introduce a deserialization bug into the analyzer whose entire job is finding deserialization bugs. We’d never live it down.

Net effect: the second scan of a repo, and every one after, turns from “re-index the world” into “re-index the diff.” Which is exactly the rhythm CI runs on.

6. Where it falls short (on purpose and otherwise)

Open-Rowan is a static analyzer, and static analysis is undecidable in the general case, so anyone who tells you their engine is “sound” is selling something. We over-approximate in some places (favoring recall) and under-approximate in others. The honest list:

  • Python only. The cross-file engine speaks Python and nothing else: it parses Python ASTs and understands Python’s import rules. The index/resolve/propagate architecture would generalize, but the per-language parsing and import resolution simply aren’t written for other languages yet.
  • Dynamic dispatch and reflection. Calls resolved at runtime (getattr, duck-typed dispatch, plugin registries that wire themselves up from config) are best-effort. We resolve by name and import structure, not by full type inference, so the cleverer your indirection, the more we shrug.
  • No path sensitivity. We don’t reason about which branch condition gates a flow, so a flow guarded by a condition that can never be true still gets reported. We lean on sanitizer detection and confidence scoring to keep the resulting noise tolerable.
  • Confidence fades with distance. Long multi-hop chains come back with lower confidence, because every extra unverified hop is another chance for our over-approximation to be wrong about something.

These are the normal taxes you pay for interprocedural analysis that scales. We’d rather print them here than imply a soundness we don’t have.

6.5 How we measure it (and how it embarrassed us)

A claim like “cross-file taint finds things intra-file taint can’t” is worth exactly nothing unless you measure it in a way you can’t quietly rig. So we publish the whole benchmark (protocol, labeled corpus, scoring harness) and anyone can reproduce the numbers. Two rules keep us honest.

Open-Rowan contains OpenGrep+taint, and we’ll say so loudly. Open-Rowan runs OpenGrep with --taint-intrafile as one of its own passes and then adds the cross-file layer. So “Open-Rowan beats standalone OpenGrep” is true by construction and also completely meaningless: it’s a superset beating its own subset. The measurement that actually means something is an ablation on our own pipeline. Every finding is tagged with the engine that produced it, so a single scan reconstructs three arms (intra-file taint where engine == opengrep, the cross-file layer where engine == crossfile, and the full thing) with rules, engine, and corpus held identical.

Cross-tool comparison is a separate exercise, and we never dress it up as the controlled one. OpenGrep, Semgrep Code, CodeQL, and Pysa each ship their own taint specs (different sources, sinks, sanitizers) and there’s no honest way to force them all onto identical rules. So any cross-tool table is measuring “what you get out of the box,” engine and rules tangled together on purpose, and labeled as such.

A worked example: Langflow

To run the ablation on real code instead of fixtures, we pointed it at Langflow, a big, busy Python project (well over a thousand .py files in a full checkout) with real cross-file taint depth. One scan, partitioned by engine:

ArmFindings
A0: intra-file taint (engine == opengrep)2,248
cross-file marginal (engine == crossfile)11
A2: full Open-Rowan2,259

Eleven findings out of 2,259 is a rounding error by volume (+0.5%), but it’s the kind of finding that matters: all eleven are transitive multi-hop flows (three, four, and five hops) threading through Langflow’s MCP server-config code. One has upload_server_config() reaching a sink via upload_user_file() in another file; another has auto_configure_agentic_mcp_server() reaching a sink three hops out. No single-file analysis produces these, ever. That’s the whole pitch in one table.

Two caveats, because the charter demands them:

  • That’s volume, not yet precision We haven’t hand-triaged the eleven here. The count is a measured fact; the true-positive rate among them is future work (the full multi-repo run with sampled triage). I am not claiming all eleven are real bugs.
  • × And here’s the part that should scare you a little An earlier build of this exact engine found zero cross-file flows on this exact repo. Not because the flows weren’t there (they were, all eleven) but because of a dumb wiring bug: a mismatched engine tag silently cut the cross-file annotation path, so the propagator had nothing to chew on. The engine was perfectly deterministic the entire time. It reproducibly returned the wrong answer, and would have kept doing so in CI until the heat death of the universe. The only reason we caught it is that the benchmark put a zero where a zero was impossible. Determinism gets you a repeatable answer; the benchmark is what makes it a repeatably correct one. We think that’s the strongest argument for shipping the measurement harness next to the engine, not the most embarrassing one.

7. A note on giving it away

Open-Rowan is the planned open-source edition of Rowan, our commercial scanner. The line we drew is simple: the well-understood, generic machinery (cross-file taint, SCC convergence, parameter-indexed summaries, the cache, the externalized rule config) is open. The tuned, agentic system we build on top of it (multi-language support, the LLM-driven orchestration, the exploit-reachability work) is the product. Nobody should have to pay us for Tarjan’s algorithm from 1972.

Why open-source the engine at all? Mostly because cross-file taint is table stakes for honest vulnerability detection, and leaving all of it behind paywalls means every open-source maintainer is auditing their own seams with a tool that can’t see seams. That seems like a bad equilibrium to prop up. Open config helps too: once propagators and sinks are just YAML, adding coverage for a new library is a pull request instead of a fork. And frankly it’s the decent thing to do by OpenGrep, which handed us a solid intra-file engine under an open license; the neighborly move is to publish the layer that sits on top and write up the design so the capability can land upstream, not just in our corner. (Rowan exists and its broad positioning is public on hedgerow.dev; the specifics of the commercial build aren’t independently audited, so take them as roadmap, not gospel.)

8. Availability

Status · June 2026

Open-Rowan is in private beta. It is not on PyPI and has no public GitHub repo yet. There is nothing to pip install today, no matter what a search turns up. This paper is the design, written ahead of the release.

When it ships, we’ll publish the engine and link it from hedgerow.dev. Until then, treat any package name or repo URL you stumble across as unverified.

Want in early? We’re taking a limited number of beta users, especially folks with big Python codebases, gnarly import graphs, or AI/ML infrastructure where the cross-file flows are exactly where the bodies are buried. Email hello@hedgerow.dev or come find us at hedgerow.dev.

And the thing we want most back from you is seams: a cross-file flow we missed, or a false positive we shouldn’t have produced. Seams are the whole point.

Companion paper: “Trust Me” Is Not a Finding: The Taint Microkernel, on why the deterministic engine should be the kernel and the language model the coprocessor.


References

Public sources for the factual comparisons above. Products move; these reflect docs and reporting as of June 2026. Check before you lean on any specific claim.

  1. Semgrep: Comparing Semgrep Community Edition and Semgrep Code for static analysis. semgrep.dev/blog/2025/security-research-comparing-semgrep-community-edition-and-semgrep-code-for-static-analysis
  2. OpenGrep: Intrafile tainting tutorial (OpenGrep wiki), documenting --taint-intrafile. github.com/opengrep/opengrep/wiki/Intrafile-tainting-tutorial
  3. OpenGrep vs Semgrep (2026): Fork vs Upstream Comparison. appsecsanta.com/sast-tools/opengrep-vs-semgrep
  4. Aikido Security: One year of Opengrep: what we built and what’s next, which explicitly lists inter-file (cross-file) taint analysis among upcoming priorities. aikido.dev/blog/opengrep-sast-one-year
  5. OpenGrep community discussion: multi-file taint analysis noted as not yet implemented and on the agenda. reddit.com/r/opengrep
  6. Semgrep Community Edition: open-source SAST engine (license reference).
  7. Semgrep: Cross-file analysis taint traces. semgrep.dev/docs/semgrep-code/semgrep-pro-engine-data-flow