Skip to content

fix(core): Base only analysis clean - #361

Draft
Saloed wants to merge 23 commits into
mainfrom
saloed/base-only-clean
Draft

fix(core): Base only analysis clean#361
Saloed wants to merge 23 commits into
mainfrom
saloed/base-only-clean

Conversation

@Saloed

@Saloed Saloed commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

No description provided.

Saloed and others added 23 commits August 20, 2026 09:44
Adds a third access-path representation alongside Tree and Automata. A
BaseOnly access is a packed Long carrying three positional slots
(static / field / terminal) rather than a path tree, so a method summary
family collapses to a bounded number of edges no matter how many concrete
field chains reach it.

The shared access layer gains the extension points BaseOnly needs:

- MethodEdgesInitialToFinalApSet.add now returns every stored final whose
  metadata the insertion changed, not just the inserted pair. Tree already
  behaved this way; Cactus and Automata silently dropped deltas when a
  merged exclusion widened.
- addAll inserts many premises against one conclusion without
  materialising a path edge per premise.
- filterEdgesByFinalTo and collectSummariesByFinalTo thread an optional
  full final-fact pattern so an indexed storage can narrow candidates.
- ExclusionSet.Concrete accepts a pluggable persistent accessor set and
  computes its hash lazily.

ConcurrentReadSafeLong2ObjectMap and ConcurrentReadSafeLongSet are the
primitive-long counterparts of the existing read-safe collections, for the
packed-Long summary storages: one writer, many readers, no removals.

Design and conformance notes live in docs/baseonly-*.md.
Several regression samples need Spring and Stirling types on the classpath
but must not pull in the real frameworks. samples-dependency provides minimal
stand-ins for the annotations and response types they reference.
Import reordering, a stray blank line and a missing trailing newline. No
behaviour change; separated so the following commits contain only real edits.
The exception application graph treated every call expression inside a try
region as a thrower and wired it to the region's catchers. Because the method
instruction graph collapses normal and exceptional successors into one
unlabelled edge set, a statement's normal-completion facts were then
propagated into the handler.

Observed on ThingsBoard: a catch parameter receiving the result of a call made
inside the try, and the catch of pushEntityActionToRuleEngine retaining 1,543
entries while rejecting 37,713 duplicate additions.

A catcher is now reachable only from explicit throw instructions and from try
exits. This is an approximation change, not a complete fix: a mid-try call that
genuinely throws no longer reaches the handler, which the new test asserts.
…gine

runAnalysis gains an ap-mode argument and two post-analysis callbacks, and the
harness exposes hooks for the rules provider, the unit resolver and the unroll
strategy. Needed by the regression tests added later in this series, which have
to run the same sample under Tree and BaseOnly and then read engine state.
Two rules reporting at the same sink must not collapse onto one partial
fingerprint. Splits the SARIF result extraction out of generateSarifReport so a
test can request fingerprint generation.
Zero-to-zero edges discover the call graph; fact edges propagate over it.
Interleaving them means facts are propagated across a call graph that is still
being discovered, and the same conclusions are re-derived as more callees
appear.

The per-method worklist now drains zero-to-zero edges first, and an analyzer
with a pending zero-to-zero backlog sorts ahead of the rest of the unit runner
queue. Ordering only; no edge is added or dropped.
… path

Behaviour-preserving reductions on paths taken for every fact at every call
statement:

- TaintRulesStorage.getConfigForMethod was a @synchronized method on one shared
  object, serialising every analyzer thread's rule lookup. The per-method and
  per-pattern caches become concurrent maps and the monitor goes away; a
  negative result is now memoised in the same map instead of a side set.
- The condition rewriter is constructed lazily and true conditions short-circuit,
  so a statement with no conditional rule no longer walks a rewrite.
- ConditionSimplifier.mkFalse returns a shared instance instead of allocating.
- The call flow function looks up cleaner and pass-through rules before building
  the evaluators, and returns early when there are none.
- The summary rewriter returns the original edge by identity when the rewrite is
  provably identity. This matters beyond allocation: the engine's unchanged-edge
  fast paths compare edges by reference.
