fix(core, analyzer): Anyfield cleanup - #322
Open
misonijnik wants to merge 26 commits into
Open
misonijnik wants to merge 26 commits into
misonijnik wants to merge 26 commits into
Conversation
misonijnik
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
2 times, most recently
from
August 12, 2026 09:24
9380010 to
da45901
Compare
misonijnik
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
from
August 14, 2026 10:13
da45901 to
a2420a6
Compare
Saloed
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
from
August 17, 2026 19:25
a2420a6 to
20fd146
Compare
misonijnik
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
from
August 18, 2026 15:13
20fd146 to
e3ee646
Compare
misonijnik
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
from
August 18, 2026 15:45
e3ee646 to
38006c3
Compare
Saloed
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
from
August 19, 2026 22:10
38006c3 to
8dfeea6
Compare
Saloed
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
2 times, most recently
from
August 19, 2026 23:10
d6a608f to
1caf0c6
Compare
misonijnik
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
from
August 23, 2026 23:34
1caf0c6 to
7e0480f
Compare
misonijnik
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
from
August 25, 2026 21:25
7e0480f to
12eb15c
Compare
misonijnik
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
2 times, most recently
from
August 27, 2026 16:11
e7170c6 to
c3af243
Compare
misonijnik
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
from
August 27, 2026 16:14
c3af243 to
04def71
Compare
Saloed
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
2 times, most recently
from
September 15, 2026 19:12
5fa3d4a to
f24ea78
Compare
Saloed
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
from
September 23, 2026 11:25
9dc2d7b to
3913251
Compare
…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.
…-storage collapse
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.
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.
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.
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.
The mechanism gave element taint to an array position without a rule. The
starred rules give the same taint, and they give it where the rule author can
see it. Thus the mechanism is not necessary.
Deletes both halves:
- the sink bridge: patchSinkConditionFactReader (JVM and Go),
arrayElementConditionReaders, callArgumentMayBeArray;
- the source duplication: resolveWithArray, resolveArrayActionPosition and
resolveArrayPosition.
This needs the any-accessor fix at the base of the stack. Without that fix a
star loses its taint at a primitive element read, and bad-hexa-conversion
finds nothing.
The array-element mechanism is deleted. A base-only source thus stops at an element read of a primitive array. Only the star continues. Flips the base-only case to assertNotReachable and states the new contract.
The demand was a single map on `AnalysisUnitRunnerManager`, shared by the whole analysis. It is now one map per analysed method, on its `MethodAnalysisContext`, which the side-effect handler factory already received and threw away -- the handler now carries the context and reads the demand off it. This narrows what the filter is allowed to conflate. A request climbs through many frames; each frame it passes through now filters against its own record, so two frames are never made to agree about a question and no state crosses the analysis. Within a method the key is unchanged: the ASKING frame, the base, the mark -- the asking frame stays in the key because it is not in general the frame holding the map. Weaker dedup costs exploration and buys precision, and the findings are unmoved: | | main | before | after | |---|---|---|---| | conductor | 7 (cf 31) | 7 (cf 26), 3.6M events | 7 (cf 26), 4.6M events | | tms | 153 (cf 75) | 153 (cf 63) | 153 (cf 63) | Nothing missing against main on either project. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018R4xeH4QFAH7RrPcKFoQGZ fix(dataflow): stop re-demanding an accessor already demanded for a question Conductor goes from 3 findings to 6, tms from 152 to all 153 of main's, and the change is confined to mark-request handling: one new class plus 21 lines. An unfold request asks one question: is the mark hidden under the `[any]` of this frame's initial fact? Answering it asks the frame to split that abstraction on the accessors the answerer found. The demand for a question only grows, so an answer contributing nothing new has not failed to answer -- it has repeated one already given. Repeating it is not a refinement: the split produces a fact ending in `[any]` again, one accessor further down, which re-raises the same question, and on a self-similar shape -- `CharSequence#content`, or `MapKey`/`MapValue` over erased generics, where the type checker has nothing to stop on -- that has no fixed point. Each round re-abstracts the base's whole accumulated fact tree and re-broadcasts a requirement across the asking frame's transitive callers. On conductor the filter fires on 289,227 of the 292,143 answers offered -- 99% of what the handler was about to demand was a repeat. A saturated demand does not consume the request: it keeps climbing, because a caller further up may hold an accessor nobody has contributed yet, and consuming it here measurably stalls the analysis instead. THE PRECISION GIVEN UP, deliberately: the question is keyed on frame, base and mark -- not on the fact, which is itself the product of earlier answers. Keying on the fact makes every round its own question and the filter a no-op, which is the loop it exists to cut. So `arg0.*` and `arg0.x.*` are one question, a frame already asked to split on `y` is not asked again below `x`, and a flow needing `arg0.x.y` goes unreported. `MarkUnfoldDemandTest` states this as a test rather than a comment. This also removes any need to touch the rules. The global `(Object $*OBJ).toString()` propagator costs conductor 3 of its 6 findings under the unanswered demand (6 -> 3), but none at all once the demand is filtered: 6 with the propagator, 6 without. Its cost was never the rule, it was the rule multiplied by an unbounded demand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018R4xeH4QFAH7RrPcKFoQGZ
Saloed
force-pushed
the
misonijnik/4.5-anyfield-cleanup
branch
from
September 23, 2026 21:36
3913251 to
eeaed05
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.