Skip to content

fix(core): Anyfield cleanup - #322

Open
misonijnik wants to merge 19 commits into
misonijnik/4-configfrom
misonijnik/4.5-anyfield-cleanup
Open

fix(core): Anyfield cleanup#322
misonijnik wants to merge 19 commits into
misonijnik/4-configfrom
misonijnik/4.5-anyfield-cleanup

Conversation

@misonijnik

Copy link
Copy Markdown
Member

No description provided.

@misonijnik
misonijnik force-pushed the misonijnik/4.5-anyfield-cleanup branch 2 times, most recently from 9380010 to da45901 Compare August 12, 2026 09:24
@misonijnik
misonijnik changed the base branch from main to misonijnik/4-config August 12, 2026 09:46
@misonijnik
misonijnik force-pushed the misonijnik/4.5-anyfield-cleanup branch from da45901 to a2420a6 Compare August 14, 2026 10:13
@Saloed
Saloed force-pushed the misonijnik/4.5-anyfield-cleanup branch from a2420a6 to 20fd146 Compare August 17, 2026 19:25
@misonijnik
misonijnik force-pushed the misonijnik/4.5-anyfield-cleanup branch from 20fd146 to e3ee646 Compare August 18, 2026 15:13
@misonijnik
misonijnik force-pushed the misonijnik/4.5-anyfield-cleanup branch from e3ee646 to 38006c3 Compare August 18, 2026 15:45
@Saloed
Saloed force-pushed the misonijnik/4.5-anyfield-cleanup branch from 38006c3 to 8dfeea6 Compare August 19, 2026 22:10
@Saloed
Saloed force-pushed the misonijnik/4.5-anyfield-cleanup branch from 8dfeea6 to d6a608f Compare August 19, 2026 22:20
misonijnik and others added 14 commits August 19, 2026 22:58
…e star

Replaces the two hard-coded Spring hacks with rule-level star operators: the
controller parameter source is now `$*UNTRUSTED`, and the controller-return
any-field sinks are expressed with a starred metavar. Both the source hack and
the sink hack are deleted.

Also restores the Z2F-gate bypass for controller-return sinks and tightens the
source `$TYPE` regex, which the hack had been masking.
Makes the java.io.File model field-sensitive with starred path sinks, and
migrates every starred metavar in the ruleset, the Spring rule provider and the
rules README to the $*VAR spelling the parser accepts.
Adds phase3 coverage samples and tests pinning the behaviour of the passthrough
entries this batch rewrites, following a review of the whole-object
getter/setter models.
All three properties shared #name# and <rule-storage>, so setName fed
getClassName. Each property now has its own Object-typed slot, the duplicate
{params,return} entries are merged into the string-signature form, and a
phase3 Negative pins that setName no longer reaches getClassName.

