Skip to content

feat(model): Make passthrough models more precise - #315

Open
misonijnik wants to merge 65 commits into
misonijnik/3-rulesfrom
misonijnik/4-config
Open

feat(model): Make passthrough models more precise#315
misonijnik wants to merge 65 commits into
misonijnik/3-rulesfrom
misonijnik/4-config

Conversation

@misonijnik

Copy link
Copy Markdown
Member

No description provided.

@misonijnik
misonijnik force-pushed the misonijnik/4-config branch 4 times, most recently from d196a75 to 00a8534 Compare July 30, 2026 08:57
@misonijnik
misonijnik force-pushed the misonijnik/4-config branch 2 times, most recently from 308e9f1 to 8cf4e17 Compare August 12, 2026 09:24
@misonijnik
misonijnik changed the base branch from main to misonijnik/3-rules August 12, 2026 09:46
@misonijnik
misonijnik force-pushed the misonijnik/4-config branch from 8cf4e17 to 7c83fba Compare August 12, 2026 12:20
@misonijnik
misonijnik force-pushed the misonijnik/4-config branch from 505719e to 37feeb3 Compare August 14, 2026 10:12
@Saloed
Saloed force-pushed the misonijnik/4-config branch from 37feeb3 to 9deaef4 Compare August 17, 2026 19:25
@misonijnik
misonijnik marked this pull request as ready for review August 17, 2026 21:28
@misonijnik
misonijnik force-pushed the misonijnik/4-config branch from 9deaef4 to 1d2f127 Compare August 18, 2026 15:13
@misonijnik
misonijnik force-pushed the misonijnik/4-config branch from 1d2f127 to 2706f72 Compare August 18, 2026 15:45
@Saloed
Saloed force-pushed the misonijnik/4-config branch from 2706f72 to 956ac26 Compare August 19, 2026 22:10
resolveArrayPosition was the last implicit type-triggered array mechanism: it
silently gave every array- or Object-typed source ASSIGN position an element
twin. The star operator expresses the same thing from the rules, and does it
better -- the any-field star is recursive, so it also catches the deep
Map<String,String[]> flows the element-only twin missed.

Array and vararg sink args are now starred explicitly, the implicit sink
any-field emission is gone, and the Go side drops its blanket any-accessor
emission in favour of explicit variadic taint in the Go model config.
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.
…let models

JIRMethodGetDefaultProvider gave every library method named get* an implicit
this->result passthrough. That heuristic is far too broad, and with the starred
servlet source rules in place it is also unnecessary: the accessors that
actually propagate taint are now modelled explicitly, for both the javax and
jakarta namespaces, together with the passthroughs the whole-object servlet
source rules read back.

Removing it is what makes this batch's rule tests clean -- without it the
bean-injection and trust-boundary-violation negative cases both report.

Measured on OWASP BenchmarkJava at this batch's tip: total=4112, unchanged from
main. The star migration and this removal net to zero traces between them; the
reduction to 2633 comes from the passthrough config precision in the next
batch.
Gives the branch's new passthroughs exact variadic slots and named servlet
virtual fields instead of whole-object models, and drops the element-star
entries the star operator made redundant.

Element->whole collapses are restored where the target type is incompatible,
since dropping those loses the taint the collapse was carrying.
Makes the multi-property bean passthroughs field-sensitive and gives precise
models to the remaining 22 beans that were still leaking taint whole-object,
completing the bean cleanup.
…e base

All 344 edges had an identical base twin, so this is pure dedupe: every
reader (charAt, substring, toString) already read `this` alongside the
slot, and every writer already wrote it. Java querylang suite green,
including the phase3 char[] append coverage.
Four orphan halves in java-util-regex: appendTail wrote
AbstractStringBuilder/StringBuilder/StringBuffer #content# slots that nothing
reads, and a pattern() entry read a Matcher#pattern slot that nothing writes.
Every one of those flows is already carried on the bare base by a sibling
exact-signature entry, and config applies all matching entries, so deleting
the dead halves changes no propagation. Clears the I2 gate on the file.
applyPattern routed the pattern string into
symbols.internationalCurrencySymbol -- a wrong-slot over-approximation
(applying a pattern does not set the currency symbol), the same shape this
branch removes elsewhere. Also made the private applyPattern(String,boolean)
overload field-sensitive for consistency with the public one, though being
private and unreachable past the modeled public entry it was already inert.
Both behavioural suites stay green.
… leak-generating pattern