- EvaluatedCleanAction drops its prev back-pointer, an unbounded chain retaining
  every intermediate cleaner state, and clearPosition de-duplicates its two
  branches.
…sible bridges

Three changes to virtual dispatch resolution:

- methodOverridesCache was keyed on the method alone, but its value depends on
  the constrained base class, so the first base class seen for a method won the
  cache for every later query. Now keyed on both.
- A bridge method whose target's parameter type cannot match any call-site
  argument would always throw in its checkcast, so it is dropped from the
  candidate set. Targets generic hierarchies such as DaoUtil.convertDataList,
  which resolved 78 toData() targets on ThingsBoard.
- A virtual call whose *declared* target is an Object method short-circuits to
  resolution failure. Previously this only applied when the resolved method was
  outside a known unit, so o.toString() on a project class entered that class's
  toString. Evidence: BaseSqlEntity#equals produced 11,540 summaries for 3,404
  steps. This is a deliberate recall trade and is measured in the branch notes.

Re-keying the cache is correct but on its own it makes Spring Data custom
fragment implementations unreachable, and the old key was the only reason they
worked. A fragment impl implements only the fragment interface (ProductRepoCustom
-> ProductRepoCustomImpl); it never implements the repository interface the
receiver is typed as (ProductRepo), and no class in the project implements that
interface at all - Spring synthesises the proxy at runtime. Constrained by the
repository interface, findOverrides therefore returns nothing, and the call to
the fragment method resolves to failure. Under the old method-only key a lookup
of the same method against the fragment interface, performed anywhere earlier in
the run, populated the cache and the repository-typed query silently reused it -
an order-dependent accident, not a rule.

The fallback restores those targets deterministically: when a constrained lookup
of an abstract interface method yields nothing, retry once against the method's
own declaring interface, and only when that interface is itself in the project.
Widening is thus bounded to interfaces the project defines - a library interface
such as CrudRepository never gets widened to every implementor on the classpath.
Virtual dispatch resolution for one statement is a pure function of the method
context, but the later shallow-scan pruning consults it on the hot path. Caches
it on the analysis context, keyed by statement index, and cleared with the rest
of the per-phase caches.
Four defects that only become reachable once analysis runs in more than one
phase over the same engine:

- resetEdgeProcessingStorage did not clear analyzerEnqueued, so an analyzer
  still marked enqueued when the runner queue was wiped never re-enqueued
  itself and silently dropped all further work for that method.
- The summary serializer and its deserialised-summary cache were bound to the
  previous ap manager, whose representation the next phase cannot read.
- Memory-pressure state lived on the long-lived manager rather than per run, so
  a phase that ended under pressure made the next phase declare OOM on its
  first GC notification.
- A cancelled phase failed the enclosing coroutine scope. Cancelled now extends
  CancellationException so it cancels only its own coroutine.

Also returns TIMEOUT with per-item placeholders when a phase is entered with a
non-positive budget, instead of activating an already-expired cancellation.
Conductor timed out in trace resolution. Instrumenting one run showed the
resolver was not revisiting entries but multiplying alternatives: 104,330 raw
call choices became 8,602,938 resolved call-summary alternatives that projected
onto only 115,295 distinct (statement, edges) continuations - a 74.6x
multiplicity, with the same backward transfer repeated for each.

Four quotients, all preserving the resolved trace set:

- TraceEdges replaces the flat premise set with alternatives-per-final-fact, so
  one trace request stands for what used to be a cartesian product of summary
  traces. If the merged request exceeds the action budget the resolver falls
  back to resolving each exact premise cube, so nothing is lost.
- Exclusion sets no longer distinguish graph nodes: traces are compared and
  cached after normalising every exclusion to the universe.
- All start traces of one summary that differ only in their method-entry premise
  collapse onto one summary node plus one boundary node per entry, so caller
  search runs once per boundary instead of once per (summary, start) pair.
- Start traces are memoised across field-generalisation-equivalent requests, and
  the least field-specific request is resolved first.

Also: a summary whose start is already determined by its non-Zero premises skips
the backward walk, concrete-field call summaries covered by an applicable
wildcard are dropped before the weakest-entry antichain, and root filtering uses
one reverse BFS instead of a forward BFS per root.
…a shallow pass

