Releases · 1.30.0

What's new in Capa 1.30.0

An information-flow security fix. A value marked @secret that reached a public sink through a lambda (bound to its parameter, or captured by it and read back through its result) used to slip past the checker with no diagnostic at either tier, while the identical flow through a direct named call was caught. This release closes the locally-resolved cases: a warning by default, a hard error under @strict_ifc, on both backends. It also rejects a named argument at a first-class call, closing a silent divergence between the two backends.

What this release is

Capa 1.30.0 is an information-flow security fix. 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. The direct form of the same flow, a plain named call carrying the secret into a sink, was already caught; the gap was the lambda indirection, the idiomatic way to factor out a log or serialise step.

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 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 lives in the analyzer (with one coupled correctness change in the backends, below).

What changed, in plain terms

A lambda is a small inline function. Capa already knew how to follow a secret through a named function: it summarises which of a function's parameters reach a sink, and flags a @secret passed into one of them. But a lambda's flow labels were stamped at the point the lambda was defined, and the branch that calls a function value (a let-bound lambda, or an immediately-invoked (fun...)(x)) did no information-flow work at all. So the lambda laundered the secret past both the default warning and the @strict_ifc error.

Capa 1.30.0 applies a lambda's latent flow signature at the call site, not at its definition. That closes two shapes:

  • The parameter-sink. A @secret bound to a lambda's parameter that reaches a public sink inside the body. Each lambda literal now carries its own sink-reaching summary, resolved to the invoked lambda and applied at the call exactly as the named-call check does.
  • The container-capture result-sink. A container captured by a closure defined before a mutation, then read back through the closure's result. Each captured binding's current live label is now re-read at the invocation, so a later push is visible through the earlier capture.

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 lambda below binds its parameter s and sinks it. The secret is passed to the lambda, not to a direct named call:

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

fun sink_str(s: String, stdio: Stdio)
    stdio.println(s)

fun leak(stdio: Stdio, secret: @secret String)
    let g: Fun(String) -> Unit = fun(s: String) -> Unit => sink_str(s, stdio)
    g(secret)

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

On 1.29.0 (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.29.0
$ capa --check leak.capa
leak.capa: ok (4 items, 13 expressions typed, 10 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.29.0: capa --check still reported ok and exited 0 while the secret escaped. On 1.30.0 the default check warns at the call site:

capa 1.30.0
$ capa --check leak.capa
leak.capa:8:7: warning: information-flow: a @secret value is passed to 'g' as s, which reaches a public sink inside 'g' (it sends data out of the program). Route it through declassify(value, reason: "...") if this disclosure is intended.
   8 |     g(secret)
             ^

leak.capa: ok (4 items, 13 expressions typed, 10 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. If the disclosure is genuinely intended, route the value through declassify(value, reason: "..."), exactly as in the information-flow chapter.

A coupled fix: a named argument at a first-class call

Capa promises byte-identical output on the Python interpreter and the Wasm backend. A named argument at a first-class (lambda) call site broke that promise. It was type-checked positionally, but the Python backend honoured the names while the Wasm backend bound positionally, so the two backends printed different values, and the names could reorder a @secret into an un-sunk slot:

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

fun sink_str(s: String, stdio: Stdio)
    stdio.println(s)

fun leak(stdio: Stdio, secret: @secret String)
    let g: Fun(String, String) -> Unit = fun(a: String, b: String) -> Unit => sink_str(b, stdio)
    g(b: secret, a: "pub")

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

On 1.29.0 that program passed capa --check with zero warnings, then diverged: the Python backend printed s3cr3t and the Wasm backend printed pub. A function value carries no parameter names, so a named argument there has no sound binding. On 1.30.0 the call is rejected at compile time, identically on both backends:

capa 1.30.0
$ capa --check named.capa
named.capa:8:5: error: named arguments are not supported at a call to the function value 'g'; a function value carries no parameter names, so pass the arguments positionally
   8 |     g(b: secret, a: "pub")
           ^

named.capa: 1 error

Positional first-class calls stay allowed. The named-function, method, and variant paths do carry parameter names and keep the sound named-argument path; they are untouched.

An honest scope

This is a security fix with a deliberately narrow, disclosed scope. It flags a secret reaching a public sink through a locally-resolved lambda (a let-bound lambda invoked in the same scope, or an immediately-invoked one) for the parameter-sink and the container-capture-result-sink cases, and nothing more. It is not "lambdas 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:

  • A sink internal to the closure body (a side effect, not the result the caller sinks). This carries no result to inspect, so it stayed open here; its locally-resolved portion was closed later, in 1.31.0.
  • Closures that escape local resolution: aliased (let g2 = g), passed to a higher-order callee and invoked there, returned, reassigned, or bound from a call result. Closing these needs a higher-order control-flow analysis Capa does not have.
  • A sink reached only through a nested local lambda: the summary walk resolves a body's calls to named callees only, not to a local-lambda binding.

In the other direction the fix errs toward flagging in two disclosed, sound cases: a captured struct whole-reassigned to a secret after the closure is defined, and an in-body declassify inside a captured closure. Both over-report, never under-report; one of the sibling-read over-reports was later removed in 1.30.1. 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