A no-lost-entries audit against origin/main (per-method base-pair reachability)
found DecimalFormatSymbols#setCurrency and #getLocale propagated taint via the
old set.+/get.+ wildcards but had no exact entry after the split -- a genuine
lost capability, invisible to OWASP and the e2e suite because nothing exercises
them. Worse, a surviving {set.+} pattern had been mutated to write arg(0) into
the single .currency slot, so EVERY setter tainted .currency and setNaN etc.
leaked out of getCurrency. Replaced it with an exact setCurrency->.currency
writer and a getLocale->.locale reader; every public setter now keeps its
arg->this base-pair (verified) and no setter cross-writes .currency.
DecimalFormatSymbols#getInstance scoped as a whole-object factory in the lint
allowlist, as its DateFormatSymbols twin already was.
… reads

Map#entrySet writes taint into .java.util.Map$Entry#Key and #Value, but neither
getKey nor getValue had a model to read them back, so taint entered an entry and
could never leave. Add both accessors against the vfields entrySet already writes.

LinkedList#getFirst/#getLast copied the whole receiver to the result
(taintCopyOnly: this -> result), so taint anywhere on the list flowed out of either
accessor. Read .java.lang.Iterable#Element instead - which List#add writes, and
which those methods did not previously read at all, so this also closes a channel
rather than only narrowing one. The sibling <rule-storage> reads stay: addFirst
writes that family across LinkedList/Deque/Collection/Iterable.

Conductor: 287 findings, identical set to the default-get-disabled baseline.
Pure rename: 1572 accessors renamed, no copy block added or removed.

Each carrier accessor takes the name of what it actually holds, resolved in
three steps. Where the same entry already has a precise copy on the same owner,
the carrier reuses that field - so JobConf#setJar's carrier line becomes a second
write to 'jar' instead of a channel into every getter, and the writer x reader
cross product collapses without dropping a single model. Owners that conflate
several properties get a per-method name, which is how HttpMethodBase stops
letting setPath reach getResponseBody. Everything else is a single-store type -
builder, buffer, parser, keyed container - and takes one name for the store it
models: buffer, content, entries, elements, path, or a per-type word where one
fits (UriBuilder#uri, MapMessage#body, ObjectNode#fields, AxisFault#fault).

No <rule-storage> remains. The only bracketed names left are <string-bytes> and
<serialized-value>, both on the UNROLLED_SYNTHETIC_SLOTS allowlist.
Model half of 0c5311e6a; the cli test fixture stays with the approximation work.
…batch

Verified against 3-rules with rule identity taken as the entry-level flags plus
the edge, field names normalised so a rename is not counted as a deletion:
zero rules lost. 14220 before, 15025 after.

Two keying bugs had to go first. Keying entries by (function, signature) merged
sibling entries that share a name - java.net.URL#<init>, MessageFormat#<init>,
CompoundName#<init> - dropping 449 edges. Keying by function alone also merged
entries that differ only in taintCopyOnly, which silently changed semantics: the
go builtin append had one entry with taintCopyOnly carrying arg(0)[*] ->
result[*] and a separate entry without it carrying arg(1) -> result, and merging
them dropped the flag and handed its copy the other entry's behaviour. Entry
identity is now everything except the copy list, so rules differing in any flag
stay distinct.

The deletions came from two families. "collapse the ... rule-storage slot onto
the base" kept the whole-object twin and dropped the field-sensitive edge; with
slots now carrying semantic names there is nothing to dedupe, so both edges
stand. "delete the implicit array-element mechanism" removed element-to-element
models, which are the shape the type filter wants - same element type in and
out, rather than a container copied into a differently-typed one.

No <rule-storage> remains, no entry has an empty copy list, and taintCopyOnly
occurrences match 3-rules exactly at 1095.
Thirty-four passThrough entries key their signature on a leading-dot primitive
array name (`type: .byte[]`, `.char[]`, `.int[]`, ...). A scalar type in a
signature deserialises to SerializedSimpleNameMatcher.Simple, whose match is
exact string equality (TypeNameMatching.kt:33), so `.byte[]` never matches a
parameter declared `byte[]` and every rule keyed on one is dead.

The dead rules are the ones that carry data into a caller-supplied buffer or
parse a byte[] payload, so the gap is a plain false negative:

  java.io.InputStream#read(byte[])            this -> arg(0)
  java.nio.ByteBuffer#get(byte[])             this -> arg(0)
  java.nio.CharBuffer#get(char[])             this -> arg(0)
  java.lang.String#getBytes(int,int,byte[],int)   this -> arg(2)
  java.lang.String#getChars(int,int,char[],int)   this -> arg(2)
  ObjectMapper#readValue(byte[], ..)          arg(0) -> result
  JsonFactory#createParser(byte[])            arg(0) -> result

Pre-existing: 3-rules carries the same 34 occurrences.
A field accessor is identified by the whole triple (className, fieldName,
fieldType) - FieldAccessor is a data class over all three (Accessors.kt:96). So
`.java.text.MessageFormat#pattern#java.lang.Object` and
`.java.text.MessageFormat#pattern#java.lang.String` are two different slots, and
a write into one is invisible to a read of the other.

The <rule-storage> rename in this batch gave the write side and the read side of
twenty slots different value types, which silently breaks each of those flows.
Every one of them is a slot that exists in 3-rules with a single consistent
spelling, so this is a regression introduced by the rename:

  DateFormatSymbols#localPatternChars, DecimalFormat#pattern,
  DecimalFormatSymbols#currency, MessageFormat#pattern, StringJoiner#delimiter,
  Matcher#input, Name#components, Reference#{className,factoryClassName,
  factoryClassLocation}, BasicControl#oid, Control#oid,
  ExtendedRequest#encodedValue, Rdn#type, SortKey#matchingRuleId,
  DOMResult#systemId, Resource#{file,url}, HttpHeaders#headerMap,
  NativeMessageHeaderAccessor#nativeHeaders

