Releases · 1.31.0

What's new in Capa 1.31.0

An information-flow security fix. A value marked @secret, captured by a closure and then sunk from inside that closure's own body after the taint arrived, used to slip past the checker with no diagnostic at either tier. This release flags it: a warning by default, and a hard error under @strict_ifc, on both backends.

What this release is

Capa 1.31.0 is an information-flow security fix, and only that. It closes a case where a value you marked @secret could reach a public sink (a log line, a network call) with no signal to the author: no warning at the default tier, and not even an error under @strict_ifc. That silence at both tiers was the whole problem, because the author wrote @secret, believed the value was protected, and it was not.

It is a minor release. The only programs it affects are the ones that were already leaking a secret without a diagnostic; every correct program compiles exactly as before. That follows the same rule the earlier information-flow soundness fixes on the 1.x line used: tightening a static check so a program that was silently unsound now warns is a security fix under the project's stability policy, not a breaking change. Severity is low to moderate, confidentiality only, and the fix touches the analyzer alone (no runtime or code-generation change).

What changed, in plain terms

A closure is a small inline function that captures the variables around it. Deferring a log line or a serialise step into a closure and calling it a moment later is an everyday pattern. The leak lived in the order of events:

  • A closure captured a value that was still public when the closure was written.
  • The @secret value was stored into that captured value after the closure was written.
  • The closure was then called, and its body sent the now-secret value to a public sink.

The checker read the closure body once, at the point it was written, when the captured value was still public, and never looked at it again at the call. So the later taint was missed entirely. The prior release (1.30.0) had already learned to re-check a capture when the closure's returned result is sunk by the caller, but a sink internal to the body, where the closure returns nothing and prints the captured value itself, carries no result to inspect and stayed silent. Capa 1.31.0 gives each closure a summary of which captured values reach a sink inside its body; at the call it checks the live label of each of those captured values, and a captured @secret reaching an internal sink is now flagged at the call site. The check runs in the analyzer, before code generation, so the diagnostic is identical on the interpreter and the Wasm backend.

Before and after

The closure below captures bag. The secret is written into bag afterwards, and the closure sinks it from inside its own body:

leak.capa
const TOKEN: @secret String = "s3cr3t"

type Bag { data: String }

fun leak(stdio: Stdio, secret: @secret String)
    var bag: Bag = Bag { data: "public" }
    let log: Fun() -> Unit = fun() -> Unit => stdio.println(bag.data)
    bag.data = secret        // the secret arrives after log is defined
    log()                    // sinks the secret inside the closure body

fun main(stdio: Stdio)
    leak(stdio, TOKEN)

On 1.30.1 (and earlier releases on the 1.x line) the checker was silent at both tiers, and both backends printed the secret at run time:

capa 1.30.1
$ capa --check leak.capa
leak.capa: ok (4 items, 15 expressions typed, 8 bindings)

$ capa --run leak.capa            # Python backend
s3cr3t

$ capa --run --wasm leak.capa     # Wasm backend
s3cr3t

Adding @strict_ifc() to leak made no difference on 1.30.1: capa --check still reported ok and exited 0 while the secret escaped. On 1.31.0 the default check warns at the call site:

capa 1.31.0
$ capa --check leak.capa
leak.capa:9:5: warning: information-flow: a @secret value is passed to 'log' as the captured 'bag', which reaches a public sink inside 'log' (it sends data out of the program). Route it through declassify(value, reason: "...") if this disclosure is intended.
   9 |     log()                    // sinks the secret inside the closure body
           ^

leak.capa: ok (4 items, 15 expressions typed, 8 bindings)

Add @strict_ifc() to leak and the same finding becomes a hard error: capa --check exits 1, so a pipeline that gates on strict mode now stops the build. A warning does not block, so capa --run still prints the secret at the default tier on both backends (the warning is printed first); it is @strict_ifc that turns the finding into a build-stopping error. If the disclosure is genuinely intended, route the value through declassify(value, reason: "..."), exactly as in the information-flow chapter; a closure that declassifies the value it sinks stays clean.

An honest scope

This is a security fix with a deliberately narrow, disclosed scope. It closes the locally-resolved case: a closure bound to a name and called in the same scope, or one that is immediately invoked, where the sink is reached directly or through a named helper, and the taint arrives by a field store, a container push, or a called function's field-write. That is the common shape, and it is now covered on both backends. It is not "capture-internal sinks are closed".

The following stay open, each a known, tested false negative that still leaks unflagged at run time on both backends, documented rather than silent:

  • Closures that escape local resolution: aliased to another name (let g = f; g()), passed to another function and invoked there (apply(f)), returned, or reassigned. Closing these needs a higher-order control-flow analysis Capa does not have.
  • A sink reached only through a further nested inline closure: the body-summary walk follows calls to named functions and methods, not to a local lambda binding.
  • A few container-aliasing shapes inherited from the underlying taint channel: a container moved out of its struct, or a mutator whose receiver is rooted at a call or an index rather than a binding.
  • A loop-carried read placed textually before the push that would feed it, which the one-pass body summary does not see.

In the other direction the fix errs toward flagging: in a small number of sound cases it warns where nothing actually leaks (for example a whole-struct read of a clean field of a struct whose different field was later made secret). Those over-reports never hide a leak, and are listed alongside the residuals in the advisory. For the full reasoning, the reproduction, and the complete list, read the advisory or the technical design record linked below.

Warnings do not block. As with every information-flow finding in Capa, the default tier warns and the program still runs; it is @strict_ifc that turns the finding into a build-stopping error. Gate on strict mode in CI if you want the guarantee enforced.

Learn more

Keep going