Splits the pipeline into prescan, shallow scan and full scan, and restricts the
expensive final pass to the rules a cheap pass proved actionable:

  Prescan      TreeApManager, no facts, harvests relevant rule ids
  ShallowScan  BaseOnlyApManager(fieldSensitive), forward analysis, vulnerability
               confirmation, actionable-rule search
  FullScan     configured ap mode, restricted to the discovered rules

SelectedTaintRulesProvider (and its Go twin) decorate the real provider with
per-instruction rule buckets, so a source or sink rule is evaluated only at the
exact statement where the shallow scan proved it actionable, with actionsAfter
narrowed to the selected actions. Cleaners and pass-throughs deliberately keep
delegating to the full configuration, so no sanitizer is lost and the
over-approximation stays sound.

Actionable rules come from TraceActionSearcher, which walks the resolved
source-to-sink corridor and projects surviving entries to statement -> rule ->
actions. Each vulnerability gets a single wall-clock budget covering both trace
resolution and rule search; on exhaustion it is reported Unprocessed rather than
Failed, and falls back to the rules that actually fired during the shallow
forward pass. That fallback is a global over-approximation (on Conductor, 5,577
forward-only atoms against 1,039 in common), so it is narrowed three ways:
caller reachability to the sink method, taint-mark reachability through the
observed summary transitions, and the semgrep rule graph.

These six concerns are one commit because they are one change in the code:
selectPhase, the TaintAnalyzer driver and the manager interface each interleave
all of them line by line, and no ordering of them compiles on its own.

The shallow scan reads its ap mode from a new TaintAnalyzerOptions field,
shallowScanApMode, rather than hardcoding the field-sensitive BaseOnly manager,
and AnalysisTest.runAnalysis forwards a shallowApMode argument into it. Without
that seam the phase could only ever be exercised under its production default,
and every test below that depends on shallow-scan behaviour would be blind. The
default itself is BaseOnlyField, so production behaviour is unchanged. The seam
lands here rather than with the rest of the harness work because neither the
shallow scan nor ApMode.BaseOnlyField exists before this commit.

ApMode gains BaseOnly and BaseOnlyField here, so regression tests that have to
run under them can only be written from this commit onwards; the Spring Data
fragment test gains its BaseOnlyField case for that reason.
Generated Spring entry points all carried the handler's name and the description
()V, so two overloads of the same handler produced one synthetic method and one
of them was lost from every name-and-descriptor lookup. Overloads now get a
stable index suffix.
EntityActionService#pushEntityActionToRuleEngine is analyzed under about 110
distinct argument-type contexts on ThingsBoard, and constructActionData under
62. Zero and ClassStatic facts depend on neither the receiver nor the argument
types, so every one of those contexts re-tabulated identical flows.

During the shallow scan only, such facts are routed to the callee's
EmptyMethodContext analyzer. A method context is left alone when it constrains a
lambda class or a functional interface, because the call resolver excludes
lambda classes from override enumeration and that constraint is the only
evidence of the callee implementation.

Applied symmetrically at call handling and at trace lookup; the backward
resolver must normalise the same way or it cannot find the shared summaries.

The call-handling half lives in NormalMethodAnalyzer.handleResolvedMethodCall:
every resolved callee is registered with the unit-runner manager once, and the
callee's method context is passed through overApproximateMethodContext before
its entry points are enumerated, with "context independent" meaning a zero fact
or a ClassStatic-based fact.
Under a field-insensitive representation many premises reach the same
conclusion, and the analyzer re-evaluated the flow function once per premise.

The BaseOnly worklist is now keyed by (statement, final fact) with the premises
batched behind it, and flow functions can declare that their result does not
depend on which premise carried the fact, in which case the transfer is computed
once and fanned out over the group. Non-deterministic summary results are
de-duplicated before dispatch, summary sequents are accumulated and dispatched
once, and side-effect requirements publish only their delta.
The actionable-rule search re-derives the same values many times for the same
statement and fact. Adds four memos, each a pure function of its key and each
cleared with the surrounding per-phase state: per-method trace-resolver state
(entry-edge presence, call-pass summaries, callee entry points, zero entry
facts), the non-deterministic required-initials search keyed by edge-set
version, per-statement summary handlers and prepared summaries, and the JVM
trace preconditions.

Removing all of these was measured on both benchmark projects: thingsboard
124s -> 159s, conductor 35s -> 41s, with identical finding sets. They stay.

JIRMethodCallPrecondition memoises through the same cachedCallTracePrecondition
entry as the sequent precondition; its fact and alias preconditions are folded
into one addFactPreconditions helper so both paths share a single cached result.
…ter flows

The shallow scan is the phase that decides which rules the full scan is allowed
to see, so anything it fails to reach is silently unreportable. These tests pin
the shapes that were observed to be at risk and that no existing suite covered.

Class-static: a static field written in one method and read in another, and a
Spring repository whose result reaches a sink through a static holder. Both are
the pattern where a class-static fact has to survive an arbitrary call chain.

Cross-entry-point and thread dispatch: a flow whose source and sink are reached
from different Spring controller entry points, and one that crosses a Runnable
handed to a thread, so the fact leaves the entry point that seeded it.

Getter chains and collections: a taint that reaches the sink only through a
collection element read back by a getter - the case a field-insensitive
representation is most likely to lose.

Shallow rule selection and return sinks: that a rule actionable only inside a
narrowed selection still survives into the full scan, that a controller return
value is treated as a sink, and that BaseOnly trace resolution can still resolve
the corresponding traces backwards.
Adapted from upstream cbe3b3f ("fix(analyzer): Use dedicated storage for
abstract static edges (#364)").

An edge whose initial and final access paths are both the depth-0 ClassStatic
access carries no access-path information at all - only its exclusion set is
ever interesting. Kept in the general initial-to-final storage, one such edge is
recorded per distinct exclusion set per statement, and on a project with many
static pseudo-variables that storage dominates the per-method edge set.

AbstractStaticEdges replaces the whole family at a statement with a single
exclusion set: adding an edge unions into it and reports a delta only when the
union grows, and the read paths rebuild the (initial, final) pair from the
manager's most-abstract access paths. addTaintedFactEdge and the class-static
branch of addFactToFactSupports route to it via isAbstractStaticEdge, and the
three collectApAtStatement overloads consult it before the general storage.

Two adaptations were needed against upstream:

- on this branch MethodEdgesInitialToFinalApSet.add returns a List of changed
  (initial, final) pairs rather than a nullable Pair, because BaseOnly layered
  summaries need every changed final, so the override returns a singleton list
  or an empty list instead of a value or null.
- addFactToFactSupports does not exist upstream, so upstream could not route it.
  It is the second producer of abstract static edges here, and routing only
  addTaintedFactEdge would leave the two storages disagreeing about which edges
  exist; its ClassStatic depth-0 case now dispatches per initial fact.
Rules are resolved per (method, rule) and the resolved objects are retained for
the lifetime of the analyzer, so every method that matches a rule gets its own
copy of structurally identical values. Measured over 2000 resolved methods:
ContainsMark duplicated 505x, CommonCondition$Atom 486x, TaintSinkMeta 6849x.
Retained payload for the duplicated values dropped from 17,295,256 B to
53,520 B once they are shared.

ResolvedRuleInterner is a ConcurrentHashMap-backed canonicaliser: intern for
single values, internList for the lists that hang off every resolved rule
(assign actions, tracked facts, pass-through and cleaner copies), and
internCondition to canonicalise a condition bottom-up so equal subtrees are
shared as well. One interner lives on TaintConfiguration and is handed to every
MethodTaintConfigurationResolver, which is what makes the sharing cross-method
rather than per-method.

The enabler for the duplication is the exit-sink anyFunction() rule, which
matches every method in the program and therefore resolves its meta and tracked
facts once per method.

TaintMarkManager becomes a ConcurrentHashMap. It is reached from the resolvers,
which run concurrently, and a plain HashMap resized under concurrent getOrPut is
a real corruption hazard rather than a theoretical one.
Runs the differential corpus through the real analyzer rather than the access
-path unit tests: reference install, mutation and transfer fuzz samples, trace
projection, resolution and shape fuzz samples, the kkFileView setter-identity
regression, and the summary field-explosion sample. Each asserts the same
source-to-sink flow under Tree and BaseOnly.
…nt removed

Two documents.

baseonly-clean-branch-selection.md is the per-change ranking the branch was
distilled from: what is architecture, what is a measured win, what is a
correctness fix, and what was stripped as telemetry. It also carries the
measured baseline-vs-branch numbers and the ablations, including the correction
that the telemetry strip was load artifact and not a speed-up.

Two of its Tier 1 entries - shallow-scan statement collapsing and class-static
call skipping - are no longer on the branch. They are moved into a Tier 6
section that records why, rather than edited out: the reasoning that promoted
them on two benchmarks is worth keeping next to the evidence that removed them
on twenty-eight. The changes made in their place are recorded as Tier 7.

e2e-regression-2026-08-19.md is that evidence: 28 projects against the fork
point, 23 genuinely lost findings, each traced to a specific commit. It is kept
as it was written, with a status note and a rewritten "what to fix" section, so
the analysis that drove the rewrite is not paraphrased after the fact. The
commit hashes it cites belong to the pre-rewrite history and no longer resolve
on this branch.
The shallow scan keeps four per-analyzer memoisation maps that are cleared only
at phase transitions, so they accumulate for the whole phase. Measured at the
point thingsboard aborts under a 12g heap, baseOnlyF2FTransfers held 2,815,052
entries against a live worklist of 10,321, and baseOnlyPreparedF2FSummaries held
27,438,544 IdentityHashMap slots. Together they were 38% of the 6.23 GB the
shallow phase adds on top of its 5.16 GB baseline.

Hold all four behind SoftReference, the mechanism JIRMethodAnalysisContext
already uses for its call flow-function and summary-handler caches, so the
collector can reclaim them under pressure and they are rebuilt on demand.

All four are pure deterministic memos, which is what makes an unannounced drop
safe. createFactToFactTransfer reaches only sequentFlowAssign or unchanged, and
passes error lambdas for the refinement and side-effect callbacks.
prepareFactToFactSummary and its ND sibling are rewrites rather than
registrations: they delegate to summaryRewriter.rewriteSummaryFact, and
createFactReader always copies before handing out a reader. Nothing depends on
the identity of a cached value; the IdentityHashMap is a key structure only, and
consumers dedup structurally.

The negative half, unsupportedBaseOnlyF2FTransfers, is safe for a stronger
reason: the only overriding implementation returns null on its first line, from
a syntactic test on the instruction, so re-deriving that null executes nothing.
Its key set is disjoint from the positive half by determinism of the function
rather than by cache bookkeeping, so the two may be dropped independently and in
any order.

AnalysisUnitRunnerManager gains the RefManager its single implementation already
declared, rather than threading one through BaseOnlyApManager's 48 construction
sites.

Measured on thingsboard, two runs per configuration. Under a 12g heap the
analysis stops aborting: exit 253 with 13 findings becomes exit 0 with 14, and
peak post-GC live set falls from 11073-11079 MB to 8447-8458 MB. Under 14g it
falls from 12040-12071 MB to 8072-8082 MB. Wall clock is unchanged to slightly
faster, because forced full collections drop from four or five to one. The
recovered finding is unvalidated-redirect-in-spring-app at AdminController.java
line 483, lost to the abort rather than to the analysis.

The last two caches are noise on this benchmark: Edge$NDFactToFact has no live
instances there at all, and the negative set is near-empty on JVM. They are
converted for uniformity and for Go, where createFactToFactTransfer is the
constant null default and the negative set therefore records every conclusion
while saving no work.

This buys headroom, not immunity. The fact explosion that fills these structures
is untouched, and the depth-based throttle remains unreachable in BaseOnly mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Saloed
Saloed force-pushed the saloed/base-only-clean branch from e2bd0f8 to c3b6efe Compare August 20, 2026 20:22
@Saloed

Saloed commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

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