Each orphaned spelling is re-keyed onto the live one, preferring the precise
declared type. The whole-object twin restored in ae9d09995 is what kept these
flows alive end to end, which is why the shipped rule-tests never saw them.
Two gaps found by the new passthrough regression samples.

java.io.Reader#read(char[]) and #read(char[],int,int) only copied
`this -> result`, but the result is the int character count - a mark on a
primitive is rejected outright (JIRFactTypeChecker.kt:87), so the model carried
nothing. The data goes into the array argument, which is how the InputStream
twin is already modelled. Same for #read(CharBuffer). BufferedReader#readLine
had no model at all, only #lines.

java.nio.Buffer#{flip,rewind,clear,mark,reset,position,limit} had no model, so
the standard put/flip/toString idiom lost the buffer contents at flip(); slice,
duplicate, compact and asReadOnlyBuffer were already covered.

Both gaps are pinned by security.passthrough.PassthroughValueFlowSamples
(stringReader*, charBuffer*).
The restore pass normalised field names but not value types, so re-adding an
edge that already existed under its new name produced a byte-identical second
copy inside the same entry. Copy actions accumulate
(MethodClassTaintRulesStorage.findRules), so a repeated edge is a no-op that
only makes the model harder to read.

395 duplicates removed across 40 files; each file is verified by re-parsing and
comparing the set of edges per entry, so the change is edge-preserving.
`.java.lang.String#<serialized-value>#java.lang.Object` hangs off arg(0), so the
accessor is only accepted when arg(0) may be a String. On the byte[], Reader and
InputStream readValue/readValues overloads the base is unrelated to String and
JIRFactTypeChecker rejects the path, leaving a dead copy next to the live
whole-object `arg(0) -> result` edge that actually carries those overloads.

Ten such reads removed from jackson-databind. Three of them only became
reachable once the byte[] signatures started matching, the rest were already
dead in 3-rules.
The shipped rule-tests exercise the sink and source rules, not the library
models behind them, so a passthrough slot could be renamed, re-typed or dropped
without a single test moving. These samples close that gap: each unsafe method
walks one modelled call chain from a request parameter into Runtime.exec (or a
file sink), and its safe twin walks the identical chain over a constant, so a
broken model shows up as a false negative and an over-broad one as a false
positive.

Covered: AbstractStringBuilder#content (String and char[] overloads),
StringBuffer#insert, String#format's boxed varargs element, StringJoiner
(element and delimiter), Matcher#input via group() and appendTail(),
MessageFormat#pattern, ByteArrayOutputStream#buffer, Reader#content,
ByteBuffer/CharBuffer, BasicControl#oid, Rdn#type, SortKey#matchingRuleId,
Reference#className, HttpHeaders#headerMap, the java.io.File path slot across
seven constructor/accessor pairs, and the eleven explicit HttpServletRequest
accessor models that replaced the engine's implicit get* passthrough.

