A request handler saves a user's bio. Another handler, written months later by someone else, renders that bio on a profile page. Each handler looks fine on its own. The first one only writes to the database. The second one only reads from it. The vulnerability lives in the gap between them, and that gap is exactly where most static analysis stops looking.
This is a stored, or second-order, flow. Vulkro 0.39.0, released on 21 October, now follows these flows for JavaScript, TypeScript and Python.
Why the database breaks the trail
Taint analysis follows a value from a source (request data) to a sink (an HTML response, a shell command, a SQL string) and reports the flow when nothing in between makes it safe. That works well while the value stays in memory. The moment it is written to a database column, the trail goes cold: the read that pulls it back out looks like a fresh value from a trusted place, so the sink at the other end is never connected to the request that put the value there.
Here is the shape in an Express app using Prisma:
// routes/profile.js
app.post('/profile', requireLogin, async (req, res) => {
await prisma.user.update({
where: { id: req.user.id },
data: { bio: req.body.bio },
});
res.sendStatus(204);
});
// routes/public.js
app.get('/u/:handle', async (req, res) => {
const user = await prisma.user.findUnique({ where: { handle: req.params.handle } });
res.send(`<h1>${user.handle}</h1><div class="bio">${user.bio}</div>`);
});The second route never touches req.body. A scanner that only tracks values in memory sees a database read flowing into HTML and has no reason to complain. An attacker sees a stored cross-site scripting bug that fires for every visitor to their profile.
The same thing happens with other sinks. In Django, a saved report filter can come back as SQL text:
def save_filter(request, pk):
report = Report.objects.get(pk=pk, owner=request.user)
report.filter_sql = request.POST["filter"]
report.save()
def run_report(request, pk):
report = Report.objects.get(pk=pk, owner=request.user)
with connection.cursor() as cur:
cur.execute(f"SELECT * FROM orders WHERE {report.filter_sql}")That is SQL injection with a delay. It is also the kind of bug that slips through review, because the person reviewing run_report reasonably assumes the database holds clean data.
How Vulkro connects the two halves
The approach is deliberately simple and runs entirely on your machine.
- Find the writes. Vulkro reads your data models and builds a project-wide index of which columns a request handler writes from request data. For JavaScript and TypeScript it understands Prisma, Mongoose, Sequelize, TypeORM and Knex writes. For Python it understands Django model managers, constructors, field assignment followed by
save(), Django REST Framework serializer saves, and SQLAlchemy. - Leave out columns that cannot carry an attack. System columns (ids, timestamps), values coerced to numbers or booleans before they are stored, and values HTML-escaped before storage (when the sink is HTML) are dropped from the index.
- Find the reads. When a later read of an indexed column reaches a sink, the flow is reported. The sinks covered are HTML responses, templates, outbound requests (SSRF), shell commands, SQL text and file paths.
- Say where the value came from. The trace for the finding includes a step naming the write: stored from request at a specific file and line. You can open both halves of the bug from one finding.
Why these findings are never "proven"
Vulkro separates findings it can prove, with a traced flow from source to sink, from findings it reports with less certainty. Stored flows go in the second group by design, and they are reported at Medium confidence at most.
The reason is honesty about what a static tool can know. The link between the write and the read is a column name, not a traced value. A migration might clean the column, an admin job might rewrite it, and the row that a read returns might never have been written by the handler we found. We can show you a strong, specific reason to look. We cannot show the value moving through the database, so we do not call it proven.
The same rule applies to the other new entry points in 0.39.0. Background workers (BullMQ and Bull workers, Celery tasks, Spring Kafka, RabbitMQ, JMS and SQS listeners, and Go queue consumers) are now treated as places where untrusted data enters. Their flows are reported at reduced confidence too, because what lands on a queue depends on code and infrastructure a scan of one repository cannot see.
What we measured
On our blind JavaScript holdout set (projects the rules were not tuned on), recall at Medium confidence and above went from 17 to 18 of 35 known vulnerabilities with this change alone. The negative sets, which contain code that must not be flagged, did not change. That is a small step, and we report it as one. Stored flows are a small share of real bugs, but they are among the hardest to find in review, so each one caught is worth having.
Trying it
Stored flows are on by default in 0.39.0. Run a normal scan:
vulkro scan .Look for findings whose trace includes a "stored from request" step. If the column really is safe, for example because every write path validates it, the finding gives you both file and line references to confirm that quickly. If it is not safe, the usual fix applies at the sink: escape on output, bind SQL parameters, and validate the value where it is used, not only where it was saved.
The full list of what changed in 0.39.0 is in the changelog.