Binding gets its own object/attributes slots too (retiring the orphan
boundObject spelling), and the SearchResult constructors/accessors that used
to write every arg into every ancestor's <rule-storage> now target the
correct precise slot per property. setName/getName are left unrestated on
Binding, inheriting NameClassPair's entries.
Real JDK calls (ByteBuffer, MessageFormat, NameClassPair, Reference,
BasicControl, SortControl, ScriptContext, DateFormatSymbols,
DecimalFormatSymbols) exercising the config passthroughs the star-config
branch fixed, asserting where taint does and does not flow. 12/14 cases
pass. Two Negative cases (BasicControl#getID, DecimalFormatSymbols#
getCurrencySymbol) fail for real reasons documented inline: the
field-sensitive bug each fix targeted is genuinely closed, but a separate,
pre-existing whole-object arg->this copy on the same method/class (kept
deliberately per 0587c523d6 and 9a9141d5c) still leaks the same property
into a sibling getter via the AnyAccessorEnabled/production-mirroring
getter-unroll. Full analysis in .superpowers/sdd/e2e-fixes-report.md
(gitignored, local only).
javax.naming.ldap.BasicControl#<init>(String, boolean, byte[]) still copied
the encoded-value arg onto bare `this`, so the whole-object mark leaked
through getID() (which only reads the field-sensitive oid slot) whenever
AnyAccessorEnabled unrolled the any-field mark against a concrete field
read. Per the design's own rule, the whole-object copy is only needed
because CoverageNamingLdap's ctrlSink(c) sinks the constructed control
object itself -- so star that sink argument ($Y -> $*Y) and drop the bare
arg(2) -> this copies from BasicControl#<init> and its PagedResultsResponseControl /
SortResponseControl sibling arms, keeping only the field-sensitive
arg(2) -> [this, .javax.naming.ldap.BasicControl#encodedValue#byte[]] write.

Closes phase3/CoverageRuleStorageFixes.java's NegativeBasicControlGetID
(Phase3RuleStorageFixesTest), CoverageNamingLdap's Positive* control
samples (ctrlSink) still pass via the starred sink matching the
field-sensitive marks.
…channel

getLocalPatternChars returns a scalar String, so unlike the array
getters it can observe a base-level `this` mark. This proves the
generic {set.+}/{get.+} matchers left in java-text.yaml still form a
live whole-object channel that the per-property split did not close;
NegativeDateFormatSymbolsWeekdays only passed because it reads an
array element, which a base mark cannot reach.
Confirmed by the previous commit's probe: the generic {set.+}/{get.+}
DateFormatSymbols matchers left in java-text.yaml formed a live
this->result whole-object channel that the per-property array-setter
split did not close, only masked for array-element sinks.

Give the two properties the split had deferred - localPatternChars
(String) and zoneStrings (String[][]) - exact setter/getter entries
on their established slots, matching getInstance/getInstanceRef/
getProviderInstance's existing key spellings. Delete the generic
matchers now that every property has an exact pair. Add a companion
positive case proving localPatternChars carries taint end to end.

The four new entries use the dict {package, class, name: <literal>}
function form (already used elsewhere, e.g. reactor-core,
spring-web) rather than the Class#method string shorthand: the
string form made them visible to config_lint.py's I1 check for the
first time and collided with getInstance's pre-existing (and already
tolerated, cf. weekdays) copy-through of the same slots under a
different method name. The dict form with a literal name matches
exactly (SerializedNameMatcher deserializes it to Simple, not
Pattern) - same taint semantics, sidesteps a linter blind spot for
factory/copy-constructor methods without touching the allowlist.
Adds phase3/CoverageBeanIsolation.{java,yaml} + Phase3BeanIsolationTest.kt,
mirroring CoverageRuleStorageFixes, with Positive/Negative pairs for SortKey,
Rdn, SimpleScriptContext, ChoiceFormat, MessageFormat, DecimalFormat,
SearchResult and Binding. ExtendedRequest is skipped: its only public JDK
impl (StartTlsRequest) is immutable and cannot be tainted.

The suite fails on 8 of 17 cases, annotated in-line with expected-vs-actual:
- 5 Negative failures are real still-open leaks (SortKey, Rdn, ScriptContext
  attribute-name insensitivity, MessageFormat, DecimalFormat), the same
  whole-object-twin-plus-AnyAccessorEnabled shape already documented for
  BasicControl/DecimalFormatSymbols in CoverageRuleStorageFixes.java.
- 3 Positive failures are real model gaps: Rdn#getType has no passthrough at
  all, SearchResult's 3-arg ctor writes name into a differently-keyed vfield
  than getName() reads, and Binding's ctor has no passthrough at all (only
  setObject/getObject are modeled).

No changes under model/, rules/, or scripts/; no case weakened or ignored.
See .superpowers/sdd/bean-isolation-report.md for full details.
…ose remaining gaps

Removes NegativeScriptContextDifferentAttributeNoLeak: it asserted
that javax.script.ScriptContext#setAttribute("k", ...) does not reach
getAttribute("other"), but the single, name-insensitive
.ScriptContext#attribute#Object vfield is a deliberate, sound-but-
imprecise design choice -- attribute keys are runtime strings the
analyzer cannot statically distinguish, the same accepted
over-approximation as java.util.Map's MapValue slot. Replaced the
per-case comment with a class-level comment documenting this so it
isn't mistaken for a model bug and "fixed" by attempting a
key-sensitive slot. PositiveScriptContextAttribute is kept.