Two of these started red - Reader#read(char[]) and the nio flip() chain - and
are green as of the model fixes in this batch.
2633 was measured at 2e67c6f, six commits before ae9d09995 restored the
passthrough rules the batch had deleted, and was never re-measured afterwards.
The gate as committed fails.

Measured locally on one BenchmarkJava checkout with two analyzer jars built from
the same rules and differing only in model/:

  3-rules  TraceGenerationStats(total=4338, simple=503, generatedSuccess=3835)
  4-config TraceGenerationStats(total=4338, simple=503, generatedSuccess=3835)

The batch is trace-neutral, so the expectation goes back to the 4112 that
3-rules gates on upstream BenchmarkJava (the local corpus is the explyt fork,
which runs +226 over upstream - only the delta transfers, and the delta is zero).
A slot is identified by the whole triple (className, fieldName, fieldType), and the
value type also becomes the base type for whatever hangs off the slot
(JIRFactTypeChecker.accessorActualType). java.lang.Object is the one spelling that
is never rejected: a base mark copied into the slot always survives, an element or
field accessor may still hang off it, and the write side and the read side cannot
drift into two different slots the way twenty of them did in this batch.

1867 positions across 50 files; the only exception is
java.lang.String#<string-bytes>#byte[], which the engine constructs with that exact
triple (TaintEvaluator.kt:72) and the config has to keep spelling the same way.

Supersedes the direction of 3c7a4d64b, which unified the twenty split slots onto
their precise declared types instead. 35 copies became duplicates of an existing
edge once the types matched and were dropped; verified edge-preserving per entry.
…ound

Three models were missing an edge that the container/serialization regression
samples exercise:

  String#join(CharSequence, Iterable)      element -> result was missing, so the
  String#join(CharSequence, CharSequence[]) joined elements never reached the result;
                                           arg(*) -> result only carries a whole-object
                                           mark, and an element mark re-roots as
                                           result.Element, which no scalar sink sees.
  Properties#store/storeToXML/load/loadFromXML  not modelled at all, only #list.
  Yaml#dump/dumpAs/dumpAsMap/dumpAll/serialize  only the load half existed; the dump
                                           half now writes the same <serialized-value>
                                           slot the load half reads, so a dump/load
                                           round trip on one Yaml instance is bridged.

Samples: security.passthrough.PassthroughContainerSamples (arrays incl. copyOf and
System.arraycopy, List get/iterate/copy/unmodifiable/toArray/join/stream, LinkedList,
Set, Map value/key/values/entrySet, Properties) and PassthroughSerializationSamples
(jackson write/read/read-bytes/readTree/convertValue, snakeyaml dump/load,
Properties store), each unsafe method paired with a constant-fed safe twin.

Two cases are deliberately not asserted, both structural rather than model bugs:
serialising a bean whose *field* is tainted (the mark lands on json.name, not on
json, and collapsing it needs an AnyField read), and ObjectOutputStream writing
through to the ByteArrayOutputStream it wraps (aliasing, not a passthrough edge).
…ainers

The leading-dot signature fix revived seven models that copy into a caller-supplied
array rather than into the result, and nothing pinned them. Added: String#getChars,
ByteBuffer#get(byte[]), CharBuffer#get(char[]) and a servlet doing
getInputStream().read(byte[]).

Container coverage extended to the bulk and nested moves: Map#getOrDefault,
Map<String,List<String>> two hops deep, List#addAll, Collections#addAll,
Map#putAll, Collectors#joining and Deque#push/poll.
The collector collapses the stream elements into one String, and the model would have
to know which collector reached collect(..) to express that. Modelling it at the
Stream#collect level instead would put an element mark on the root of every collected
container, which is the kind of over-approximation this batch is trying to remove, so
the sample stays as documentation and the gap stays open.
ae9d09995 put back every edge the batch had deleted, on the theory that a deletion is
a lost rule. Most of those edges are the whole-object twin the batch had deliberately
replaced with a field-sensitive slot, so restoring them re-created the imprecision the
batch set out to remove - and it is what pushed the OWASP trace count back up, which is
why EXPECTED_TRACES had to be raised.

Measured on one BenchmarkJava checkout, analyzer jars differing only in model/:

  before the restore (2e67c6f)  traces 2859  TP 1286  category-FP 559  off-category  399
  after the restore  (ae9d09995)  traces 4338  TP 1286  category-FP 559  off-category 1385
  this commit                     traces 2859  TP 1286  category-FP 559  off-category  399

The restore buys no true positive - TP is identical at 1286 across every state - and
costs ~986 extra findings on test cases whose vulnerability is of another category,
i.e. noise. Dropping it puts the trace count back on the 2633 the branch gates on
(2859 here; the local corpus is the explyt fork, which runs +226 over upstream).

1910 edges removed by shape: 656 whole->slot, 599 slot->whole, 427 whole->whole,
142 element->slot, 60 element->element (the Go slices folds, redundant because the
whole copy already carries element to element), 26 others. 218 entries were left with
an empty copy list and removed with them.

The rule-tests are the guard on the other side: 677 pass with 0 false negatives,
including the 130 new passthrough samples added in this batch, so no modelled flow in
the suite depended on a restored edge.
Measured after dropping the blanket restore: 2859 traces on the local fork, which is
2633 on the upstream corpus CI checks out - the number the batch set at 2e67c6f.

Reverts 6d425e62f, which raised it to 4112 on the evidence that the batch as committed
produced 4338 locally. That measurement was right about the committed tree and wrong
about what to do with it: the fix was to take the restore back out, not to accept its
trace count.
Written test-first: PassthroughValueFlowSamples#stringFormatLocaleUnsafe and
PassthroughFileModelSamples#fileSystemGetPathUnsafe both failed before this change
and pass after it.

A whole copy X -> Y re-roots X's subtree at Y, so X[i] lands as Y[i]; the type checker
keeps that path only where Y may hold elements. Where the destination is a scalar the
element facts are dropped unless an explicit [X,'[*]'] -> Y copy carries them. Auditing
every (function, signature) group for an array-typed source flowing into a scalar
destination with no element edge anywhere in the group found 21 such holes:

  String#format(Locale, String, Object[])   the varargs box - the plain (String, Object[])
                                            overload has the carrier, the Locale one did not
  FileSystem#getPath(String, String[])      every trailing path segment
  String#copyValueOf(char[]) and (char[],int,int)
  Spliterators#spliterator - eight array overloads (Arrays#spliterator already had it)
  JsonGenerator#writeBinary, ObjectBuffer#completeAndClearBuffer, String#charAt
  kotlin StringsKt append(Appendable|StringBuilder, array)

Also pinned on the precision side, all three passing as negatives: a mark on a
primitive result (String#length), a map value reaching the key side, and a map key
reaching the value side.

Checked and deliberately not touched: 127 copies whose destination is a primitive are
not dead - a mark on a primitive is accepted when it carries the primitive-tracking
mode, which rules opt into with `primitive-tracking: true`.
CallPositionToJIRValueResolver resolves an Argument position with
callExpr.args.getOrNull(index), so a copy naming arg(0) on a zero-argument overload -
JspWriter#println(), SelectItemGroup#<init>(), JsonGenerator#writeStartObject() and
friends - resolves to nothing and can never fire. They come from per-overload signature
splits that left the whole copy list on every overload.

Deadness is decided from the entry's own signature, never from a library jar, and only
where that signature is the exact string form: SerializedSignatureMatcher.Simple
enforces `method.parameters.size == args.size`, so `signature: () void` binds to
zero-argument methods whatever version of the library is on the classpath.

A partial `{params: [{index, type}]}` matcher constrains only the listed indices and
says nothing about the total arity, so a higher arg(N) there is NOT provably dead.
Three such entries are deliberately untouched - AxiomSoapMessage#<init> really does
take (SOAPMessage, Attachments, SOAPFactory, ..), and oro's Util#split /
Util#substitute really do take a trailing int.

61 copies removed across 5 files, each verified out of range against its own exact
signature; entries left with an empty copy list go with them. Rule-tests unchanged at
685 pass / 0 FN / 0 FP.
A String is immutable and scalar, so its taint belongs at the String itself, not in a
bytes/chars/content sub-slot. Two things follow from the sub-slot that this removes:

- It is invisible where it matters. A plain ContainsMark sink reads the mark *at* the
  position, so an edge like `-> result.java.lang.String#content` never reached one; the
  whole-object twin sitting next to it was doing all the work.
- It is what forces the engine to special-case cleaning. A depth-one sanitizer clean
  clears the String but not `str.bytes`, so the next getBytes() reads the taint straight
  back out - which is why JIRTaintCleanActionEvaluator carries a hardcoded String bytes
  accessor and a `todo: fix in config?`. With the slots gone that special case has
  nothing left to clean; removing it belongs to the engine branch.

63 modifiers flattened onto their position, across the String constructors,
getBytes/getChars/toCharArray/copyValueOf/valueOf/subSequence, Matcher#group/
replaceAll/toString, Base64$Decoder#decode, StringJoiner#toString, Normalizer#normalize,
CachedRowSet, TextMessage/BinaryMessage#toStringPayload, ByteArrayResource and
ExtendedRequest#getID. Most collapse into the whole-object twin that was already there.
Nested slots (result.CharBuffer#value.String#chars) stay - no clean ever targets those.

Pinned from both sides: SsrfSamples$UnsafeBytesRoundTripProxyServlet - a
new String(byte[]) / getBytes round trip must still carry taint - and
XssServletSamples$SafeGreetingBytesRoundTripServlet - the same round trip after a
sanitizer must stay clean.
Measured against ThingsBoard, a Spring Boot 3 project: 40 reference-returning servlet
accessors had no model at all, and the same 41 were missing on the javax side, so this is
a coverage hole in the servlet model rather than a jakarta parity bug. What covered them
instead was the engine's default get model, which copies the whole `this.<get-default>`
subtree onto the result - it cannot tell "reads request data" from "returns the container",
so `getServletContext()` inherited the request's entire mark tree exactly like
`getAttribute()` did.

Two kinds of entry, on both APIs:

- Attribute store/read pairs on ServletRequest and HttpSession. setAttribute writes what
  getAttribute reads back, through named `attributes` and `attribute-names` slots. The
  session pair earns its place on its own: the trust-boundary rule deliberately excludes
  getSession from being a source, since the session is trusted-side state, so the only
  thing that can make a session read tainted is an earlier store - which is precisely what
  the slot models and what nothing modelled before.

- Client-influenced request metadata: scheme, server name, remote address/host, protocol,
  content type, character encoding, context path and method, each from its own slot.
  Nothing writes these, so they carry a mark only from a starred request source, and then
  as a single leaf rather than as the request's whole tree.

The jakarta file additionally receives the 21 entries that existed only on javax - Cookie,
PushBuilder, ServletRequestWrapper#<init> and the two ServletResponse output accessors -
each checked against the jakarta 6.0 API before being modelled. Both files now hold 59
entries and are namespace-symmetric.

Pinned from both sides: RequestAttributeRoundTripServlet and
SessionAttributeRoundTripServlet as positives, and SafeAccessorsServlet extended to
exercise both attribute calls with constants, so the slots may not leak into an unrelated
read.

Rule tests 689 pass / 0 FN / 0 FP / 0 skipped (was 687). OWASP unchanged at 2859 traces
locally, TP 1286 / FP 559 / off-category 399 - on this branch the default get model still
fires unconditionally, so these edges are additive here; they only replace it on 4.5,
where the model is gated on no rule having matched.
java.util.List#get was the one unmodelled method carrying taint through the
default get model on conductor: it accounted for 1047 of the 1053 facts that
model produced, across only three methods.

The default get model reads a virtual this.<get-default> field that nothing
writes, so it only matches a receiver whose node is abstract - it is the
fallback that fires exactly where precision was already lost. Reading the
element slot instead states the real flow: an element goes in through the
collection writes we already model, and List#get takes it out.

overrides: true so the rule reaches ArrayList and the other implementations
rather than the interface alone.
@Saloed
Saloed force-pushed the misonijnik/4-config branch from 956ac26 to 48f5131 Compare August 19, 2026 22:20
`c6e707724` dropped `.java.lang.String#<serialized-value>#java.lang.Object` from all ten
`ObjectMapper#readValue` overloads. Its reasoning -- the accessor hangs off arg(0), so it
only type-checks when arg(0) may be a String -- is right, and it holds for nine of them.
The tenth *is* the `java.lang.String` overload, where the base is a String and the read
does type-check.

Without it a tainted JSON string reaches `readValue` carrying its mark at
`.java.lang.String#<serialized-value>#java.lang.Object` and the deserialized object comes
back clean, because the surviving whole-object `arg(0) -> result` edge does not reach
through that accessor (hertzbeat).

Restores that one action only; the other nine stay removed. Verified: the deserialization
shapes go from 13/18 to 18/18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant