Lambda-flow sensitivity information-flow fix
The engineering-level record for Capa 1.30.0, the companion to its What's New page. It states what was built, the model it rests on, the mechanism in the analyzer with file and line references, the alternatives rejected, how it was verified, and every residual that stays open. Claims are labelled MEASURED (read or run) or JUDGEMENT (reasoned inference).
1Summary
Capa 1.30.0 is an information-flow (IFC) soundness fix. Before it, a @secret value that reached a public sink through a locally-resolved lambda, either bound to the lambda's parameter or captured by a closure and read back through its result, leaked with no diagnostic at either tier: capa --check reported ok, an @strict_ifc build reported zero errors, and the secret reached the sink at run time on both backends. The identical flow written as a direct named call was already flagged, so the gap was the lambda indirection, not the sink. A lambda's flow and capture labels were stamped at its definition, and the call branch for a function value did no information-flow work.
The fix applies a lambda's latent flow signature at the application site: each lambda literal is summarised on the same sink-reaching fixpoint as a named function and applied at a locally-resolved call, and each captured binding's live label is re-read at the invocation. A coupled correctness change rejects a named argument at a first-class call, which had no sound meaning and diverged between the two backends. It ships under the STABILITY.md security exception as a MINOR bump. Severity is low to moderate, confidentiality only.
2Problem and threat
Property at stake. Capa treats information-flow control over @secret data as a first-class, machine-checked security property: a @secret value must not reach a public sink (Stdio.println / eprintln, Net.post, panic, a sink-reaching parameter of a further function) without an explicit declassify. The noninterference guarantee is claimed only under @strict_ifc, where the check is a hard error; by default it is a warning.
Threat model. A silent false negative: the author writes @secret, believes they are protected, and a build that gates noninterference on @strict_ifc passes while the secret escapes at run time. There is no integrity or availability impact and no bypass of the capability discipline; the break is confidentiality only. The advisory's illustrative vector is CVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N (about 5.3), with AC:H reflecting that the specific lambda-indirection shape and the @secret annotation must all be present.
Two distinct pre-fix mechanisms (MEASURED). Face 1, the parameter-sink: a call whose callee is a Fun-typed local or an IIFE went through a branch that consulted no sink summary and bound the arguments positionally, so a @secret bound to a lambda parameter reaching a sink inside the body was silently public. Face 2, the container-capture result-sink: a closure's captured labels were stamped at the closure's definition and never re-reflected when a captured binding was mutated afterwards, so a container captured before a push and read through the closure's result was silently public.
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)const TOKEN: @secret String = "s3cr3t"
type Bag { items: List<String> }
impl Bag
fun reveal(self) -> String
match self.items.get(0)
Some(x) -> return x
None -> return "empty"
fun leak(stdio: Stdio, secret: @secret String)
var bag: Bag = Bag { items: [] }
let f = fun() -> String => bag.reveal()
bag.items.push(secret)
stdio.println(f())
fun main(stdio: Stdio)
leak(stdio, TOKEN)3Model and prior art
First-class functions in an information-flow type system. The design frames a closure as carrying a latent flow signature that is instantiated at the application site, not the definition site. This is the model the advisory attributes to FlowCaml (the information-flow extension of Caml; Pottier and Simonet, "Information Flow Inference for ML", POPL 2002 and TOPLAS 2003). MEASURED: the advisory names "FlowCaml's model for first-class functions" as the basis for applying the signature at the application site. Capa realises the signature as the same per-callable sink-reaching / sink-path summary it computes for named functions, extended to lambda literals; the capture side re-reads the live label of each free binding at the invocation.
Noninterference and declassification (background). The underlying property is noninterference (Goguen and Meseguer, 1982): a public observation must not depend on a secret input. declassify(value, reason: "...") is Capa's endorsed escape hatch, an intentional, audited downgrade, in the tradition of principled declassification (Sabelfeld and Sands, "Declassification: Dimensions and Principles", 2005). JUDGEMENT: these citations are the standard basis for the property Capa enforces; they were not re-derived here, they frame the mechanism below.
4Mechanism
The fix touches capa/analyzer/_ifc.py, capa/analyzer/_ifc_summary.py and capa/analyzer/_dispatch.py across five commits. All references below are into the clean 1.30.0 install (site-packages/capa/analyzer/).
4.1 Face 1: the parameter-sink summary applied at the call (d8a31c5)
_collect_lambda_callables (_ifc_summary.py:514) registers every lambda literal in the module as a synthetic callable keyed by ("lambda", id(lambda_expr)) (_ifc_summary.py:516) and summarises its body on the same sink-reaching / sink-path fixpoint a named function uses. At a call g(args) whose callee resolves through the existing binding resolution to one certain lambda literal, and at an IIFE whose callee is the literal, that summary is applied to the actual arguments: _check_ifc_local_lambda_call (_ifc.py:2804) applies it at _ifc.py:2823, and _check_ifc_iife_call (_ifc.py:2825) applies it at _ifc.py:2833, both through the shared _apply_lambda_sink_summary (_ifc.py:2835). The diagnostic is assembled at _ifc.py:3010, naming the bound name ('g') or "the closure" for an IIFE. On any ambiguity (a reassigned var, an alias, a call-result binding, a lambda invoked in a higher-order callee) it falls back to no check: a conservative miss, never a wrong-target guess.
4.2 Face 2: the live capture re-read (099e2bc, 38dff85)
_fresh_capture_label (_ifc.py:1430) re-reads, at the invocation of a locally-resolved lambda, each captured free binding's current live label and joins it into the callee label. It never consults the label cached at the lambda's definition. It reads the branch-scoped container-mutation taint for all captures, and the live whole-value label for a reference-typed capture only. 38dff85 restricted the whole-value re-read to reference types: a value-typed / built-in-immutable capture (String / Int / Float / Bool / Char) is captured by value, so its later reassignment is correctly ignored. Branch-soundness is by construction: the container-taint map is live and branch-scoped, so a push in a mutually-exclusive branch is not observed at another branch's invocation. This face carries the later taint into the closure's RESULT label, so a caller that sinks the result is caught; a sink internal to the body carries no result and stays open (section 7, residual 1).
4.3 Rejecting a named argument at a first-class call (5189908)
A named argument at a first-class / lambda call site was type-checked positionally, but the Python transpiler emitted kwargs (honouring the names) while the Wasm backend bound positionally, a silent divergence that could reorder a @secret into an un-sunk slot. A Fun-typed value carries no parameter names, so a named argument has no sound binding. The check (_dispatch.py:164) rejects it before the positional binding that would otherwise misbind it, returning True when a named argument is found so the caller skips that binding. The named fun / method / variant path does carry parameter names and keeps the sound named-argument path (_resolve_named_args); it is untouched. Being an analysis rejection, it is identical on both backends.
4.4 Both backends
MEASURED: Faces 1 and 2 are a single analyzer implementation that runs before code generation, so the two new IFC diagnostics are backend-independent by construction; the runtime leak they should reject reproduced identically on the Python interpreter and the Wasm Component Model backend (section 6). The named-argument change is the opposite case: it REMOVES a real backend divergence (Python printed s3cr3t, Wasm printed pub for the same source) by rejecting the construct at analysis time on both backends.
5Alternatives considered and why rejected
- Stamping the flow labels at the lambda's definition (the pre-fix behaviour, unsound). This is exactly what leaked: a definition-time stamp cannot see a later capture mutation, and the function-value call branch did no flow work. Rejected in favour of applying the signature at the application site.
- Guessing the target on an ambiguous callee (rejected). When the callee cannot be resolved to one certain lambda literal, the check falls back to no check rather than guessing. A wrong-target guess could flag the wrong flow or miss the real one; a conservative miss is the disclosed residual 2, not a false report.
- Re-reading every capture's whole-value label (over-reports; corrected in 38dff85). Re-reading a value-typed primitive capture's label would flag a later reassignment that a by-value capture cannot observe. 38dff85 restricted the whole-value re-read to reference types, keeping the container-taint re-read for all captures. A pentester found the value-typed-capture false positive during Stage B; the reviewer measured the sound resolution.
- Binding a named first-class argument positionally by declaration order (unsound, the divergence). A function value carries no parameter names, so there is no sound binding; the two backends disagreed. Rejecting the construct is sound and identical on both backends; positional first-class calls stay allowed.
6Verification
Before/after, run on both released binaries. Two isolated venvs, capa-language==1.29.0 and capa-language==1.30.0 (capa --version confirmed for each), plus wasmtime for the Wasm backend.
BEFORE, capa 1.29.0 (Face 1, silent at both tiers, leaks on both backends):
$ capa --check face1_param_sink.capa
face1_param_sink.capa: ok (4 items, 13 expressions typed, 10 bindings)
$ capa --run face1_param_sink.capa # Python backend
s3cr3t
$ capa --run --wasm face1_param_sink.capa # Wasm backend
s3cr3tAFTER, capa 1.30.0 (warns by default, errors under @strict_ifc; still leaks at run time because a warning does not block):
$ capa --check face1_param_sink.capa
face1_param_sink.capa:6: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.
6 | g(secret)
^
face1_param_sink.capa: ok (4 items, 13 expressions typed, 10 bindings)
# with @strict_ifc() on leak:
face1_param_sink.capa:7:7: error: 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.
face1_param_sink.capa: 1 errorFace 2 (face2_capture_result.capa) was silent on 1.29.0 and warns on 1.30.0 at the caller's own sink, stdio.println(f()), with the result-sink wording:
face2_capture_result.capa:12:19: warning: information-flow: a @secret value reaches Stdio.println (argument 1), a public sink that sends data out of the program. Route it through declassify(value, reason: "...") if this disclosure is intended.The named-argument divergence (MEASURED). The program g(b: secret, a: "pub") on 1.29.0 passed capa --check with zero warnings, then capa --run printed s3cr3t and capa --run --wasm printed pub: a byte-level backend divergence, confirmed on the released 1.29.0 binary. On 1.30.0 it is rejected on both backends:
named.capa:6: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
named.capa: 1 errorTests that pin it. All in tests/test_ifc_branch_scoped_container.py at cafe73d. Closed shapes: TestSecretIntoLocalLambdaSinkClosed (Face 1), TestClosureCaptureBeforePushClosed and TestCaptureRereadReftype (Face 2). Precision: TestCaptureLiveRereadPrecision. Disclosed residuals: TestCaptureInternalSinkResidualDisclosed, TestEscapingLambdaSinkResidualDisclosed, TestHofInvokedClosureResidualDisclosed, TestNestedLocalLambdaSinkOpaqueResidualDisclosed.
7Scope and residuals
MEASURED framing: 1.30.0 flags a live-@secret flow through a LOCALLY-RESOLVED lambda (a let-bound lambda invoked in the same scope, or an IIFE) for the PARAMETER-SINK and the container-capture RESULT-SINK cases, and rejects a named argument at a first-class call. It closes nothing more. This is NOT "lambdas are closed".
Open residuals (each a tested false negative that leaks at run time UNFLAGGED at both tiers on both backends):
- A sink INTERNAL to the closure body. A locally-resolved closure that captures a value mutated after its definition and SINKS it inside its own body (a side effect, not the result the caller sinks) leaks unflagged, because the Face 2 re-read carries the taint into the closure's result label only. Pinned in TestCaptureInternalSinkResidualDisclosed. The locally-resolved portion of this was closed in the successor release 1.31.0.
- Closures that ESCAPE local resolution. A closure the caller cannot resolve to one certain lambda literal is reached by neither the sink summary nor the capture re-read: a reassigned var, an alias, a call-result binding, a closure passed to a higher-order callee then invoked, returned then invoked, stored then invoked, recursive, or conditionally selected. Closing them needs a higher-order control-flow / points-to analysis Capa does not have. Pinned in TestEscapingLambdaSinkResidualDisclosed, TestHofInvokedClosureResidualDisclosed.
- A sink reached only through a NESTED LOCAL lambda. The summary walk resolves a body's calls to named callees only, never to a local-lambda binding, so "sinks its parameter" means directly or via a named callee. Pinned in TestNestedLocalLambdaSinkOpaqueResidualDisclosed.
Two disclosed sound over-reports (they FLAG though nothing secret reaches the sink):
- A captured STRUCT whole-reassigned to a secret after the closure is defined. A reference-typed capture's re-read cannot tell a whole reassign from an in-place field store, so it flags under @strict_ifc though the capture is by value and prints the public value at run time: a safe strict-tier over-rejection. Pinned in TestCaptureRereadReftype. A related sibling-read over-report (a closure reading only a clean sibling of a mutated struct) was, for the DIRECT field-read case, removed in 1.30.1.
- An in-body declassify inside a captured closure. The Face 2 re-read reads the raw branch-scoped container taint and is declassify-blind, so a closure that declassifies its captured value in-body flags at both tiers. Sound (over-report, never a missed leak), with a clean workaround: declassify at the call site. Pinned in TestCaptureLiveRereadPrecision.
8Cross-references
- Version. 1.30.0 (released 2026-08-10). MINOR bump under the STABILITY.md security exception, matching the earlier IFC soundness releases (1.2.0, 1.3.0, 1.4.0, 1.15.0, 1.26.0 through 1.29.0). It closes the lambda-flow residual disclosed with 1.29.0's whole-struct-read advisory.
- Commits. d8a31c5 (Stage A, the parameter-sink face), 5189908 (reject named arguments at a first-class call; disclose the nested-local-lambda sink), 099e2bc (Stage B, the capture-side result-sink face), ff65822 (disclose the declassify-blind capture re-read over-report), 38dff85 (restrict the capture re-read to reference-typed captures; honest capture-side wording; residual pins), cafe73d (release).
- Advisory. docs/advisories/2026-08-10-ifc-lambda-flow-sensitivity.md. DISCREPANCY (MEASURED): the release process references the external identifier GHSA-xm3f-8mh8-3x5x, but no GHSA, CWE or CVE identifier for this fix appears in the committed 1.30.0 tree (the advisory body, the CHANGELOG entry, or SECURITY.md). This record treats that identifier as an out-of-tree reference, not as tree content.
- Tests. tests/test_ifc_branch_scoped_container.py (section 6).
- Related records. The successor precision release 1.30.1 (field-store field-sensitivity, which removes one sibling-read over-report of this fix and builds the field-store access-path channel), and the successor security release 1.31.0 (which closes the locally-resolved capture-internal sink, residual 1 above). The broader information-flow laundering line: 2026-06-16-soundness.md, 2026-07-03-soundness.md, and the 2026-08 advisories for 1.26.0 through 1.29.0.