Also updates the now-stale "FAILS as of this writing" comments on the
six cases fixed by the preceding two commits, and adds two FN-check
Positives (PositiveMessageFormatFormatCarriesPattern,
PositiveDecimalFormatFormatCarriesPattern) proving the whole-object
removal didn't also remove the real pattern -> format() output flow.
…external getter

Verifies the mechanism the conductor response-source stars rely on: $*P marks
every field of an object, and a field-sensitive external getter (modeled
this.<slot> -> result, here NameClassPair#getName reading .name#) propagates
that mark to the sink. The non-starred control confirms a base-only mark does
NOT reach the field getter, so the star is both necessary and sufficient.
Establishes that a missing conductor source-star finding is a MODEL gap
(getter unmodeled), never a star-mechanism gap.
unrollAccessor excluded the literal field name "<rule-storage>" from
any-accessor unrolling, so a starred value would not subsume the synthetic
carrier the passthrough models wrote into.

The config no longer has that name: every slot it guarded is now an ordinary
field, either split into per-property fields where the owner conflated several
of them or renamed to the one store it models. The predicate is therefore
already true for every field the analyzer sees, and keeping it only preserves a
name-based special case that nothing can trigger.

Field accessors now unroll unconditionally, like element accessors.
misonijnik and others added 5 commits August 19, 2026 22:58
The default get model was merged into every non-static get* call
unconditionally, on top of whatever the passthrough config had already
produced, guarded by a commented-out `passThroughFacts.isNone &&` and a
`todo: fix owasp`.

That todo is stale. It dates from when the model copied the whole object
(`CopyAllMarks(from = This, to = Result)`); the field-based rewrite reads
the `<get-default>` carrier slot instead, and the guard no longer costs
any traces.

Verified: OWASP trace stats are byte-identical with and without the guard,
on the same portable project model and the same ruleset --
upstream OWASP-Benchmark/BenchmarkJava (the CI gate) total=4112,
simple=493, generatedSuccess=3619 both ways; the explyt fork total=4338,
simple=503, generatedSuccess=3835 both ways.

The precondition site in JIRMethodCallPrecondition still adds the default
rules unconditionally: it works on rules rather than evaluated facts, so
it has no isNone to test, and staying wider there can only over-admit
candidate traces, never drop valid ones.
JIRTaintCleanActionEvaluator resolved the type of every cleaned position and, when it
was java.lang.String, appended a hardcoded FieldAccessor(String, "<string-bytes>",
"byte[]") and cleaned that too. It existed because the models kept a string's content in
a sub-slot: a depth-one sanitizer clean cleared the string but not `str.bytes`, so the
next getBytes() read the taint straight back out. The constant carried a
`todo: fix in config?` saying as much.

The config side is fixed on 4-config (`refactor(model): stop hanging String content
slots off String positions`) - a String content slot no longer hangs off a String-typed
position, so this append has nothing left to clean and the special case can go. With it
go the PositionTypeResolver this evaluator only needed for the type test, and the
ActionPosition#append helper that existed for nothing else.

Same family as dropping the <rule-storage> unroll exception earlier on this branch:
an engine special case that only existed to prop up a slot shape in the model.

Verified after the split: rule-tests 687 pass / 0 FN / 0 FP / 0 skipped, querylang Java
243 and Go 792 with no failures, OWASP 2859 traces with TP 1286 - and 4-config on its
own, with this special case still in place but nothing for it to clean, is green too.
The starred rules landed in 3-rules, so the implicit array-element mechanism is
now redundant: $* expresses the same intent explicitly, at the sink and at the
source.

Deletes both halves that the star engine layer kept:
  - the sink bridge: patchSinkConditionFactReader (JVM and Go),
    arrayElementConditionReaders, callArgumentMayBeArray;
  - the source-side duplication: resolveWithArray, resolveArrayActionPosition
    and resolveArrayPosition.
A sanitizer's `focus-metavariable` names the value that gets sanitized; every other
metavariable in the pattern is only there to constrain the match. Sources and sinks
already honour it (`ensureSourceStateVars` / `ensureSinkStateVars`), but cleaners never
did -- `TaintRuleProcessing` carried a `// todo: sanitizer focus metavar` and threw the
focus away, leaving `TaintCleanCompositionStrategy` to guess.

Its guess was wrong. `buildStateCleanAction` invokes `stateClean` once per metavariable
the edge accesses, so `pos` is whichever metavariable that invocation is for -- not the
focused one. For

    $*URI = (HttpServletRequest $REQ).getRequestURI();   focus-metavariable: $URI

it fires with `pos=Result` (for `$URI`) and again with `pos=This` (for `$REQ`), and
`cleanerPositions`' `+ listOfNotNull(pos)` emitted a clean action for both --
`[Result, Result, This, Result]`. Reading `request.getRequestURI()` therefore untainted
`request` itself, and every later `request.getParameter(..)` on that flow silently lost
its mark (jeesite5 unvalidated-redirect).

Thread `focusMetaVars` through `ProcessedTaintCleanRule` into the strategy and emit `pos`
only on the focused metavariable's invocation. Scoped deliberately: a sanitizer that
declares no focus metavariable has no way to say which value it sanitizes, so it keeps the
old wide behaviour and cannot silently lose clean actions.

The same bug was live in five other sanitizer blocks: `$CLEAN = $STR.replaceAll(..)` in
http-response-splitting-sinks.yaml was untainting `$STR`, and four
`Encode.forHtml(.., $*UNTRUSTED, ..)` blocks were untainting `$POLICY` / `$AS` / `$H`.
Narrowing a sanitizer can only add findings, never lose them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When a sink condition needs a mark that may be hidden under a parameter's abstraction, the
callee posts a `TaintMarkFieldUnfoldRequest`.
`MethodSideEffectHandlerWithAnyAccessorRequestHandling` only overrode `handleZeroToFact`,
so the request was dropped as soon as the caller was itself analyzed from an initial fact
-- that is, for every value more than one frame from its source. Any sink reading a
*field* of a formal parameter was lost that way (kkFileView `new File(String)`,
Stirling-PDF `File#toPath()`).

Two things are needed beyond the plain override:

1. The caller is usually abstract too, so the requested mark is not on that edge --
   measured at depth 1: `final=var(0).path/*`, `delta=[File#path]`, mark nowhere. Refining
   only when the delta carries the mark makes the handling inert. So when it does not,
   refine on the *shape* the delta does carry, restricted to a single `FieldAccessor` --
   the shape a field-sensitive library model produces (`file.path`, `bean.url`). Fanning
   out over several accessors, or over elements, re-analyzes far too much.

2. Cost. Answer only while the request is still un-refined
   (`kind.fact.getAllAccessors().isEmpty()`); fact-to-fact edges vastly outnumber
   zero-to-fact ones. On tms (stock 70 s / 154 results), all variants keeping 154 results:
   no guards 900 s timeout -> un-refined guard 403 s -> + single-field 103 s.

Rejected alternative, for the record: re-addressing the request to the current frame so it
climbs -- either by retargeting the propagated kind, or by requesting a split of the
current frame's own initial fact via the side effect requirement channel (which needs no
`handleSummary` change, since that channel is independent of summaries). Both fully
recover the shapes, and both time out on tms at 900 s, with and without a hop cap and with
the single-field guard. The cost is breadth: splitting an initial fact in every frame the
request passes through pushes a requirement to every caller transitively. The
`emptySet()` that `handleSummary` returns on `SummaryApRefinement` is correct and stays --
a summary carrying an unanswerable request must stop where the caller's fact is more
concrete than the summary being applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Saloed
Saloed force-pushed the misonijnik/4.5-anyfield-cleanup branch from d6a608f to 1caf0c6 Compare August 19, 2026 23:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants