Field-store field-sensitivity precision fix
The engineering-level record for Capa 1.30.1, 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). This is a precision (false-positive-removal) release, not a security fix; it carries no advisory.
1Summary
Capa 1.30.1 is an information-flow precision fix. It removes two sound over-reports that 1.30.0 emitted on a read of a public sibling field of a struct whose OTHER field was made secret: a cross-function field store (bag.secret_field = secret in a callee, then a caller reading bag.note) and the closure analogue (a closure re-reading a clean sibling of a struct stored into after the closure was defined). Both were disclosed as sound over-reports in the two 2026-08-10 advisories; neither ever leaked.
The fix gives a direct field store the field sensitivity a container mutation already had: it is keyed on the (root, field-path) access-path channel, so a read of a disjoint sibling path is no longer tainted. It closes no leak and removes no coverage, so it ships as a PATCH under the STABILITY.md patch rule, with no security advisory. Because the only change accepts more programs, no correct 1.x program can break.
2Problem and motivation
Not a threat, a false positive. Unlike the surrounding information-flow releases, 1.30.1 fixes no soundness hole. It removes a diagnostic that fired where nothing leaked. A sound over-report is safe (it never hides a leak), but it erodes trust in the checker and pushes an author toward a spurious declassify that then masks a genuine future leak, so removing it has real value.
Pre-fix behaviour (MEASURED). 1.30.0 already keyed a container mutation (bag.items.push(secret)) on the (root, field-path) the container lived at, so a read of a different field stayed clean. A plain field store did not get that precision: it raised the label of the whole struct through the cross-function effect and the capture re-read alike, so a later read of any part of the struct, including a disjoint public sibling, observed the taint. The minimal reproduction:
const TOKEN: @secret String = "s3cr3t"
type Bag { secret_field: String, note: String }
fun fill(bag: Bag, secret: @secret String)
bag.secret_field = secret
fun main(stdio: Stdio)
var bag: Bag = Bag { secret_field: "", note: "public" }
fill(bag, TOKEN)
stdio.println(bag.note)The read at bag.note is a disjoint sibling of the stored bag.secret_field; the program prints public and never discloses the secret, yet 1.30.0 flagged the read.
3Model and prior art
Access-path / field-sensitive taint. The channel is the (root, field-path) access path: a taint names not just the binding but the exact sub-path of the value that is dirty, and a read observes a taint only when its path is prefix-compatible with the tainted path. This is the field-sensitive taint model the 1.28.0 and 1.29.0 container fixes built (keying a container mutation on the field it lives at, and catching a whole-value read back through the tainted prefix). MEASURED: 1.30.1 extends that same channel from container mutations to a direct field store. JUDGEMENT: field-sensitive / access-path taint is standard practice in static taint analysis; the novelty here is only extending an existing channel to one more mutation form, not a new theory.
Soundness invariant. Field sensitivity is safe only if every whole-value label raise that a field-precise read cannot see still forces a conservative whole-value taint. 1.30.1 makes that a maintenance invariant (see 4.5): every non-seed whole-value raise routes through one choke-point that also marks the binding whole-value-dirty, so the field-precise re-read never silently suppresses a taint it cannot resolve to a path.
4Mechanism
The fix is entirely in the analyzer (capa/analyzer/_ifc.py and capa/analyzer/_ifc_summary.py) across six commits. All references below are into the clean 1.30.1 install (site-packages/capa/analyzer/).
4.1 Field-key the cross-function field-store effect (7490d6a, 24acfae)
A callee's field store used to record a WHOLE-VALUE cross-function effect on the mutated parameter. 7490d6a keys that mutation effect on the (root, field-path) access path, and 24acfae does the same for the cross-function summary content channel, so a caller reading a disjoint sibling is no longer tainted. The path of a read is computed by _field_path_from_root (_ifc_summary.py:836) and matched prefix-compatibly against the tainted paths (the argument-side gate at _ifc_summary.py:297 onward): a determinable read path that is prefix-compatible with no tainted path is clean; an undeterminable read falls back to the empty path, which is prefix-compatible with every taint, so it stays conservatively flagged.
4.2 Field-precise capture re-read of an in-place field store (1580715)
The Face 2 capture re-read from 1.30.0, _fresh_capture_label (_ifc.py:1430), used the whole-value label of the captured root, so a closure reading only a clean sibling flagged. 1580715 makes it field-precise: a read at a determinable field path observes only the branch-scoped container taints prefix-compatible with that path, consulted through _container_taint_at (_ifc.py:2126). A whole or undeterminable read still observes every taint on the root (residual, section 7).
4.3 Ancestor store observed on a descendant read (6eb9a10)
Prefix-compatibility is directional: a store at an interior node must be observed by a read of any descendant path (writing the parent dirties the child). 6eb9a10 makes the read gate observe an ancestor field store on a descendant read, so making the whole struct secret and reading one field of it stays flagged. This keeps the precision from opening a hole.
4.4 Re-consult the whole-value label for an escaped store (a808c86)
A field store the field-precise channel cannot see (an aliased, renamed or over-long root) escapes to the whole-value label. a808c86 makes the capture re-read re-consult sym.label for a capture dirtied by such an escaped store, so a store the precise channel misses is still caught through the conservative carrier.
4.5 One choke-point for every non-seed whole-value raise (67cea58)
67cea58 routes every whole-value label raise OUTSIDE the precise field-store leaf path (an aliased / escaped / unresolvable-path store, and the cross-function whole-value carrier) through a single choke-point, _raise_whole_value_label (_ifc.py:2070), which also marks the binding whole-value-dirty. This is the maintenance invariant that keeps the field-precise re-read sound: it can never silently suppress a whole-value taint it cannot resolve to a path, because any such taint has also set the dirty mark the re-read consults.
4.6 Both backends
MEASURED: the fix commits touch only capa/analyzer/_ifc.py, capa/analyzer/_ifc_summary.py and test files. No code-generation or runtime file is touched, and no runtime behaviour changes: the affected programs never leaked, so they print the same public value on both backends before and after. What changes is the analyzer verdict, which is a single implementation running before code generation and therefore backend-independent by construction (section 6).
5Alternatives considered and why rejected
- Keep the whole-value carrier for a field store (the pre-fix behaviour, over-reports). Sound but imprecise: it flags every sibling read. Rejected in favour of the field-keyed store, keeping the whole-value carrier only where the store cannot be resolved to a path.
- Field-key every field store regardless of root (rejected). An aliased, renamed or over-long root cannot be resolved to a reliable field path, so field-keying it would drop a real cross-function whole-value flow. The keying is applied only to a store rooted directly at the binding within the tracked field-path bound; anything else keeps the conservative carrier. This preserves every leak 1.30.0 caught.
- Trust the field-precise channel everywhere (unsound without the invariant). A field-precise read that could not see an escaped or aliased store would silently clear a real taint. Instead every non-seed whole-value raise routes through the _raise_whole_value_label choke-point that also sets the dirty mark (4.5), so the precise read can defer to the conservative carrier when a taint is not path-resolvable.
6Verification
Before/after, run on both released binaries. Two isolated venvs, capa-language==1.30.0 and capa-language==1.30.1 (capa --version confirmed for each), plus wasmtime for the Wasm backend. The program is the section 2 reproduction.
BEFORE, capa 1.30.0 (flags the clean sibling read; the program never leaks):
$ capa --check sibling.capa
sibling.capa:8: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.
8 | stdio.println(bag.note)
^
sibling.capa: ok (4 items, 14 expressions typed, 7 bindings)
# with @strict_ifc() on main: the same finding is a hard error, exit 1
sibling.capa:9:19: error: information-flow: a @secret value reaches Stdio.println (argument 1) ...
sibling.capa: 1 error
$ capa --run sibling.capa
publicAFTER, capa 1.30.1 (clean at every tier; runtime unchanged on both backends):
$ capa --check sibling.capa
sibling.capa: ok (4 items, 14 expressions typed, 7 bindings)
# with @strict_ifc() on main: still ok, exit 0
$ capa --run sibling.capa # Python backend
public
$ capa --run --wasm sibling.capa # Wasm backend
publicNo leak dropped (MEASURED control). Changing the last line to read the STORED field, stdio.println(bag.secret_field), still warns on 1.30.1:
$ capa --check stored.capa
stored.capa:8: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.
8 | stdio.println(bag.secret_field)
^Tests that pin it. All in tests/test_ifc_branch_scoped_container.py at 2ac52e5. Precision closed: TestCrossFnFieldStoreFieldKeyed, TestCrossFnContentFieldPrecise, TestCaptureFieldStoreFieldPrecise, TestAncestorStoreDescendantRead, TestCaptureEscapedStoreReconsult, TestCaptureCrossFnWholeValueReconsult. Still-flagging over-report (whole / method read): TestCaptureLiveRereadPrecision; the whole-copy sibling over-report: TestWholeCopySiblingOverReportDisclosed.
7Scope and residuals
MEASURED framing: 1.30.1 makes a DIRECT field store field-precise on the (root, field-path) channel for a store rooted directly at the binding, both cross-function and through a capture re-read, so a disjoint sibling read stays clean. It closes no leak and removes no coverage. It does nothing more.
Stays flagged (no leak dropped, MEASURED where noted): a read of the stored path (verified, section 6), a whole or getter read of the struct, passing the whole struct to a callee that sinks the stored path, and a store at an interior node read back through a descendant path.
Open residuals:
- A whole or method read of a captured struct still over-reports. A closure reading the captured root through a method receiver (bag.reveal()), a bare use, or an argument cannot be resolved to a field path, so it still observes every taint on the root and still flags a clean sibling. A sound over-report, never a missed leak. Pinned in TestCaptureLiveRereadPrecision.
- An aliased, renamed or over-long root sibling still over-reports. A store the field-precise channel cannot resolve keeps the conservative whole-value carrier, so its sibling stays flagged, at parity with the container-mutation case.
- Element- and value-rooted points-to still leaks (pre-existing). A struct held as a Map value or a List / tuple element, mutated through its own binding and read via .get(...) / destructuring, is a different-root points-to residual that leaks on 1.30.0 and 1.30.1 alike; it is not introduced here.
8Cross-references
- Version. 1.30.1 (released 2026-08-11). PATCH under the STABILITY.md patch rule, NOT the security exception the earlier information-flow releases (1.2.0 through 1.30.0) used, because it closes no leak. No security advisory.
- Commits. 7490d6a (field-key the cross-function field-store mutation effect), 24acfae (field-key the cross-function summary content channel), 1580715 (field-precise capture re-read of an in-place field store), 6eb9a10 (observe an ancestor field store on a descendant read), a808c86 (re-consult sym.label for a capture dirtied by an escaped store), 67cea58 (route every non-seed whole-value raise through one choke-point), 2ac52e5 (release).
- Advisories (resolution, no new advisory). The two sound over-reports are recorded as resolved (for the direct field read) in 2026-08-10-ifc-cross-function-whole-struct-read.md and 2026-08-10-ifc-lambda-flow-sensitivity.md.
- Tests. tests/test_ifc_branch_scoped_container.py (section 6).
- Related records. The predecessor security release 1.30.0 (lambda-flow, whose sibling-read over-report this removes for the direct field read), and the successor security release 1.31.0, which consumes this field-store access-path channel to close the locally-resolved capture-internal sink.