> ## Content Index
> Fetch the complete content index at: https://blog.brokk.ai/llms.txt
> Use this file to discover other available public pages before exploring further.

# Following untrusted data through a database
- URL: https://blog.brokk.ai/following-untrusted-data-through-a-database/
- Published: 2026-09-21T15:00:15.000Z
- Updated: 2026-09-21T15:00:15.000Z
- Author: David Baker Effendi
- Tags: Code Intelligence

A profile field can be stored safely with a parameterized SQL query and still cause stored cross-site scripting (XSS) when it is later inserted into a page without the right output encoding. The same stored value could cause SQL injection if another part of the application concatenates it into a query.

Sensitive data takes similar routes. A customer's email address may belong in the database but not in a log, analytics event, or external service. The question is whether the data reaches a place where it can cause harm or be disclosed to the wrong audience.

A static analyzer checks these paths without running the code. In taint analysis, a policy marks data at a source and follows it to a sink: a use we want to check. That might mean user-controlled text reaching HTML or SQL, or personal data reaching a log. To follow either path, the analyzer needs a way across the storage boundary.

Here's the problem in three lines:

```java
KeyStore store = KeyStore.open("accounts");
store.put("account", userInput());
sink(store.get("account"));
```

Our example API, `KeyStore`, stores values under string keys. `userInput()` supplies untrusted data; `sink(...)` stands in for an operation where that data would be dangerous.

The path is easy to follow by eye: write the input under "account", then read it back. If put and get live in a library the analyzer can't inspect, it needs a model of that connection.

Bifrost lets you describe that connection in a policy: which calls write data, and which calls read it back. It can then follow possible flows through storage without inspecting the database code. The examples and recordings below use Bifrost v0.11.5.

## Connect the writes and reads

Bifrost's policy language, [RQLP](https://bifrost.brokk.ai/static-analysis-policies/?ref=blog.brokk.ai), lets us describe the write and read together. Here's an intentionally naive `:stores` section to show how the pieces fit:

```lisp
:stores
  (endpoint-set :entries [
    (store-write :id account-write
      :selector (rql (call :callee (name "put")))
      :store application-db
      :input (argument :index 1))

    (store-read :id account-read
      :selector (rql (call :callee (name "get")))
      :store application-db
      :output return-value)])
```

Each `:selector` matches calls in the code: `put` for writes, `get` for reads. These name-only queries are illustrative; we "narrow" them to the right API below.

`application-db` is the shared binding key for this persistence store. Using `:store application-db` on both definitions connects the matched writes and reads. More definitions can share that name; a different name creates a separate store.

This is a policy name, not a connection string or the record key "account". Each `:id` names one definition; the shared `:store` value joins the definitions.

The write's `:input (argument :index 1)` selects the `value` in `put(key, value)`. Arguments count from zero. The read's `:output return-value` selects the value returned by `get(key)`. The model says that data passed to the write may emerge from the read.

This first version doesn't distinguish record keys or store objects. We add `:key` and `:instance` to make those distinctions when Bifrost can prove them.

## Keep different keys separate

Change the read to use another key:

```java
KeyStore store = KeyStore.open("accounts");
store.put("account", userInput());
sink(store.get("preferences"));
```

With `:key (argument :index 0)` on both definitions, Bifrost can prove that `"account"` and `"preferences"` are different. This write therefore doesn't feed this read through the store model.

When Bifrost cannot establish a key's identity, it keeps the possible connection. An unknown key may lead to a false positive; it cannot establish that a read is safe.

`:instance receiver` separates store objects when their identities are proven. Use it only if different objects represent separate stores in your API: two clients can still talk to the same database.

## Match the right API

Matching every method named `put` or `get` could pull in unrelated APIs.

This store definition instead requires calls to resolve exactly to our `KeyStore` methods. It also checks the receiver type and argument count:

```lisp
:languages [jvm]
:rql-schema-version 1
:stores
  (endpoint-set :entries [
    (store-write :id account-write
      :selector (rql
        (call-argument :formal-index 1
          (resolved-call
            :resolves-to "ai.brokk.KeyStore.put"
            :proof exact
            :receiver-type (assignable-to "ai.brokk.KeyStore")
            (call-bindings
              (call-shape
                (call :callee (name "put") (arity 2)))))))
      :store application-db
      :key (argument :index 0)
      :instance receiver
      :input (argument :index 1))

    (store-read :id account-read
      :selector (rql
        (call-argument :formal-index 0
          (resolved-call
            :resolves-to "ai.brokk.KeyStore.get"
            :proof exact
            :receiver-type (assignable-to "ai.brokk.KeyStore")
            (call-bindings
              (call-shape
                (call :callee (name "get") (arity 1)))))))
      :store application-db
      :key (argument :index 0)
      :instance receiver
      :output return-value)])
```

> Bonus: `:receiver-type (assignable-to "ai.brokk.KeyStore")` accepts methods declared by `KeyStore` or its proven subtypes. The separate `:resolves-to` field still requires the exact method named in the query; this type check alone won't include every override.

The call-argument step picks an argument binding: the written value for put, and the key for get. Below each selector, :key, :instance and :input or :output describe how that call connects to the store.

## Add the source and sink

The policy also needs a source and a sink. For our placeholder methods:

```lisp
:sources
  (endpoint-set :entries [
    (source
      :id user-input
      :display-name "userInput()"
      :categories [input.user-controlled]
      :selector (rql (call :callee (name "userInput")))
      :bind return-value
      :labels [attacker-controlled])])

; Where taint becomes a finding
:sinks
  (endpoint-set :entries [
    (sink
      :id sensitive-sink
      :display-name "sink(...)"
      :categories [data.sensitive]
      :selector (rql (call :callee (name "sink")))
      :dangerous-operand (argument :index 0)
      :accepts [attacker-controlled])])
```

These source and sink selectors use names for brevity. For a real policy, identify the actual APIs as precisely as the store methods above. These are policy sections to adapt; the [policy documentation](https://bifrost.brokk.ai/static-analysis-policies/?ref=blog.brokk.ai) covers the surrounding configuration.

![](https://storage.ghost.io/c/f5/e4/f5e49182-2f16-4855-8727-fde7524e784f/content/images/2026/09/wihtout_store.gif)

![](https://storage.ghost.io/c/f5/e4/f5e49182-2f16-4855-8727-fde7524e784f/content/images/2026/09/download.gif)

The two recordings use the same Java example. The first runs without the store model: Bifrost cannot follow the opaque `put` call, so it reports incomplete analysis and zero findings. That does not establish that the code is safe. The second adds the store model and completes with one finding and no analysis warnings.

`KeyStore.put` and `KeyStore.get` are native declarations, giving Bifrost the API identities but no implementation to inspect. The `userInput()` and `sink(...)` helpers have ordinary Java bodies and are marked as endpoints by the policy. Both runs require models for opaque calls.

The second run completes, but the finding remains possible and unproven, with a partial path. The displayed path starts at `store.get("account")` and ends at `sink(...)`; it does not show the full journey from `userInput()` through the write and read.

Both recordings also show incomplete dependency-model coverage. This is reported separately from completion of the policy run; the store model resolves the boundary needed for this example.

Separate v0.11.5 checks, not shown in these recordings, test key matching. Reading "preferences" after writing "account" completes with zero findings. Using locally aliased variables for the same key completes with one finding, with both key identities recovered.

## What this model can tell you

Bifrost carries taint labels from matched writes to compatible reads, repeating until no new labels appear or it reaches an analysis limit. The storage connection is an assumption declared by the policy, not observed database activity.

It doesn't track the database's contents over time. Deletion and overwrite order are outside this model, so it won't tell you which write happened last.

Checking operation order, such as whether an opened resource is eventually closed, is a separate job for [typestate analysis](https://bifrost.brokk.ai/static-analysis-policies/?ref=blog.brokk.ai#typestate-endpoint-reuse-plus-protocol-rules). I'll dive into this more for my next blog post.

The profile field's safety depends on its eventual use. Parameterizing the database write does not protect a page that later inserts the stored value as HTML; that use needs context-appropriate output encoding to prevent stored XSS. A later SQL query also needs its own protection against injection. The write and the unsafe use may happen in different requests or jobs.

For sensitive data, the concern may be disclosure: an email address belongs in the database but should not reach a particular log or external service. The source and sink definitions determine which flow we want to check.

The demo uses a placeholder sink. A real policy must identify the actual HTML, SQL or disclosure sinks and the protections relevant to each. The store model gives Bifrost a way to follow the data from a write to a later read, even when the database implementation is opaque. We still need to check the model's assumptions and the gaps it reports, but *we can now ask what happens to the data after it comes back out.*