From d76b6f9c9b394966e9c4587bb1a6d601c56e8861 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 01/26] feat(rules): express Spring whole-object source and sink taint via the 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. --- .../sast/project/spring/SpringRuleProvider.kt | 73 +++---------------- 1 file changed, 10 insertions(+), 63 deletions(-) diff --git a/core/src/main/kotlin/org/opentaint/jvm/sast/project/spring/SpringRuleProvider.kt b/core/src/main/kotlin/org/opentaint/jvm/sast/project/spring/SpringRuleProvider.kt index 7f3778015..03c8f520a 100644 --- a/core/src/main/kotlin/org/opentaint/jvm/sast/project/spring/SpringRuleProvider.kt +++ b/core/src/main/kotlin/org/opentaint/jvm/sast/project/spring/SpringRuleProvider.kt @@ -4,15 +4,10 @@ import org.opentaint.dataflow.ap.ifds.AccessPathBase import org.opentaint.dataflow.ap.ifds.ClassStaticAccessor import org.opentaint.dataflow.ap.ifds.access.FactAp import org.opentaint.dataflow.ap.ifds.access.InitialFactAp -import org.opentaint.dataflow.configuration.CommonConditionRewriter import org.opentaint.dataflow.configuration.jvm.ActionPosition import org.opentaint.dataflow.configuration.jvm.Argument -import org.opentaint.dataflow.configuration.jvm.AssignMark import org.opentaint.dataflow.configuration.jvm.ClassStatic -import org.opentaint.dataflow.configuration.jvm.Condition -import org.opentaint.dataflow.configuration.jvm.ContainsMark import org.opentaint.dataflow.configuration.jvm.CopyAllMarks -import org.opentaint.dataflow.configuration.jvm.JirCondition import org.opentaint.dataflow.configuration.jvm.Position import org.opentaint.dataflow.configuration.jvm.PositionAccessor import org.opentaint.dataflow.configuration.jvm.PositionWithAccess @@ -29,15 +24,11 @@ import org.opentaint.dataflow.configuration.jvm.TaintPassThrough import org.opentaint.dataflow.configuration.jvm.TaintStaticFieldSource import org.opentaint.dataflow.configuration.jvm.This import org.opentaint.dataflow.configuration.mkTrue -import org.opentaint.dataflow.jvm.ap.ifds.taint.ContainsMarkOnAnyField import org.opentaint.dataflow.jvm.ap.ifds.taint.TaintRulesProvider -import org.opentaint.dataflow.jvm.ap.ifds.taint.resolveBaseAp import org.opentaint.ir.api.common.CommonMethod import org.opentaint.ir.api.common.cfg.CommonInst import org.opentaint.ir.api.jvm.JIRField import org.opentaint.ir.api.jvm.JIRMethod -import org.opentaint.ir.api.jvm.TypeName -import org.opentaint.ir.impl.cfg.util.isClass class SpringRuleProvider( private val base: TaintRulesProvider, @@ -45,40 +36,7 @@ class SpringRuleProvider( ) : TaintRulesProvider by base { override fun entryPointRulesForMethod(method: CommonMethod, statement: CommonInst, fact: FactAp?, allRelevant: Boolean): Iterable { if (method is SpringGeneratedMethod) return emptyList() - - val baseRules = base.entryPointRulesForMethod(method, statement, fact, allRelevant) - if (method !is JIRMethod || method.isStatic || method.isPrivate || !method.isSpringControllerMethod()) { - return baseRules - } - - return baseRules.map { taintObjectFields(method, it) } - } - - private fun taintObjectFields(method: JIRMethod, rule: TaintEntryPointSource): TaintEntryPointSource { - val actions = rule.actionsAfter.flatMap { taintObjectFields(method, it) } - return rule.copy(actionsAfter = actions) - } - - private fun taintObjectFields(method: JIRMethod, assign: AssignMark): List { - val base = assign.position.resolveBaseAp() - if (base !is AccessPathBase.Argument) return listOf(assign) - - val paramTypeName = method.parameters.getOrNull(base.idx)?.type - ?: return emptyList() - - if (!paramTypeName.isClass) return listOf(assign) - - // todo: better handling of suspend functions - if (paramTypeName.isKotlinContinuation()) return emptyList() - - return when (val p = assign.position) { - is ActionPosition.AnyAccessorAfter -> listOf(assign) - is ActionPosition.Exact -> { - val allFieldsAssign = AssignMark(assign.mark, ActionPosition.AnyAccessorAfter(p.position)) - - listOf(assign, allFieldsAssign) - } - } + return base.entryPointRulesForMethod(method, statement, fact, allRelevant) } override fun sourceRulesForMethod(method: CommonMethod, statement: CommonInst, fact: FactAp?, allRelevant: Boolean): Iterable { @@ -241,34 +199,23 @@ class SpringRuleProvider( initialFacts: Set?, allRelevant: Boolean ): Iterable { + if (method is SpringGeneratedMethod) return emptyList() if (method !is JIRMethod || !method.isSpringControllerMethod()) { return base.sinkRulesForMethodExit(method, statement, fact, initialFacts, allRelevant) } - val allBaseRules = base.sinkRulesForMethodExit(method, statement, fact, initialFacts = null, allRelevant) - return allBaseRules.map { unfoldSpringExitObject(it) } + // Pass initialFacts = null for controller-return sinks to bypass the Z2F gate in + // JIRMethodExitRuleProvider (which drops exit rules when initialFacts is non-empty). + // Controller-return XSS sinks must still fire on F2F edges, i.e. STORED / second-order + // flows where taint enters the GET handler as an initial fact (e.g. POST writes tainted + // data into a repository, GET returns repo.findById(...)). This reproduces the load-bearing + // null bypass of the removed unfoldSpringExitObject hack; the $VAR* stars in the rules now + // handle the any-field widening that the deleted ContainsMarkRewriter used to do. + return base.sinkRulesForMethodExit(method, statement, fact, initialFacts = null, allRelevant) } - private fun unfoldSpringExitObject(rule: TaintMethodExitSink): TaintMethodExitSink = - rule.copy(condition = unfoldObjectContainsMark(position = Result, rule.condition)) - - private fun unfoldObjectContainsMark(position: Position, condition: Condition): Condition = - condition.accept(ContainsMarkRewriter(position)) - - private class ContainsMarkRewriter(val position: Position) : CommonConditionRewriter { - override fun rewriteAtom(atom: JirCondition): JirCondition { - if (atom !is ContainsMark) return atom - - if (atom.position != position) return atom - return ContainsMarkOnAnyField(position, atom.mark) - } - } - - private fun TypeName.isKotlinContinuation(): Boolean = typeName == kotlinContinuation - companion object { private const val javaObject = "java.lang.Object" - private const val kotlinContinuation = "kotlin.coroutines.Continuation" private val iterableElement = PositionAccessor.FieldAccessor( className = "java.lang.Iterable", From c4e95a4178b48a931c5fabe7c3a36668262e3ab5 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 02/26] refactor(rules): field-sensitive java.io.File model and $*VAR syntax 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. --- .../org/opentaint/jvm/sast/project/spring/SpringRuleProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/kotlin/org/opentaint/jvm/sast/project/spring/SpringRuleProvider.kt b/core/src/main/kotlin/org/opentaint/jvm/sast/project/spring/SpringRuleProvider.kt index 03c8f520a..20996428f 100644 --- a/core/src/main/kotlin/org/opentaint/jvm/sast/project/spring/SpringRuleProvider.kt +++ b/core/src/main/kotlin/org/opentaint/jvm/sast/project/spring/SpringRuleProvider.kt @@ -209,7 +209,7 @@ class SpringRuleProvider( // Controller-return XSS sinks must still fire on F2F edges, i.e. STORED / second-order // flows where taint enters the GET handler as an initial fact (e.g. POST writes tainted // data into a repository, GET returns repo.findById(...)). This reproduces the load-bearing - // null bypass of the removed unfoldSpringExitObject hack; the $VAR* stars in the rules now + // null bypass of the removed unfoldSpringExitObject hack; the $*VAR stars in the rules now // handle the any-field widening that the deleted ContainsMarkRewriter used to do. return base.sinkRulesForMethodExit(method, statement, fact, initialFacts = null, allRelevant) } From 0bb5f51ffbde5689445af943d0054feb8a712621 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:00:58 +0200 Subject: [PATCH 03/26] test(querylang): coverage for the changed passthrough config entries 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. --- .../samples-go/BuiltinSliceCoverage/rule.yaml | 10 ++ .../samples-go/BuiltinSliceCoverage/sample.go | 35 ++++++ .../samples-go/FmtCoverage/rule.yaml | 10 ++ .../samples-go/FmtCoverage/sample.go | 64 ++++++++++ .../samples-go/SlicesCoverage/rule.yaml | 10 ++ .../samples-go/SlicesCoverage/sample.go | 32 +++++ .../opentaint/semgrep/GoSampleBasedTest.kt | 10 ++ .../main/java/phase3/CoverageCollections.java | 46 +++++++ .../java/phase3/CoverageNamingDirectory.java | 44 +++++++ .../main/java/phase3/CoverageNamingLdap.java | 76 ++++++++++++ .../main/java/phase3/CoverageSecurity.java | 52 ++++++++ .../src/main/java/phase3/CoverageSql.java | 42 +++++++ .../src/main/java/phase3/CoverageStreams.java | 117 ++++++++++++++++++ .../java/phase3/CoverageStringBuilders.java | 56 +++++++++ .../src/main/java/phase3/CoverageStrings.java | 69 +++++++++++ .../src/main/java/phase3/StdlibCoverage.java | 55 ++++++++ .../resources/phase3/CoverageCollections.yaml | 15 +++ .../phase3/CoverageNamingDirectory.yaml | 15 +++ .../resources/phase3/CoverageNamingLdap.yaml | 18 +++ .../resources/phase3/CoverageSecurity.yaml | 18 +++ .../main/resources/phase3/CoverageSql.yaml | 15 +++ .../resources/phase3/CoverageStreams.yaml | 39 ++++++ .../phase3/CoverageStringBuilders.yaml | 15 +++ .../resources/phase3/CoverageStrings.yaml | 21 ++++ .../main/resources/phase3/StdlibCoverage.yaml | 21 ++++ .../semgrep/Phase3ConfigCoverageTest.kt | 23 ++++ .../semgrep/Phase3CoreCoverageTest.kt | 32 +++++ .../semgrep/Phase3IoNioCoverageTest.kt | 29 +++++ .../semgrep/Phase3JavaxCoverageTest.kt | 33 +++++ 29 files changed, 1022 insertions(+) create mode 100644 core/opentaint-go-querylang/samples-go/BuiltinSliceCoverage/rule.yaml create mode 100644 core/opentaint-go-querylang/samples-go/BuiltinSliceCoverage/sample.go create mode 100644 core/opentaint-go-querylang/samples-go/FmtCoverage/rule.yaml create mode 100644 core/opentaint-go-querylang/samples-go/FmtCoverage/sample.go create mode 100644 core/opentaint-go-querylang/samples-go/SlicesCoverage/rule.yaml create mode 100644 core/opentaint-go-querylang/samples-go/SlicesCoverage/sample.go create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageCollections.java create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingDirectory.java create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingLdap.java create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageSecurity.java create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageSql.java create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStreams.java create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStringBuilders.java create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStrings.java create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/StdlibCoverage.java create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageCollections.yaml create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingDirectory.yaml create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingLdap.yaml create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageSecurity.yaml create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageSql.yaml create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStreams.yaml create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStringBuilders.yaml create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStrings.yaml create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/StdlibCoverage.yaml create mode 100644 core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3ConfigCoverageTest.kt create mode 100644 core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3CoreCoverageTest.kt create mode 100644 core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3IoNioCoverageTest.kt create mode 100644 core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3JavaxCoverageTest.kt diff --git a/core/opentaint-go-querylang/samples-go/BuiltinSliceCoverage/rule.yaml b/core/opentaint-go-querylang/samples-go/BuiltinSliceCoverage/rule.yaml new file mode 100644 index 000000000..2a1829e3b --- /dev/null +++ b/core/opentaint-go-querylang/samples-go/BuiltinSliceCoverage/rule.yaml @@ -0,0 +1,10 @@ +rules: + - id: builtin-slice-coverage + languages: [go] + severity: WARNING + message: "taint survives a changed builtin slice passthrough and reaches Sink" + mode: taint + pattern-sources: + - pattern: "BuiltinSliceCoverage.Source(...)" + pattern-sinks: + - pattern: "BuiltinSliceCoverage.Sink($X)" diff --git a/core/opentaint-go-querylang/samples-go/BuiltinSliceCoverage/sample.go b/core/opentaint-go-querylang/samples-go/BuiltinSliceCoverage/sample.go new file mode 100644 index 000000000..3b7823562 --- /dev/null +++ b/core/opentaint-go-querylang/samples-go/BuiltinSliceCoverage/sample.go @@ -0,0 +1,35 @@ +package util + +func Source() string { return "tainted" } +func Sink(s string) { _ = s } + +// builtin append base slice arg(0): folded (elem->elem entry deleted, whole arg(0)->result kept). +func Positive_append_base() { + bar := Source() + s := []string{bar} + r := append(s, "x") + Sink(r[0]) +} + +// builtin append variadic arg(1): element star kept (boxed variadic element). +func Positive_append_variadic() { + bar := Source() + base := []string{"x"} + r := append(base, bar) + Sink(r[1]) +} + +// builtin copy(dst, src): folded (elem->elem deleted, whole arg(1)->arg(0) kept). +func Positive_copy() { + bar := Source() + src := []string{bar} + dst := make([]string, 1) + copy(dst, src) + Sink(dst[0]) +} + +func Negative_append_clean() { + s := []string{"safe"} + r := append(s, "x") + Sink(r[0]) +} diff --git a/core/opentaint-go-querylang/samples-go/FmtCoverage/rule.yaml b/core/opentaint-go-querylang/samples-go/FmtCoverage/rule.yaml new file mode 100644 index 000000000..99c0b9394 --- /dev/null +++ b/core/opentaint-go-querylang/samples-go/FmtCoverage/rule.yaml @@ -0,0 +1,10 @@ +rules: + - id: fmt-coverage + languages: [go] + severity: WARNING + message: "taint survives a changed fmt passthrough and reaches Sink" + mode: taint + pattern-sources: + - pattern: "FmtCoverage.Source(...)" + pattern-sinks: + - pattern: "FmtCoverage.Sink($X)" diff --git a/core/opentaint-go-querylang/samples-go/FmtCoverage/sample.go b/core/opentaint-go-querylang/samples-go/FmtCoverage/sample.go new file mode 100644 index 000000000..db67d0dd9 --- /dev/null +++ b/core/opentaint-go-querylang/samples-go/FmtCoverage/sample.go @@ -0,0 +1,64 @@ +package util + +import ( + "fmt" + "strings" +) + +func Source() string { return "tainted" } +func Sink(s string) { _ = s } + +// fmt.Sprint: Phase 2 removed [arg(*),'[*]']->result collapse; whole arg(*)->result kept. +func Positive_sprint() { + Sink(fmt.Sprint("p", Source())) +} + +// fmt.Sprintf +func Positive_sprintf() { + Sink(fmt.Sprintf("%s", Source())) +} + +// fmt.Sprintln +func Positive_sprintln() { + Sink(fmt.Sprintln(Source())) +} + +// fmt.Fprint: taints the writer arg(0); read it back. +func Positive_fprint() { + var b strings.Builder + fmt.Fprint(&b, Source()) + Sink(b.String()) +} + +// fmt.Fprintf / fmt.Fprintln: variadic collapse to the writer arg(0) (kept). +func Positive_fprintf() { + var b strings.Builder + fmt.Fprintf(&b, "%s", Source()) + Sink(b.String()) +} + +func Positive_fprintln() { + var b strings.Builder + fmt.Fprintln(&b, Source()) + Sink(b.String()) +} + +// fmt.Append / fmt.Appendf / fmt.Appendln: append formatted args to a []byte (arg->result). +func Positive_append() { + b := fmt.Append(nil, Source()) + Sink(string(b)) +} + +func Positive_appendf() { + b := fmt.Appendf(nil, "%s", Source()) + Sink(string(b)) +} + +func Positive_appendln() { + b := fmt.Appendln(nil, Source()) + Sink(string(b)) +} + +func Negative_clean() { + Sink(fmt.Sprint("safe", "clean")) +} diff --git a/core/opentaint-go-querylang/samples-go/SlicesCoverage/rule.yaml b/core/opentaint-go-querylang/samples-go/SlicesCoverage/rule.yaml new file mode 100644 index 000000000..e670cd87e --- /dev/null +++ b/core/opentaint-go-querylang/samples-go/SlicesCoverage/rule.yaml @@ -0,0 +1,10 @@ +rules: + - id: slices-coverage + languages: [go] + severity: WARNING + message: "taint survives a folded slices passthrough and reaches Sink" + mode: taint + pattern-sources: + - pattern: "SlicesCoverage.Source(...)" + pattern-sinks: + - pattern: "SlicesCoverage.Sink($X)" diff --git a/core/opentaint-go-querylang/samples-go/SlicesCoverage/sample.go b/core/opentaint-go-querylang/samples-go/SlicesCoverage/sample.go new file mode 100644 index 000000000..d3834e75a --- /dev/null +++ b/core/opentaint-go-querylang/samples-go/SlicesCoverage/sample.go @@ -0,0 +1,32 @@ +package util + +import "slices" + +func Source() string { return "tainted" } +func Sink(s string) { _ = s } + +// Coverage intent for the folded slices.* passthroughs. PARKED (@Disabled): the +// stdlib slices.* functions are generic (e.g. Clone[S ~[]E, E any]) and the config +// key {package: slices, name: Clone} does not match the generic-instantiated call +// in any path -- so these entries were already INERT before the fold (verified: +// slices.Clone element flow is not detected even with the pre-fold [*] stars, in +// both the querylang harness and production). Removing their stars is therefore +// neutral. These flows are kept as documentation and light up if generic-function +// config matching is ever added to the engine. +func Positive_slices_clone() { + s := []string{Source()} + c := slices.Clone(s) + Sink(c[0]) +} + +func Positive_slices_compact() { + s := []string{Source(), Source()} + c := slices.Compact(s) + Sink(c[0]) +} + +func Positive_slices_delete() { + s := []string{Source(), "a"} + c := slices.Delete(s, 1, 2) + Sink(c[0]) +} diff --git a/core/opentaint-go-querylang/src/test/kotlin/org/opentaint/semgrep/GoSampleBasedTest.kt b/core/opentaint-go-querylang/src/test/kotlin/org/opentaint/semgrep/GoSampleBasedTest.kt index 303ca82af..622fbcacc 100644 --- a/core/opentaint-go-querylang/src/test/kotlin/org/opentaint/semgrep/GoSampleBasedTest.kt +++ b/core/opentaint-go-querylang/src/test/kotlin/org/opentaint/semgrep/GoSampleBasedTest.kt @@ -89,6 +89,16 @@ class GoSampleBasedTest: GoSampleBasedTestBase("GO_SAMPLES_DIR") { @Test fun cookieValueFieldRead() = runSample("CookieValueFieldRead") + // Phase 3 config coverage: taint must survive the changed builtin/fmt passthroughs. + @Test fun builtinSliceCoverage() = runSample("BuiltinSliceCoverage", useDefaultConfig = true) + + @Test fun fmtCoverage() = runSample("FmtCoverage", useDefaultConfig = true) + + @Disabled // slices.* are generic (Clone[S ~[]E, E any]); the config key does not match the + // generic-instantiated call, so these entries were already inert before the fold (element + // flow undetected even with the pre-fold stars). Un-disable if generic config matching lands. + @Test fun slicesCoverage() = runSample("SlicesCoverage", useDefaultConfig = true) + @Disabled // todo: support struct-literal field matching (issues.md #8) @Test fun insecureCookieLiteral() = runSample("InsecureCookieLiteral") diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageCollections.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageCollections.java new file mode 100644 index 000000000..5a9b81101 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageCollections.java @@ -0,0 +1,46 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +import java.util.List; +import java.util.Set; + +// Phase 3 core coverage: immutable-factory element passthroughs. +// Each Positive flows taint from a source, through List.of / Set.of, into the +// collection element, then out through an element read to a sink. A Positive +// turning red means the factory passthrough dropped the element taint. +@RuleSet("phase3/CoverageCollections.yaml") +public abstract class CoverageCollections implements RuleSample { + public String ssrc() { return "tainted"; } + public void strSink(String s) {} + + // java.util.List#of(Object) : arg0 -> result.Element + static class PositiveListOf extends CoverageCollections { + @Override public void entrypoint() { + String t = ssrc(); + List l = List.of(t); + strSink(l.get(0)); + } + } + + // java.util.Set#of(Object) : arg0 -> result.Element + static class PositiveSetOf extends CoverageCollections { + @Override public void entrypoint() { + String t = ssrc(); + Set s = Set.of(t); + for (String v : s) { + strSink(v); + } + } + } + + // Negative: a clean local element must not be reported. + static class NegativeCleanListOf extends CoverageCollections { + @Override public void entrypoint() { + String t = "safe"; + List l = List.of(t); + strSink(l.get(0)); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingDirectory.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingDirectory.java new file mode 100644 index 000000000..1a99386a6 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingDirectory.java @@ -0,0 +1,44 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +// javax.naming.directory (java.naming JDK module) passthrough coverage. Each +// Positive flows taint from a source, through a SearchControls config passthrough, +// and back out to a sink. A Positive turning red means the config change dropped a +// real flow. +@RuleSet("phase3/CoverageNamingDirectory.yaml") +public abstract class CoverageNamingDirectory implements RuleSample { + public String[] asrc() { return new String[]{"tainted"}; } + public void arrSink(String[] s) {} + + // SearchControls#setReturningAttributes(String[]) : arg0 -> this.returningAttributes, + // read back via getReturningAttributes() : this.returningAttributes -> result. + static class PositiveSearchControlsSetter extends CoverageNamingDirectory { + @Override public void entrypoint() { + String[] a = asrc(); + javax.naming.directory.SearchControls sc = new javax.naming.directory.SearchControls(); + sc.setReturningAttributes(a); + arrSink(sc.getReturningAttributes()); + } + } + + // SearchControls#(int,long,int,String[],boolean,boolean) : arg3 -> this.returningAttributes. + static class PositiveSearchControlsCtor extends CoverageNamingDirectory { + @Override public void entrypoint() { + javax.naming.directory.SearchControls sc = + new javax.naming.directory.SearchControls(0, 0L, 0, asrc(), false, false); + arrSink(sc.getReturningAttributes()); + } + } + + // Negative: a clean local array must not be reported. + static class NegativeCleanSearchControls extends CoverageNamingDirectory { + @Override public void entrypoint() { + String[] a = new String[]{"safe"}; + javax.naming.directory.SearchControls sc = new javax.naming.directory.SearchControls(); + sc.setReturningAttributes(a); + arrSink(sc.getReturningAttributes()); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingLdap.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingLdap.java new file mode 100644 index 000000000..4e3a9e624 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingLdap.java @@ -0,0 +1,76 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +// javax.naming.ldap (java.naming JDK module) passthrough coverage. The Control-family +// ctors copy the tainted arg -> this (whole-object). We sink the constructed control +// object directly (ctrlSink), which observes that whole-object taint -- no read-back +// getter is needed (getEncodedValue is not modeled and its clone-based body does not +// propagate the field in this harness). +// ExtendedRequest#createExtendedResponse is UNTESTABLE (ExtendedRequest is an interface; +// its concrete impl StartTlsRequest has an inert reflective createExtendedResponse body). +@RuleSet("phase3/CoverageNamingLdap.yaml") +public abstract class CoverageNamingLdap implements RuleSample { + public String[] asrc() { return new String[]{"tainted"}; } + public byte[] bsrc() { return new byte[]{1}; } + public void ctrlSink(Object c) {} + + // SortControl#(String[], boolean) : arg0 -> this. + static class PositiveSortControl extends CoverageNamingLdap { + @Override public void entrypoint() { + String[] a = asrc(); + try { + javax.naming.ldap.SortControl c = new javax.naming.ldap.SortControl(a, true); + ctrlSink(c); + } catch (java.io.IOException e) { + } + } + } + + // SortResponseControl#(String, boolean, byte[]) : arg2 -> this. + static class PositiveSortResponseControl extends CoverageNamingLdap { + @Override public void entrypoint() { + byte[] b = bsrc(); + try { + javax.naming.ldap.SortResponseControl c = + new javax.naming.ldap.SortResponseControl("1.2.840.113556.1.4.474", false, b); + ctrlSink(c); + } catch (java.io.IOException e) { + } + } + } + + // BasicControl#(String, boolean, byte[]) : arg2 -> this. + static class PositiveBasicControl extends CoverageNamingLdap { + @Override public void entrypoint() { + byte[] b = bsrc(); + javax.naming.ldap.BasicControl c = + new javax.naming.ldap.BasicControl("1.2", false, b); + ctrlSink(c); + } + } + + // PagedResultsResponseControl#(String, boolean, byte[]) : arg2 -> this. + static class PositivePagedResultsResponseControl extends CoverageNamingLdap { + @Override public void entrypoint() { + byte[] b = bsrc(); + try { + javax.naming.ldap.PagedResultsResponseControl c = + new javax.naming.ldap.PagedResultsResponseControl("1.2.840.113556.1.4.319", false, b); + ctrlSink(c); + } catch (java.io.IOException e) { + } + } + } + + // Negative: a clean local byte[] must not be reported. + static class NegativeCleanBasicControl extends CoverageNamingLdap { + @Override public void entrypoint() { + byte[] b = new byte[]{0}; + javax.naming.ldap.BasicControl c = + new javax.naming.ldap.BasicControl("1.2", false, b); + ctrlSink(c); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageSecurity.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageSecurity.java new file mode 100644 index 000000000..a5a6d1f2b --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageSecurity.java @@ -0,0 +1,52 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +import java.security.CodeSigner; +import java.security.CodeSource; +import java.security.cert.Certificate; + +// Phase 3 stdlib coverage: java.security.CodeSource passthrough entries touched +// by the redundant-star cleanup. Each Positive flows taint from a source array, +// through the CodeSource constructor field store, back out through the matching +// accessor, to a sink. A Positive turning red means the config dropped a flow. +@RuleSet("phase3/CoverageSecurity.yaml") +public abstract class CoverageSecurity implements RuleSample { + public Certificate[] certSrc() { return new Certificate[0]; } + public CodeSigner[] signerSrc() { return new CodeSigner[0]; } + + public void objSink(Object o) {} + + // java.security.CodeSource#(URL,Certificate[]) : arg1 -> this.certificates ; + // getCertificates() : this.certificates -> result. + static class PositiveCodeSourceCertificates extends CoverageSecurity { + @Override public void entrypoint() { + Certificate[] certs = certSrc(); + CodeSource cs = new CodeSource((java.net.URL) null, certs); + Certificate[] got = cs.getCertificates(); + objSink(got); + } + } + + // java.security.CodeSource#(URL,CodeSigner[]) : arg1 -> this.codeSigners ; + // getCodeSigners() : this.codeSigners -> result. + static class PositiveCodeSourceSigners extends CoverageSecurity { + @Override public void entrypoint() { + CodeSigner[] signers = signerSrc(); + CodeSource cs = new CodeSource((java.net.URL) null, signers); + CodeSigner[] got = cs.getCodeSigners(); + objSink(got); + } + } + + // Negative: a clean local certificate array must not be reported. + static class NegativeCleanCodeSource extends CoverageSecurity { + @Override public void entrypoint() { + Certificate[] certs = new Certificate[0]; + CodeSource cs = new CodeSource((java.net.URL) null, certs); + Certificate[] got = cs.getCertificates(); + objSink(got); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageSql.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageSql.java new file mode 100644 index 000000000..9b935a4a3 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageSql.java @@ -0,0 +1,42 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +// javax.sql.rowset (java.sql.rowset JDK module) passthrough coverage. JoinRowSet is +// an interface, but the config passthrough is keyed on the interface, so calling +// through the interface type (obtained from RowSetProvider) matches it directly: +// addRowSet copies arg0 -> this, and getRowSets copies this -> result. +@RuleSet("phase3/CoverageSql.yaml") +public abstract class CoverageSql implements RuleSample { + public javax.sql.RowSet rsrc() { return null; } + public void objSink(Object o) {} + + // JoinRowSet#addRowSet(RowSet, String) : arg0 -> this, read back via getRowSets(). + static class PositiveJoinRowSetAddRowSet extends CoverageSql { + @Override public void entrypoint() { + javax.sql.RowSet r = rsrc(); + try { + javax.sql.rowset.JoinRowSet j = + javax.sql.rowset.RowSetProvider.newFactory().createJoinRowSet(); + j.addRowSet(r, "col"); + objSink(j.getRowSets()); + } catch (java.sql.SQLException e) { + } + } + } + + // Negative: a clean local RowSet must not be reported. + static class NegativeCleanJoinRowSet extends CoverageSql { + @Override public void entrypoint() { + javax.sql.RowSet r = null; + try { + javax.sql.rowset.JoinRowSet j = + javax.sql.rowset.RowSetProvider.newFactory().createJoinRowSet(); + j.addRowSet(r, "col"); + objSink(j.getRowSets()); + } catch (java.sql.SQLException e) { + } + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStreams.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStreams.java new file mode 100644 index 000000000..c8a2350d5 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStreams.java @@ -0,0 +1,117 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.IntBuffer; +import java.nio.LongBuffer; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +// Phase 3 stdlib coverage: java.io / java.nio / java.util.stream passthrough +// entries touched by the redundant-star cleanup. Each Positive flows taint from +// a source, through the changed passthrough, into a holder, then back out to a +// sink. A Positive turning red means the config change dropped a real flow. +@RuleSet("phase3/CoverageStreams.yaml") +public abstract class CoverageStreams implements RuleSample { + public byte[] bsrc() { return new byte[]{1}; } + public char[] csrc() { return new char[]{'x'}; } + public int[] isrc() { return new int[]{1}; } + public long[] lsrc() { return new long[]{1L}; } + public String ssrc() { return "tainted"; } + + public void bSink(byte[] b) {} + public void cSink(char[] c) {} + public void iSink(int[] i) {} + public void lSink(long[] l) {} + public void strSink(String s) {} + + // java.io.OutputStream#write(byte[]) : arg0 -> this ; toByteArray this->result. + // Also exercises the java-io `write.*` pattern entry (same arg0->this shape). + static class PositiveOutputStreamWrite extends CoverageStreams { + @Override public void entrypoint() { + try { + byte[] b = bsrc(); + ByteArrayOutputStream o = new ByteArrayOutputStream(); + o.write(b); + bSink(o.toByteArray()); + } catch (IOException e) { + } + } + } + + // java.io.ByteArrayOutputStream#write(byte[],int,int) : arg0 -> this. + static class PositiveByteArrayOutputStreamWrite3 extends CoverageStreams { + @Override public void entrypoint() { + byte[] b = bsrc(); + ByteArrayOutputStream o = new ByteArrayOutputStream(); + o.write(b, 0, b.length); + bSink(o.toByteArray()); + } + } + + // java.nio.ByteBuffer#put(int,byte[]) : arg1 -> this.data ; array() this.data->result. + static class PositiveByteBufferPut extends CoverageStreams { + @Override public void entrypoint() { + byte[] b = bsrc(); + ByteBuffer buf = ByteBuffer.allocate(64); + buf.put(0, b); + bSink(buf.array()); + } + } + + // java.nio.CharBuffer#put(int,char[]) : arg1 -> this.data ; array() -> result. + static class PositiveCharBufferPut extends CoverageStreams { + @Override public void entrypoint() { + char[] c = csrc(); + CharBuffer cb = CharBuffer.allocate(64); + cb.put(0, c); + cSink(cb.array()); + } + } + + // java.nio.IntBuffer#put(int[]) : arg0 -> this.data ; array() -> result. + static class PositiveIntBufferPut extends CoverageStreams { + @Override public void entrypoint() { + int[] i = isrc(); + IntBuffer ib = IntBuffer.allocate(64); + ib.put(i); + iSink(ib.array()); + } + } + + // java.nio.LongBuffer#put(long[]) : arg0 -> this ; array() this->result. + static class PositiveLongBufferPut extends CoverageStreams { + @Override public void entrypoint() { + long[] l = lsrc(); + LongBuffer lb = LongBuffer.allocate(64); + lb.put(l); + lSink(lb.array()); + } + } + + // java.util.stream.Stream#of(Object) : arg0 -> result.Element. + static class PositiveStreamOf extends CoverageStreams { + @Override public void entrypoint() { + String s = ssrc(); + Stream st = Stream.of(s); + List l = st.collect(Collectors.toList()); + strSink(l.get(0)); + } + } + + // Negative: a clean local buffer must not be reported. + static class NegativeCleanByteBuffer extends CoverageStreams { + @Override public void entrypoint() { + byte[] b = new byte[]{2}; + ByteBuffer buf = ByteBuffer.allocate(64); + buf.put(0, b); + bSink(buf.array()); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStringBuilders.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStringBuilders.java new file mode 100644 index 000000000..5a132e914 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStringBuilders.java @@ -0,0 +1,56 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +// Phase 3 core coverage: char[] overloads of the string-builder append/insert +// entries. Each Positive flows a tainted char[] through the builder (arg -> this) +// and reads it back via toString. StringBuilder.append(char[]) is already covered +// in StdlibCoverage; here we exercise the remaining char[] overloads. The abstract +// java.lang.AbstractStringBuilder#append/#insert entries are non-instantiable and +// are therefore covered transitively through StringBuilder / StringBuffer below. +@RuleSet("phase3/CoverageStringBuilders.yaml") +public abstract class CoverageStringBuilders implements RuleSample { + public char[] csrc() { return new char[]{'x'}; } + public void strSink(String s) {} + + // java.lang.StringBuffer#append(char[]) : arg0 -> this + static class PositiveStringBufferAppendChars extends CoverageStringBuilders { + @Override public void entrypoint() { + char[] ch = csrc(); + StringBuffer sb = new StringBuffer(); + sb.append(ch); + strSink(sb.toString()); + } + } + + // java.lang.StringBuilder#insert(int, char[]) : arg1 -> this + static class PositiveStringBuilderInsertChars extends CoverageStringBuilders { + @Override public void entrypoint() { + char[] ch = csrc(); + StringBuilder sb = new StringBuilder(); + sb.insert(0, ch); + strSink(sb.toString()); + } + } + + // java.lang.StringBuffer#insert(int, char[]) : arg1 -> this + static class PositiveStringBufferInsertChars extends CoverageStringBuilders { + @Override public void entrypoint() { + char[] ch = csrc(); + StringBuffer sb = new StringBuffer(); + sb.insert(0, ch); + strSink(sb.toString()); + } + } + + // Negative: a clean local char[] must not be reported. + static class NegativeCleanAppendChars extends CoverageStringBuilders { + @Override public void entrypoint() { + char[] ch = new char[]{'y'}; + StringBuffer sb = new StringBuffer(); + sb.append(ch); + strSink(sb.toString()); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStrings.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStrings.java new file mode 100644 index 000000000..8d12e815b --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStrings.java @@ -0,0 +1,69 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +import java.nio.charset.StandardCharsets; +import java.text.ChoiceFormat; +import java.text.DecimalFormat; +import java.util.Locale; + +// Phase 3 core coverage: java.lang.String factory overloads plus java.text +// pattern/setter entries. Each Positive flows taint from a source, through the +// changed passthrough, and back out to a sink. The java.text cases (ChoiceFormat +// ctor, DecimalFormat set*) rely on arg -> this whole-object taint plus a guessed +// getter accessor (AnyAccessorEnabled) to read the value back. +@RuleSet("phase3/CoverageStrings.yaml") +public abstract class CoverageStrings implements RuleSample { + public Object[] osrc() { return new Object[]{"tainted"}; } + public byte[] bsrc() { return new byte[]{1}; } + public String ssrc() { return "tainted"; } + public void strSink(String s) {} + + // java.lang.String#format(Locale, String, Object[]) : arg2 -> result + static class PositiveStringFormatLocale extends CoverageStrings { + @Override public void entrypoint() { + Object[] a = osrc(); + String s = String.format(Locale.ROOT, "%s", a); + strSink(s); + } + } + + // java.lang.String#(byte[], int, int, Charset) : arg0 -> this + static class PositiveStringInitBytesCharset extends CoverageStrings { + @Override public void entrypoint() { + byte[] b = bsrc(); + String s = new String(b, 0, b.length, StandardCharsets.UTF_8); + strSink(s); + } + } + + // java.text.ChoiceFormat#(String) : arg0 -> this (read back via toPattern) + static class PositiveChoiceFormatPattern extends CoverageStrings { + @Override public void entrypoint() { + String p = ssrc(); + ChoiceFormat cf = new ChoiceFormat(p); + strSink(cf.toPattern()); + } + } + + // java.text set.+(String) : arg0 -> this (DecimalFormat#setPositivePrefix, + // read back via getPositivePrefix) + static class PositiveDecimalFormatSetPrefix extends CoverageStrings { + @Override public void entrypoint() { + String p = ssrc(); + DecimalFormat df = new DecimalFormat(); + df.setPositivePrefix(p); + strSink(df.getPositivePrefix()); + } + } + + // Negative: a clean local value must not be reported. + static class NegativeCleanStringFormat extends CoverageStrings { + @Override public void entrypoint() { + Object[] a = new Object[]{"safe"}; + String s = String.format(Locale.ROOT, "%s", a); + strSink(s); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/StdlibCoverage.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/StdlibCoverage.java new file mode 100644 index 000000000..b97cb2699 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/StdlibCoverage.java @@ -0,0 +1,55 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +import java.util.Arrays; + +// Phase 3 config coverage: each Positive flows taint from a source, through a +// changed passthrough entry (Phase 1 fold or Phase 2 collapse removal), to a sink. +// A Positive turning red means the config change dropped a real flow. +@RuleSet("phase3/StdlibCoverage.yaml") +public abstract class StdlibCoverage implements RuleSample { + public String[] asrc() { return new String[]{"tainted"}; } + public char[] csrc() { return new char[]{'x'}; } + public void arrSink(String[] s) {} + public void strSink(String s) {} + + // Phase 1 fold: java.util.Arrays#copyOf [arg0,*]->[result,*] => arg0->result + static class PositiveArraysCopyOf extends StdlibCoverage { + @Override public void entrypoint() { + String[] d = asrc(); + String[] c = Arrays.copyOf(d, 1); + arrSink(c); + } + } + + // Phase 1 fold: java.util.Arrays#copyOfRange + static class PositiveArraysCopyOfRange extends StdlibCoverage { + @Override public void entrypoint() { + String[] d = asrc(); + String[] c = Arrays.copyOfRange(d, 0, 1); + arrSink(c); + } + } + + // Phase 2 collapse removed: java.lang.AbstractStringBuilder#append(char[]) + // kept whole copy arg0->this; whole char[] taint must still reach the builder. + static class PositiveStringBuilderAppendChars extends StdlibCoverage { + @Override public void entrypoint() { + char[] ch = csrc(); + StringBuilder sb = new StringBuilder(); + sb.append(ch); + strSink(sb.toString()); + } + } + + // Negative: a locally-built clean array must not be reported. + static class NegativeCleanCopyOf extends StdlibCoverage { + @Override public void entrypoint() { + String[] d = new String[]{"safe"}; + String[] c = Arrays.copyOf(d, 1); + arrSink(c); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageCollections.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageCollections.yaml new file mode 100644 index 000000000..8b16d5435 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageCollections.yaml @@ -0,0 +1,15 @@ +rules: + - id: phase3-coverage-collections + languages: + - java + severity: ERROR + message: taint reaches sink through an immutable-factory element passthrough + mode: taint + pattern-sources: + - patterns: + - pattern: $X = ssrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: strSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingDirectory.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingDirectory.yaml new file mode 100644 index 000000000..6165449fa --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingDirectory.yaml @@ -0,0 +1,15 @@ +rules: + - id: phase3-coverage-naming-directory + languages: + - java + severity: ERROR + message: taint reaches sink through a javax.naming.directory passthrough + mode: taint + pattern-sources: + - patterns: + - pattern: $X = asrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: arrSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingLdap.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingLdap.yaml new file mode 100644 index 000000000..93fb7634d --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingLdap.yaml @@ -0,0 +1,18 @@ +rules: + - id: phase3-coverage-naming-ldap + languages: + - java + severity: ERROR + message: taint reaches sink through a javax.naming.ldap passthrough + mode: taint + pattern-sources: + - patterns: + - pattern: $X = asrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = bsrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: ctrlSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageSecurity.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageSecurity.yaml new file mode 100644 index 000000000..97d371d76 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageSecurity.yaml @@ -0,0 +1,18 @@ +rules: + - id: phase3-coverage-security + languages: + - java + severity: ERROR + message: taint reaches sink through a java.security.CodeSource passthrough + mode: taint + pattern-sources: + - patterns: + - pattern: $X = certSrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = signerSrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: objSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageSql.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageSql.yaml new file mode 100644 index 000000000..35cfeb765 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageSql.yaml @@ -0,0 +1,15 @@ +rules: + - id: phase3-coverage-sql + languages: + - java + severity: ERROR + message: taint reaches sink through a javax.sql.rowset passthrough + mode: taint + pattern-sources: + - patterns: + - pattern: $X = rsrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: objSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStreams.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStreams.yaml new file mode 100644 index 000000000..04efcb286 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStreams.yaml @@ -0,0 +1,39 @@ +rules: + - id: phase3-coverage-streams + languages: + - java + severity: ERROR + message: taint reaches sink through a java.io / java.nio / java.util.stream passthrough + mode: taint + pattern-sources: + - patterns: + - pattern: $X = bsrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = csrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = isrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = lsrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = ssrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: bSink($Y); + - focus-metavariable: $Y + - patterns: + - pattern: cSink($Y); + - focus-metavariable: $Y + - patterns: + - pattern: iSink($Y); + - focus-metavariable: $Y + - patterns: + - pattern: lSink($Y); + - focus-metavariable: $Y + - patterns: + - pattern: strSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStringBuilders.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStringBuilders.yaml new file mode 100644 index 000000000..d1a11d29a --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStringBuilders.yaml @@ -0,0 +1,15 @@ +rules: + - id: phase3-coverage-string-builders + languages: + - java + severity: ERROR + message: taint reaches sink through a string-builder char[] passthrough + mode: taint + pattern-sources: + - patterns: + - pattern: $X = csrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: strSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStrings.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStrings.yaml new file mode 100644 index 000000000..760f10e40 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStrings.yaml @@ -0,0 +1,21 @@ +rules: + - id: phase3-coverage-strings + languages: + - java + severity: ERROR + message: taint reaches sink through a String or java.text passthrough + mode: taint + pattern-sources: + - patterns: + - pattern: $X = osrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = bsrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = ssrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: strSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/StdlibCoverage.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/StdlibCoverage.yaml new file mode 100644 index 000000000..04996a6d0 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/StdlibCoverage.yaml @@ -0,0 +1,21 @@ +rules: + - id: phase3-stdlib-coverage + languages: + - java + severity: ERROR + message: taint reaches sink through a changed stdlib passthrough + mode: taint + pattern-sources: + - patterns: + - pattern: $X = asrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = csrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: arrSink($Y); + - focus-metavariable: $Y + - patterns: + - pattern: strSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3ConfigCoverageTest.kt b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3ConfigCoverageTest.kt new file mode 100644 index 000000000..e524f6b82 --- /dev/null +++ b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3ConfigCoverageTest.kt @@ -0,0 +1,23 @@ +package org.opentaint.semgrep + +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import org.opentaint.semgrep.util.SampleBasedTest +import org.opentaint.semgrep.util.TestAnalysisRunner +import kotlin.test.Test + +// Coverage for config passthrough entries changed in the redundant-star cleanup +// (Phase 1 folds + Phase 2 collapse removals). configurationRequired = true loads +// the bundled model/java/config; AnyAccessorEnabled mirrors the production unroll. +@TestInstance(PER_CLASS) +class Phase3ConfigCoverageTest : SampleBasedTest(configurationRequired = true) { + @Test + fun `stdlib passthrough coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @AfterAll + fun close() { + closeRunner() + } +} diff --git a/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3CoreCoverageTest.kt b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3CoreCoverageTest.kt new file mode 100644 index 000000000..fbad0c03c --- /dev/null +++ b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3CoreCoverageTest.kt @@ -0,0 +1,32 @@ +package org.opentaint.semgrep + +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import org.opentaint.semgrep.util.SampleBasedTest +import org.opentaint.semgrep.util.TestAnalysisRunner +import kotlin.test.Test + +// Coverage for JDK stdlib passthrough entries touched by the redundant-star +// cleanup: immutable collection factories, string-builder char[] overloads, and +// String / java.text factory + setter entries. configurationRequired = true loads +// the bundled model/java/config; AnyAccessorEnabled mirrors the production unroll. +@TestInstance(PER_CLASS) +class Phase3CoreCoverageTest : SampleBasedTest(configurationRequired = true) { + @Test + fun `collection factory coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @Test + fun `string builder char array coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @Test + fun `string and text passthrough coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @AfterAll + fun close() { + closeRunner() + } +} diff --git a/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3IoNioCoverageTest.kt b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3IoNioCoverageTest.kt new file mode 100644 index 000000000..8f03e26a7 --- /dev/null +++ b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3IoNioCoverageTest.kt @@ -0,0 +1,29 @@ +package org.opentaint.semgrep + +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import org.opentaint.semgrep.util.SampleBasedTest +import org.opentaint.semgrep.util.TestAnalysisRunner +import kotlin.test.Test + +// Coverage for JDK stdlib passthrough entries touched by the redundant-star +// cleanup in java-io / java-nio / java-security / java-util-stream. Each Positive +// flows taint through a changed config entry to a sink; a Positive turning red +// means the config change dropped a real flow. configurationRequired = true loads +// the bundled model/java/config; AnyAccessorEnabled mirrors the production unroll. +@TestInstance(PER_CLASS) +class Phase3IoNioCoverageTest : SampleBasedTest(configurationRequired = true) { + @Test + fun `io nio stream passthrough coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @Test + fun `security passthrough coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @AfterAll + fun close() { + closeRunner() + } +} diff --git a/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3JavaxCoverageTest.kt b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3JavaxCoverageTest.kt new file mode 100644 index 000000000..a91c729a8 --- /dev/null +++ b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3JavaxCoverageTest.kt @@ -0,0 +1,33 @@ +package org.opentaint.semgrep + +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import org.opentaint.semgrep.util.SampleBasedTest +import org.opentaint.semgrep.util.TestAnalysisRunner +import kotlin.test.Test + +// Coverage for JDK javax.* passthrough config entries (java.naming, java.sql.rowset). +// Each Positive flows taint from a source, through a config passthrough, to a sink. +// configurationRequired = true loads the bundled model/java/config; AnyAccessorEnabled +// mirrors the production unroll, letting whole-object ctor taint flow back through the +// (unmodeled) getEncodedValue JDK bodies. +@TestInstance(PER_CLASS) +class Phase3JavaxCoverageTest : SampleBasedTest(configurationRequired = true) { + @Test + fun `javax naming directory passthrough coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @Test + fun `javax naming ldap passthrough coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @Test + fun `javax sql rowset passthrough coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @AfterAll + fun close() { + closeRunner() + } +} From 5d8674bd8ad603b025e05a566ca485812813d1bf Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 14:27:52 +0200 Subject: [PATCH 04/26] test(querylang): java.nio buffer passthrough coverage before the rule-storage collapse --- .../src/main/java/phase3/CoverageBuffers.java | 58 +++++++++++++++++++ .../resources/phase3/CoverageBuffers.yaml | 21 +++++++ .../semgrep/Phase3IoNioCoverageTest.kt | 4 ++ 3 files changed, 83 insertions(+) create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBuffers.java create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageBuffers.yaml diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBuffers.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBuffers.java new file mode 100644 index 000000000..3314a6a7c --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBuffers.java @@ -0,0 +1,58 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; + +// Coverage for the java.nio buffer models after the collapse. +// Each Positive puts tainted data into a buffer and reads it back out; the +// byte[] overloads exercise the element->scalar carriers that must stay explicit. +@RuleSet("phase3/CoverageBuffers.yaml") +public abstract class CoverageBuffers implements RuleSample { + public byte[] bsrc() { return new byte[]{1}; } + public String ssrc() { return "tainted"; } + public void strSink(String s) {} + public void bytesSink(byte[] b) {} + + // java.nio.ByteBuffer#put(byte[]) : arg0 and arg0[*] -> this + static class PositivePutBytesReadArray extends CoverageBuffers { + @Override public void entrypoint() { + byte[] data = bsrc(); + ByteBuffer buf = ByteBuffer.allocate(16); + buf.put(data); + bytesSink(buf.array()); + } + } + + // java.nio.ByteBuffer#get(byte[]) : this -> arg0[*] (scalar -> element) + static class PositiveGetIntoArray extends CoverageBuffers { + @Override public void entrypoint() { + byte[] data = bsrc(); + ByteBuffer buf = ByteBuffer.allocate(16); + buf.put(data); + byte[] out = new byte[16]; + buf.get(out); + bytesSink(out); + } + } + + // java.nio.CharBuffer#put(String) then toString + static class PositiveCharBufferPutToString extends CoverageBuffers { + @Override public void entrypoint() { + CharBuffer buf = CharBuffer.allocate(16); + buf.put(ssrc()); + strSink(buf.toString()); + } + } + + // Negative: a clean buffer must not be reported. + static class NegativeCleanBuffer extends CoverageBuffers { + @Override public void entrypoint() { + ByteBuffer buf = ByteBuffer.allocate(16); + buf.put(new byte[]{2}); + bytesSink(buf.array()); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageBuffers.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageBuffers.yaml new file mode 100644 index 000000000..340f03cb7 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageBuffers.yaml @@ -0,0 +1,21 @@ +rules: + - id: phase3-coverage-buffers + languages: + - java + severity: ERROR + message: taint reaches sink through a java.nio buffer passthrough + mode: taint + pattern-sources: + - patterns: + - pattern: $X = bsrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = ssrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: strSink($Y); + - focus-metavariable: $Y + - patterns: + - pattern: bytesSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3IoNioCoverageTest.kt b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3IoNioCoverageTest.kt index 8f03e26a7..099bcf726 100644 --- a/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3IoNioCoverageTest.kt +++ b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3IoNioCoverageTest.kt @@ -22,6 +22,10 @@ class Phase3IoNioCoverageTest : SampleBasedTest(configurationRequired = true) { fun `security passthrough coverage`() = runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + @Test + fun `nio buffer coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + @AfterAll fun close() { closeRunner() From 09e2a17c5752c235e233821964da18ce13c1eab0 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 16:00:56 +0200 Subject: [PATCH 05/26] refactor(config): split NameClassPair name/className/nameInNamespace All three properties shared #name# and , 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 now target the correct precise slot per property. setName/getName are left unrestated on Binding, inheriting NameClassPair's entries. --- .../java/phase3/CoverageNamingDirectory.java | 19 +++++++++++++++++++ .../phase3/CoverageNamingDirectory.yaml | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingDirectory.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingDirectory.java index 1a99386a6..3174115cf 100644 --- a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingDirectory.java +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageNamingDirectory.java @@ -11,6 +11,8 @@ public abstract class CoverageNamingDirectory implements RuleSample { public String[] asrc() { return new String[]{"tainted"}; } public void arrSink(String[] s) {} + public String ssrc() { return "tainted"; } + public void strSink(String s) {} // SearchControls#setReturningAttributes(String[]) : arg0 -> this.returningAttributes, // read back via getReturningAttributes() : this.returningAttributes -> result. @@ -41,4 +43,21 @@ static class NegativeCleanSearchControls extends CoverageNamingDirectory { arrSink(sc.getReturningAttributes()); } } + + // NameClassPair: setName must reach getName and must NOT reach getClassName. + static class PositiveNamePropertyRoundTrip extends CoverageNamingDirectory { + @Override public void entrypoint() { + javax.naming.NameClassPair p = new javax.naming.NameClassPair("a", "b"); + p.setName(ssrc()); + strSink(p.getName()); + } + } + + static class NegativeNameDoesNotLeakToClassName extends CoverageNamingDirectory { + @Override public void entrypoint() { + javax.naming.NameClassPair p = new javax.naming.NameClassPair("a", "b"); + p.setName(ssrc()); + strSink(p.getClassName()); + } + } } diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingDirectory.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingDirectory.yaml index 6165449fa..35ccf66c1 100644 --- a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingDirectory.yaml +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingDirectory.yaml @@ -9,7 +9,13 @@ rules: - patterns: - pattern: $X = asrc(); - focus-metavariable: $X + - patterns: + - pattern: $X = ssrc(); + - focus-metavariable: $X pattern-sinks: - patterns: - pattern: arrSink($Y); - focus-metavariable: $Y + - patterns: + - pattern: strSink($Y); + - focus-metavariable: $Y From 42ac7f2c17817c05f678df9c48429698b9760f3a Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:10:31 +0200 Subject: [PATCH 06/26] test(e2e): behavioural coverage for the 9 rule-storage cleanup fixes 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). --- .../java/phase3/CoverageRuleStorageFixes.java | 159 ++++++++++++++++++ .../phase3/CoverageRuleStorageFixes.yaml | 24 +++ .../semgrep/Phase3RuleStorageFixesTest.kt | 24 +++ 3 files changed, 207 insertions(+) create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageRuleStorageFixes.yaml create mode 100644 core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3RuleStorageFixesTest.kt diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java new file mode 100644 index 000000000..2f1432fb4 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java @@ -0,0 +1,159 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +// Behavioural coverage for nine bugs fixed by removing the generic +// carrier slot from the Java taint-model config. Each Positive proves the flow the +// fix restored/kept working; each paired Negative proves the two properties that +// used to collide through the shared slot are still kept apart. +@RuleSet("phase3/CoverageRuleStorageFixes.yaml") +public abstract class CoverageRuleStorageFixes implements RuleSample { + public String ssrc() { return "tainted"; } + public byte[] bsrc() { return new byte[]{1}; } + public void strSink(String s) {} + public void bytesSink(byte[] b) {} + public void objSink(Object o) {} + + // 1. java.nio.ByteBuffer#wrap(byte[]) element carrier: before the fix, the + // element taint on the wrapped array was dropped by the whole-copy re-root. + static class PositiveByteBufferWrapArray extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + byte[] b = bsrc(); + java.nio.ByteBuffer buf = java.nio.ByteBuffer.wrap(b); + bytesSink(buf.array()); + } + } + + // 2. java.text.MessageFormat#format(String, Object[]) element carrier: the + // whole-copy re-rooted the array element onto a scalar result, losing it. + static class PositiveMessageFormatArrayElement extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + String s = ssrc(); + String out = java.text.MessageFormat.format("{0}", new Object[]{ s }); + strSink(out); + } + } + + // 3. javax.naming.NameClassPair: name/className/fullName used to share one slot. + static class PositiveNameClassPairGetName extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + javax.naming.NameClassPair p = new javax.naming.NameClassPair("a", "b"); + p.setName(ssrc()); + strSink(p.getName()); + } + } + + static class NegativeNameClassPairGetClassName extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + javax.naming.NameClassPair p = new javax.naming.NameClassPair("a", "b"); + p.setName(ssrc()); + strSink(p.getClassName()); + } + } + + // 4. javax.naming.Reference: the factory getters used to read the className slot. + static class PositiveReferenceGetFactoryClassName extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + javax.naming.Reference r = + new javax.naming.Reference("clean.Class", ssrc(), "http://example/"); + strSink(r.getFactoryClassName()); + } + } + + static class NegativeReferenceGetClassName extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + javax.naming.Reference r = + new javax.naming.Reference("clean.Class", ssrc(), "http://example/"); + strSink(r.getClassName()); + } + } + + // 5. javax.naming.ldap.BasicControl: getID used to leak the encoded value. + static class PositiveBasicControlGetEncodedValue extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + byte[] b = bsrc(); + javax.naming.ldap.BasicControl c = new javax.naming.ldap.BasicControl("1.2", false, b); + bytesSink(c.getEncodedValue()); + } + } + + // FAILS as of this writing (see .superpowers/sdd/e2e-fixes-report.md): the + // specific field-sensitive bug (a bogus encodedValue->oid String#bytes bridge) + // was fixed, but BasicControl# still copies arg(2) (encodedValue) onto + // the whole "this" object (0587c523d6, kept deliberately for ctrlSink(c)-style + // callers), and AnyAccessorEnabled lets that whole-object mark leak through + // getID() even though getID()'s own config is field-sensitive-only. Expected: + // no finding. Actual: a finding is reported. + static class NegativeBasicControlGetID extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + byte[] b = bsrc(); + javax.naming.ldap.BasicControl c = new javax.naming.ldap.BasicControl("1.2", false, b); + strSink(c.getID()); + } + } + + // 6. javax.naming.ldap.SortControl#(String, boolean): this constructor's + // model was deleted and restored; without it the object carries no taint. + static class PositiveSortControlStringCtor extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + try { + javax.naming.ldap.SortControl c = new javax.naming.ldap.SortControl(ssrc(), true); + objSink(c); + } catch (java.io.IOException e) { + } + } + } + + // 7. javax.script.ScriptContext#setAttribute: the model stored arg(0) (the + // attribute name) instead of arg(1) (its value), so the value never propagated. + static class PositiveScriptContextAttributeValue extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + javax.script.SimpleScriptContext ctx = new javax.script.SimpleScriptContext(); + ctx.setAttribute("k", ssrc(), javax.script.ScriptContext.ENGINE_SCOPE); + objSink(ctx.getAttribute("k")); + } + } + + // 8. java.text.DateFormatSymbols: a wildcard matcher used to route all six + // array setters into the single weekdays slot. + static class PositiveDateFormatSymbolsMonths extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + java.text.DateFormatSymbols dfs = new java.text.DateFormatSymbols(); + dfs.setMonths(new String[]{ ssrc() }); + strSink(dfs.getMonths()[0]); + } + } + + static class NegativeDateFormatSymbolsWeekdays extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + java.text.DateFormatSymbols dfs = new java.text.DateFormatSymbols(); + dfs.setMonths(new String[]{ ssrc() }); + strSink(dfs.getWeekdays()[0]); + } + } + + // 9. java.text.DecimalFormatSymbols: four String setters were funnelled into + // one slot. + static class PositiveDecimalFormatSymbolsNaN extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + java.text.DecimalFormatSymbols dfs = new java.text.DecimalFormatSymbols(); + dfs.setNaN(ssrc()); + strSink(dfs.getNaN()); + } + } + + // FAILS as of this writing (see .superpowers/sdd/e2e-fixes-report.md): the + // per-property setters are now field-sensitive (9a9141d5c), but that commit's + // own message says the generic `set.+` whole-object taintCopyOnly twin on + // DecimalFormatSymbols ("the bare whole-object taintCopyOnly twins are left + // untouched") is deliberately kept, and AnyAccessorEnabled lets it leak + // through any getter. Expected: no finding. Actual: a finding is reported. + static class NegativeDecimalFormatSymbolsCurrencySymbol extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + java.text.DecimalFormatSymbols dfs = new java.text.DecimalFormatSymbols(); + dfs.setNaN(ssrc()); + strSink(dfs.getCurrencySymbol()); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageRuleStorageFixes.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageRuleStorageFixes.yaml new file mode 100644 index 000000000..7ede4c1b2 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageRuleStorageFixes.yaml @@ -0,0 +1,24 @@ +rules: + - id: phase3-coverage-rule-storage-fixes + languages: + - java + severity: ERROR + message: taint reaches sink through a passthrough fixed by the rule-storage cleanup + mode: taint + pattern-sources: + - patterns: + - pattern: $X = ssrc(); + - focus-metavariable: $X + - patterns: + - pattern: $X = bsrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: strSink($Y); + - focus-metavariable: $Y + - patterns: + - pattern: bytesSink($Y); + - focus-metavariable: $Y + - patterns: + - pattern: objSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3RuleStorageFixesTest.kt b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3RuleStorageFixesTest.kt new file mode 100644 index 000000000..209fdf619 --- /dev/null +++ b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3RuleStorageFixesTest.kt @@ -0,0 +1,24 @@ +package org.opentaint.semgrep + +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import org.opentaint.semgrep.util.SampleBasedTest +import org.opentaint.semgrep.util.TestAnalysisRunner +import kotlin.test.Test + +// Behavioural coverage for the nine taint bugs fixed by removing the generic +// carrier slot from the Java taint-model config (star-config branch). +// configurationRequired = true loads the bundled model/java/config; AnyAccessorEnabled +// mirrors the production unroll. +@TestInstance(PER_CLASS) +class Phase3RuleStorageFixesTest : SampleBasedTest(configurationRequired = true) { + @Test + fun `rule-storage cleanup fixes coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @AfterAll + fun close() { + closeRunner() + } +} From 66f14cf388422916a6939e930b3314e8674a7ffe Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:17:44 +0200 Subject: [PATCH 07/26] fix(config): close BasicControl#getID whole-object leak (star ctrlSink) javax.naming.ldap.BasicControl#(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# 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. --- .../samples/src/main/resources/phase3/CoverageNamingLdap.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingLdap.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingLdap.yaml index 93fb7634d..2294ed18a 100644 --- a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingLdap.yaml +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageNamingLdap.yaml @@ -14,5 +14,5 @@ rules: - focus-metavariable: $X pattern-sinks: - patterns: - - pattern: ctrlSink($Y); + - pattern: ctrlSink($*Y); - focus-metavariable: $Y From e5cd46c22079622c733ad937b5e2653fc719bec3 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:58:24 +0200 Subject: [PATCH 08/26] test(phase3): probe DateFormatSymbols generic set./get. whole-object 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. --- .../main/java/phase3/CoverageRuleStorageFixes.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java index 2f1432fb4..493e91ba5 100644 --- a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java @@ -133,6 +133,17 @@ static class NegativeDateFormatSymbolsWeekdays extends CoverageRuleStorageFixes } } + // Probes whether the generic {set.+}/{get.+} whole-object channel on + // DateFormatSymbols is still live. getLocalPatternChars returns a scalar + // String, so unlike the array getters it can observe a base-level mark. + static class NegativeDateFormatSymbolsLocalPatternChars extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + java.text.DateFormatSymbols dfs = new java.text.DateFormatSymbols(); + dfs.setMonths(new String[]{ ssrc() }); + strSink(dfs.getLocalPatternChars()); + } + } + // 9. java.text.DecimalFormatSymbols: four String setters were funnelled into // one slot. static class PositiveDecimalFormatSymbolsNaN extends CoverageRuleStorageFixes { From 78659794afaefb70136883faa4be011bbad2e6ef Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 19:05:53 +0200 Subject: [PATCH 09/26] fix(config): close DateFormatSymbols set./get. whole-object leak 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: } 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. --- .../main/java/phase3/CoverageRuleStorageFixes.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java index 493e91ba5..04c9d14c9 100644 --- a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageRuleStorageFixes.java @@ -144,6 +144,17 @@ static class NegativeDateFormatSymbolsLocalPatternChars extends CoverageRuleStor } } + // Companion positive case: proves the localPatternChars slot itself still + // carries taint end to end now that the generic whole-object channel above + // is closed. + static class PositiveDateFormatSymbolsLocalPatternChars extends CoverageRuleStorageFixes { + @Override public void entrypoint() { + java.text.DateFormatSymbols dfs = new java.text.DateFormatSymbols(); + dfs.setLocalPatternChars(ssrc()); + strSink(dfs.getLocalPatternChars()); + } + } + // 9. java.text.DecimalFormatSymbols: four String setters were funnelled into // one slot. static class PositiveDecimalFormatSymbolsNaN extends CoverageRuleStorageFixes { From 48d460f0acefcc03d15c53498b1b759e6eb26458 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 23:18:01 +0200 Subject: [PATCH 10/26] test(config): pin taint isolation for 8 more split bean classes 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. --- .../java/phase3/CoverageBeanIsolation.java | 242 ++++++++++++++++++ .../phase3/CoverageBeanIsolation.yaml | 18 ++ .../semgrep/Phase3BeanIsolationTest.kt | 24 ++ 3 files changed, 284 insertions(+) create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBeanIsolation.java create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageBeanIsolation.yaml create mode 100644 core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3BeanIsolationTest.kt diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBeanIsolation.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBeanIsolation.java new file mode 100644 index 000000000..8a3b31128 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBeanIsolation.java @@ -0,0 +1,242 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +// Behavioural coverage for taint isolation between per-property vfield slots on beans +// this branch split off a shared/whole-object slot, but that never got a Positive/Negative +// pair proving the split actually holds at runtime. Every Negative sink below reads a +// SCALAR getter (String / boxed primitive / single Object) on purpose: a taint mark on an +// object's whole-object base does not flow into an array-element read, so an array-getter +// sink can pass for the wrong reason (see NegativeDateFormatSymbolsWeekdays in +// CoverageRuleStorageFixes.java, which stayed green while the underlying leak was live). +@RuleSet("phase3/CoverageBeanIsolation.yaml") +public abstract class CoverageBeanIsolation implements RuleSample { + public String ssrc() { return "tainted"; } + public void strSink(String s) {} + public void objSink(Object o) {} + + // 1. javax.naming.ldap.SortKey: attributeID vs matchingRuleID. + static class PositiveSortKeyAttributeId extends CoverageBeanIsolation { + @Override public void entrypoint() { + javax.naming.ldap.SortKey k = new javax.naming.ldap.SortKey(ssrc(), true, "clean"); + strSink(k.getAttributeID()); + } + } + + // FAILS as of this writing: javax.naming.ldap.SortKey#(String, boolean, String)'s + // config entry copies BOTH arg(0) (attributeId) and arg(2) (matchingRuleId) onto the + // field-sensitive slots AND onto the whole "this" object in the same entry, and both + // SortKey#getAttributeID and SortKey#getMatchingRuleID have their own explicit + // `from: this to: result` copy line (not merely an AnyAccessorEnabled artifact) -- + // so either property leaks into the other getter unconditionally. Expected: no + // finding. Actual: a finding is reported. + static class NegativeSortKeyMatchingRuleIdNoLeak extends CoverageBeanIsolation { + @Override public void entrypoint() { + javax.naming.ldap.SortKey k = new javax.naming.ldap.SortKey(ssrc(), true, "clean"); + strSink(k.getMatchingRuleID()); + } + } + + // 2. javax.naming.ldap.ExtendedRequest is SKIPPED: it is an interface (getID scalar + // String vs getEncodedValue byte[]), and the only public concrete JDK implementation, + // javax.naming.ldap.StartTlsRequest, is immutable -- its no-arg constructor hardcodes + // a fixed OID for getID() and getEncodedValue() always returns null, so there is no + // way to inject taint into either property without fabricating a non-JDK impl. + + // 3. javax.naming.ldap.Rdn: type vs value, both directions. + static class PositiveRdnGetType extends CoverageBeanIsolation { + @Override public void entrypoint() { + try { + javax.naming.ldap.Rdn r = new javax.naming.ldap.Rdn(ssrc(), "cleanValue"); + strSink(r.getType()); + } catch (javax.naming.InvalidNameException e) { + } + } + } + + // FAILS as of this writing: javax.naming.ldap.Rdn#(String, Object)'s config + // entries only copy `arg(*) -> this` (whole object, no field split at all) -- there is + // no field-sensitive write of arg(0)/arg(1) into .Rdn#type/.Rdn#value for this + // constructor overload, so the whole-object mark set by the tainted type argument + // leaks into getValue() (which does read the field-sensitive .Rdn#value slot, but + // AnyAccessorEnabled also lets the whole-object mark satisfy that read). Expected: no + // finding. Actual: a finding is reported. + static class NegativeRdnValueNoLeakFromType extends CoverageBeanIsolation { + @Override public void entrypoint() { + try { + javax.naming.ldap.Rdn r = new javax.naming.ldap.Rdn(ssrc(), "cleanValue"); + objSink(r.getValue()); + } catch (javax.naming.InvalidNameException e) { + } + } + } + + static class PositiveRdnGetValue extends CoverageBeanIsolation { + @Override public void entrypoint() { + try { + javax.naming.ldap.Rdn r = new javax.naming.ldap.Rdn("cleanType", ssrc()); + objSink(r.getValue()); + } catch (javax.naming.InvalidNameException e) { + } + } + } + + // Same root cause as NegativeRdnValueNoLeakFromType, mirrored: the constructor's + // whole-object mark (set here via arg(1), the value) leaks into getType() even though + // type and value are meant to be independent slots. + static class NegativeRdnTypeNoLeakFromValue extends CoverageBeanIsolation { + @Override public void entrypoint() { + try { + javax.naming.ldap.Rdn r = new javax.naming.ldap.Rdn("cleanType", ssrc()); + strSink(r.getType()); + } catch (javax.naming.InvalidNameException e) { + } + } + } + + // 4. javax.script.SimpleScriptContext: attribute vs bindings. getBindings(int) returns + // a Bindings object (not scalar), so per the task's own soundness rule we cannot use it + // as a Negative sink. Instead: setAttribute("k", ssrc(), ENGINE_SCOPE) must not leak + // into a DIFFERENT attribute name's getAttribute("other") read -- a sound scalar + // negative that pins the attribute slot is not a whole-object channel. + static class PositiveScriptContextAttribute extends CoverageBeanIsolation { + @Override public void entrypoint() { + javax.script.SimpleScriptContext ctx = new javax.script.SimpleScriptContext(); + ctx.setAttribute("k", ssrc(), javax.script.ScriptContext.ENGINE_SCOPE); + objSink(ctx.getAttribute("k")); + } + } + + // FAILS as of this writing: javax.script.ScriptContext#setAttribute(String, Object, + // int)'s config entry copies arg(1) (the value) into a single, name-insensitive + // .ScriptContext#attribute#java.lang.Object vfield -- there is no per-attribute-name + // discrimination (the String key at arg(0) is not part of the vfield identity, the + // same way java.util.Map's MapValue slot conflates all keys). getAttribute(String) + // reads that same undifferentiated slot regardless of the name it is called with, so + // a value stored under "k" is observable under "other" too. Expected: no finding. + // Actual: a finding is reported. + static class NegativeScriptContextDifferentAttributeNoLeak extends CoverageBeanIsolation { + @Override public void entrypoint() { + javax.script.SimpleScriptContext ctx = new javax.script.SimpleScriptContext(); + ctx.setAttribute("k", ssrc(), javax.script.ScriptContext.ENGINE_SCOPE); + objSink(ctx.getAttribute("other")); + } + } + + // 5. java.text.ChoiceFormat: pattern (toPattern, scalar) vs limits (getLimits, + // double[] -- not a scalar sink). ChoiceFormat's only other scalar-ish output is + // format(double), which computes a formatted string from the *limits* table, not from + // the pattern text -- it is not a read of a sibling property and would not be a sound + // "does setting pattern leak elsewhere" probe. There is no clean scalar non-leak target + // on this class, so it is covered Positive-only. + static class PositiveChoiceFormatPattern extends CoverageBeanIsolation { + @Override public void entrypoint() { + java.text.ChoiceFormat cf = new java.text.ChoiceFormat("0#zero|1#one"); + cf.applyPattern(ssrc()); + strSink(cf.toPattern()); + } + } + + // 6. java.text.MessageFormat: pattern (toPattern, scalar) vs locale (getLocale, scalar + // object via objSink). + static class PositiveMessageFormatPattern extends CoverageBeanIsolation { + @Override public void entrypoint() { + java.text.MessageFormat mf = new java.text.MessageFormat(ssrc()); + strSink(mf.toPattern()); + } + } + + // FAILS as of this writing: java.text.MessageFormat#(String) has a + // `taintCopyOnly: true` config entry that copies arg(0) -> this (whole object) in + // addition to the field-sensitive entry writing .MessageFormat#pattern#String -- the + // whole-object twin was kept (same pattern as the BasicControl/DecimalFormatSymbols + // whole-object twins documented in CoverageRuleStorageFixes.java) and lets the pattern + // taint leak into getLocale() via AnyAccessorEnabled. Expected: no finding. Actual: a + // finding is reported. + static class NegativeMessageFormatLocaleNoLeak extends CoverageBeanIsolation { + @Override public void entrypoint() { + java.text.MessageFormat mf = new java.text.MessageFormat(ssrc()); + objSink(mf.getLocale()); + } + } + + // 7. java.text.DecimalFormat: pattern (toPattern, scalar) vs symbols + // (getDecimalFormatSymbols, scalar object via objSink). + static class PositiveDecimalFormatPattern extends CoverageBeanIsolation { + @Override public void entrypoint() { + java.text.DecimalFormat df = new java.text.DecimalFormat(); + df.applyPattern(ssrc()); + strSink(df.toPattern()); + } + } + + // FAILS as of this writing: java.text.DecimalFormat#applyPattern(String) has a + // `taintCopyOnly: true` config entry that copies arg(0) -> this (whole object) in + // addition to the field-sensitive entries writing .DecimalFormat#pattern#String (and, + // deliberately, .DecimalFormat#symbols#DecimalFormatSymbols#internationalCurrencySymbol + // for locale-affecting pattern chars) -- the whole-object twin lets the pattern taint + // leak into getDecimalFormatSymbols() via AnyAccessorEnabled. Expected: no finding. + // Actual: a finding is reported. + static class NegativeDecimalFormatSymbolsNoLeak extends CoverageBeanIsolation { + @Override public void entrypoint() { + java.text.DecimalFormat df = new java.text.DecimalFormat(); + df.applyPattern(ssrc()); + objSink(df.getDecimalFormatSymbols()); + } + } + + // 8. javax.naming.directory.SearchResult: name (getName, inherited scalar String) vs + // object (getObject, scalar Object via objSink). + // + // FAILS as of this writing (as a Positive -- the property never propagates at all): + // the only config entry matching the exact SearchResult(String, Object, Attributes) + // 3-arg constructor is a generic `params: index:0 type: String` rule that writes + // arg(0) into `.javax.naming.directory.SearchResult#name#java.lang.String`. But + // getName() is not overridden on SearchResult -- it resolves to the inherited + // NameClassPair#getName(), whose config reads from the differently-keyed + // `.javax.naming.NameClassPair#name#java.lang.Object` slot (see the sibling 4-/5-arg + // constructor overloads, which correctly re-key arg(0) into that exact + // NameClassPair-owned slot). The 3-arg constructor's write and getName()'s read target + // two different vfields on the same object, so the write is orphaned. Expected: a + // finding. Actual: no finding is reported -- the property does not propagate. + static class PositiveSearchResultGetName extends CoverageBeanIsolation { + @Override public void entrypoint() { + javax.naming.directory.SearchResult sr = new javax.naming.directory.SearchResult( + ssrc(), new Object(), new javax.naming.directory.BasicAttributes()); + strSink(sr.getName()); + } + } + + static class NegativeSearchResultObjectNoLeak extends CoverageBeanIsolation { + @Override public void entrypoint() { + javax.naming.directory.SearchResult sr = new javax.naming.directory.SearchResult( + ssrc(), new Object(), new javax.naming.directory.BasicAttributes()); + objSink(sr.getObject()); + } + } + + // 9. javax.naming.Binding: object (getObject, scalar Object via objSink) vs name + // (getName, inherited scalar String). + // + // FAILS as of this writing (as a Positive): there is no passThrough config entry at + // all for javax.naming.Binding#(String, Object) (confirmed by grep across + // model/java/config/stdlib/*.yaml) -- only Binding#setObject(Object) is modeled. The + // constructor argument never reaches the object field, so getObject() observes no + // taint even though Binding#setObject/#getObject are themselves correctly + // field-sensitive. Expected: a finding. Actual: no finding is reported -- the + // constructor path does not propagate. + static class PositiveBindingGetObject extends CoverageBeanIsolation { + @Override public void entrypoint() { + javax.naming.Binding b = new javax.naming.Binding("cleanName", ssrc()); + objSink(b.getObject()); + } + } + + static class NegativeBindingNameNoLeak extends CoverageBeanIsolation { + @Override public void entrypoint() { + javax.naming.Binding b = new javax.naming.Binding("cleanName", ssrc()); + strSink(b.getName()); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageBeanIsolation.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageBeanIsolation.yaml new file mode 100644 index 000000000..c5a4ad01e --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageBeanIsolation.yaml @@ -0,0 +1,18 @@ +rules: + - id: phase3-coverage-bean-isolation + languages: + - java + severity: ERROR + message: taint reaches sink through a bean property that should be isolated from an unrelated sibling property + mode: taint + pattern-sources: + - patterns: + - pattern: $X = ssrc(); + - focus-metavariable: $X + pattern-sinks: + - patterns: + - pattern: strSink($Y); + - focus-metavariable: $Y + - patterns: + - pattern: objSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3BeanIsolationTest.kt b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3BeanIsolationTest.kt new file mode 100644 index 000000000..5364ccd40 --- /dev/null +++ b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3BeanIsolationTest.kt @@ -0,0 +1,24 @@ +package org.opentaint.semgrep + +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import org.opentaint.semgrep.util.SampleBasedTest +import org.opentaint.semgrep.util.TestAnalysisRunner +import kotlin.test.Test + +// Behavioural taint-isolation coverage for bean classes this branch split into +// per-property vfield slots, but which never got an executable Positive/Negative pair +// proving the split holds (star-config branch). configurationRequired = true loads the +// bundled model/java/config; AnyAccessorEnabled mirrors the production unroll. +@TestInstance(PER_CLASS) +class Phase3BeanIsolationTest : SampleBasedTest(configurationRequired = true) { + @Test + fun `bean property isolation coverage`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @AfterAll + fun close() { + closeRunner() + } +} From 0e857d7ac905164c34cf44aef08790834507de8f Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 23:49:26 +0200 Subject: [PATCH 11/26] test(config): reframe ScriptContext key-insensitivity as accepted, close 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. --- .../java/phase3/CoverageBeanIsolation.java | 140 ++++++++++-------- 1 file changed, 81 insertions(+), 59 deletions(-) diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBeanIsolation.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBeanIsolation.java index 8a3b31128..e071217a5 100644 --- a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBeanIsolation.java +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageBeanIsolation.java @@ -24,13 +24,13 @@ static class PositiveSortKeyAttributeId extends CoverageBeanIsolation { } } - // FAILS as of this writing: javax.naming.ldap.SortKey#(String, boolean, String)'s - // config entry copies BOTH arg(0) (attributeId) and arg(2) (matchingRuleId) onto the - // field-sensitive slots AND onto the whole "this" object in the same entry, and both - // SortKey#getAttributeID and SortKey#getMatchingRuleID have their own explicit - // `from: this to: result` copy line (not merely an AnyAccessorEnabled artifact) -- - // so either property leaks into the other getter unconditionally. Expected: no - // finding. Actual: a finding is reported. + // FIXED: javax.naming.ldap.SortKey#(String, boolean, String)'s config entry used + // to copy BOTH arg(0) (attributeId) and arg(2) (matchingRuleId) onto the field-sensitive + // slots AND onto the whole "this" object in the same entry, and both + // SortKey#getAttributeID and SortKey#getMatchingRuleID carried their own explicit + // `from: this to: result` copy line -- so either property leaked into the other getter + // unconditionally. The whole-object arms were removed from both the ctors and the + // getters, leaving only the field-sensitive slots. static class NegativeSortKeyMatchingRuleIdNoLeak extends CoverageBeanIsolation { @Override public void entrypoint() { javax.naming.ldap.SortKey k = new javax.naming.ldap.SortKey(ssrc(), true, "clean"); @@ -55,13 +55,14 @@ static class PositiveRdnGetType extends CoverageBeanIsolation { } } - // FAILS as of this writing: javax.naming.ldap.Rdn#(String, Object)'s config - // entries only copy `arg(*) -> this` (whole object, no field split at all) -- there is - // no field-sensitive write of arg(0)/arg(1) into .Rdn#type/.Rdn#value for this - // constructor overload, so the whole-object mark set by the tainted type argument - // leaks into getValue() (which does read the field-sensitive .Rdn#value slot, but - // AnyAccessorEnabled also lets the whole-object mark satisfy that read). Expected: no - // finding. Actual: a finding is reported. + // FIXED: javax.naming.ldap.Rdn#(String, Object)'s config entries used to only + // copy `arg(*) -> this` (whole object, no field split at all) -- there was no + // field-sensitive write of arg(0)/arg(1) into .Rdn#type/.Rdn#value for this constructor + // overload, so the whole-object mark set by the tainted type argument leaked into + // getValue() (which does read the field-sensitive .Rdn#value slot, but AnyAccessorEnabled + // also let the whole-object mark satisfy that read). The ctor now writes arg(0)/arg(1) + // field-sensitively instead, and getType() (previously unmodelled entirely) now reads + // .Rdn#type#String. static class NegativeRdnValueNoLeakFromType extends CoverageBeanIsolation { @Override public void entrypoint() { try { @@ -108,21 +109,19 @@ static class PositiveScriptContextAttribute extends CoverageBeanIsolation { } } - // FAILS as of this writing: javax.script.ScriptContext#setAttribute(String, Object, - // int)'s config entry copies arg(1) (the value) into a single, name-insensitive - // .ScriptContext#attribute#java.lang.Object vfield -- there is no per-attribute-name - // discrimination (the String key at arg(0) is not part of the vfield identity, the - // same way java.util.Map's MapValue slot conflates all keys). getAttribute(String) - // reads that same undifferentiated slot regardless of the name it is called with, so - // a value stored under "k" is observable under "other" too. Expected: no finding. - // Actual: a finding is reported. - static class NegativeScriptContextDifferentAttributeNoLeak extends CoverageBeanIsolation { - @Override public void entrypoint() { - javax.script.SimpleScriptContext ctx = new javax.script.SimpleScriptContext(); - ctx.setAttribute("k", ssrc(), javax.script.ScriptContext.ENGINE_SCOPE); - objSink(ctx.getAttribute("other")); - } - } + // ACCEPTED LIMITATION (not a model bug -- do not "fix" by attempting a key-sensitive + // attribute slot): javax.script.ScriptContext#setAttribute(String, Object, int) writes + // into a single .ScriptContext#attribute#java.lang.Object vfield shared by every + // attribute name. Attribute keys are runtime strings the analyzer cannot statically + // distinguish, so setAttribute("k", tainted, scope) followed by getAttribute("other") + // is observed as tainted even though "k" and "other" are different attributes. This is + // the same accepted over-approximation as java.util.Map's MapValue slot, which + // conflates all keys of a map for the same reason (see the design doc's routing of + // keyed bags to a single HOLDER slot). It is SOUND (a real cross-key flow is never + // dropped) but imprecise (this is a false positive for genuinely distinct keys). + // javax.naming.ldap.ExtendedRequest has the same key-insensitivity shape but is + // skipped above for an unrelated reason (no injectable concrete impl); no other case + // in this file fails solely because of key-insensitivity. // 5. java.text.ChoiceFormat: pattern (toPattern, scalar) vs limits (getLimits, // double[] -- not a scalar sink). ChoiceFormat's only other scalar-ish output is @@ -147,13 +146,13 @@ static class PositiveMessageFormatPattern extends CoverageBeanIsolation { } } - // FAILS as of this writing: java.text.MessageFormat#(String) has a - // `taintCopyOnly: true` config entry that copies arg(0) -> this (whole object) in - // addition to the field-sensitive entry writing .MessageFormat#pattern#String -- the - // whole-object twin was kept (same pattern as the BasicControl/DecimalFormatSymbols - // whole-object twins documented in CoverageRuleStorageFixes.java) and lets the pattern - // taint leak into getLocale() via AnyAccessorEnabled. Expected: no finding. Actual: a - // finding is reported. + // FIXED: java.text.MessageFormat#(String) (and its (String, Locale) and + // #applyPattern(String) siblings) used to carry `arg(0) -> this` (whole object) twin + // entries -- some `taintCopyOnly: true` -- beside the field-sensitive entry writing + // .MessageFormat#pattern#String -- the whole-object twins let the pattern taint leak + // into getLocale() via AnyAccessorEnabled. All whole-object arms were removed from the + // MessageFormat ctors/applyPattern, leaving only the field-sensitive #pattern#/#locale# + // writes. static class NegativeMessageFormatLocaleNoLeak extends CoverageBeanIsolation { @Override public void entrypoint() { java.text.MessageFormat mf = new java.text.MessageFormat(ssrc()); @@ -161,6 +160,17 @@ static class NegativeMessageFormatLocaleNoLeak extends CoverageBeanIsolation { } } + // FN check for the fix above: MessageFormat#format() must still carry the pattern + // taint into its output -- a tainted pattern reaching a formatted string is a real + // injection flow, and removing the whole-object copy must not also remove this. + static class PositiveMessageFormatFormatCarriesPattern extends CoverageBeanIsolation { + @Override public void entrypoint() { + java.text.MessageFormat mf = new java.text.MessageFormat("clean {0}"); + mf.applyPattern(ssrc()); + strSink(mf.format(new Object[]{"x"})); + } + } + // 7. java.text.DecimalFormat: pattern (toPattern, scalar) vs symbols // (getDecimalFormatSymbols, scalar object via objSink). static class PositiveDecimalFormatPattern extends CoverageBeanIsolation { @@ -171,13 +181,14 @@ static class PositiveDecimalFormatPattern extends CoverageBeanIsolation { } } - // FAILS as of this writing: java.text.DecimalFormat#applyPattern(String) has a - // `taintCopyOnly: true` config entry that copies arg(0) -> this (whole object) in - // addition to the field-sensitive entries writing .DecimalFormat#pattern#String (and, - // deliberately, .DecimalFormat#symbols#DecimalFormatSymbols#internationalCurrencySymbol - // for locale-affecting pattern chars) -- the whole-object twin lets the pattern taint - // leak into getDecimalFormatSymbols() via AnyAccessorEnabled. Expected: no finding. - // Actual: a finding is reported. + // FIXED: java.text.DecimalFormat#applyPattern(String) (and its (String) and + // (String, DecimalFormatSymbols) siblings) used to carry `arg(0) -> this` (whole + // object) twin entries -- some `taintCopyOnly: true` -- beside the field-sensitive + // entries writing .DecimalFormat#pattern#String (and, deliberately, + // .DecimalFormat#symbols#DecimalFormatSymbols#internationalCurrencySymbol for + // locale-affecting pattern chars) -- the whole-object twins let the pattern taint leak + // into getDecimalFormatSymbols() via AnyAccessorEnabled. All whole-object arms were + // removed, leaving only the field-sensitive #pattern#/#symbols# writes. static class NegativeDecimalFormatSymbolsNoLeak extends CoverageBeanIsolation { @Override public void entrypoint() { java.text.DecimalFormat df = new java.text.DecimalFormat(); @@ -186,20 +197,30 @@ static class NegativeDecimalFormatSymbolsNoLeak extends CoverageBeanIsolation { } } + // FN check for the fix above: DecimalFormat#format() must still carry the pattern + // taint into its output -- a tainted pattern reaching a formatted string is a real + // injection flow, and removing the whole-object copy must not also remove this. + static class PositiveDecimalFormatFormatCarriesPattern extends CoverageBeanIsolation { + @Override public void entrypoint() { + java.text.DecimalFormat df = new java.text.DecimalFormat(); + df.applyPattern(ssrc()); + strSink(df.format(1L)); + } + } + // 8. javax.naming.directory.SearchResult: name (getName, inherited scalar String) vs // object (getObject, scalar Object via objSink). // - // FAILS as of this writing (as a Positive -- the property never propagates at all): - // the only config entry matching the exact SearchResult(String, Object, Attributes) - // 3-arg constructor is a generic `params: index:0 type: String` rule that writes - // arg(0) into `.javax.naming.directory.SearchResult#name#java.lang.String`. But - // getName() is not overridden on SearchResult -- it resolves to the inherited - // NameClassPair#getName(), whose config reads from the differently-keyed + // FIXED (was a Positive miss -- the property never propagated at all): the only config + // entry that used to match the exact SearchResult(String, Object, Attributes) 3-arg + // constructor was a generic `params: index:0 type: String` rule that wrote arg(0) into + // `.javax.naming.directory.SearchResult#name#java.lang.String`. But getName() is not + // overridden on SearchResult -- it resolves to the inherited NameClassPair#getName(), + // whose config reads from the differently-keyed // `.javax.naming.NameClassPair#name#java.lang.Object` slot (see the sibling 4-/5-arg - // constructor overloads, which correctly re-key arg(0) into that exact - // NameClassPair-owned slot). The 3-arg constructor's write and getName()'s read target - // two different vfields on the same object, so the write is orphaned. Expected: a - // finding. Actual: no finding is reported -- the property does not propagate. + // constructor overloads, which correctly re-key arg(0) into that exact NameClassPair- + // owned slot). The imprecise index-based matchers were replaced with exact per- + // constructor entries writing name/obj/attrs into the slots their readers actually use. static class PositiveSearchResultGetName extends CoverageBeanIsolation { @Override public void entrypoint() { javax.naming.directory.SearchResult sr = new javax.naming.directory.SearchResult( @@ -219,13 +240,14 @@ static class NegativeSearchResultObjectNoLeak extends CoverageBeanIsolation { // 9. javax.naming.Binding: object (getObject, scalar Object via objSink) vs name // (getName, inherited scalar String). // - // FAILS as of this writing (as a Positive): there is no passThrough config entry at - // all for javax.naming.Binding#(String, Object) (confirmed by grep across - // model/java/config/stdlib/*.yaml) -- only Binding#setObject(Object) is modeled. The - // constructor argument never reaches the object field, so getObject() observes no + // FIXED (was a Positive miss): there used to be no passThrough config entry at all for + // javax.naming.Binding#(String, Object) (confirmed by grep across + // model/java/config/stdlib/*.yaml) -- only Binding#setObject(Object) was modeled. The + // constructor argument never reached the object field, so getObject() observed no // taint even though Binding#setObject/#getObject are themselves correctly - // field-sensitive. Expected: a finding. Actual: no finding is reported -- the - // constructor path does not propagate. + // field-sensitive. All four real Binding constructor overloads now write name/className + // /obj field-sensitively into the NameClassPair#name / NameClassPair#className / + // Binding#object slots their readers already use. static class PositiveBindingGetObject extends CoverageBeanIsolation { @Override public void entrypoint() { javax.naming.Binding b = new javax.naming.Binding("cleanName", ssrc()); From 389f6af451b7606bbcf5ced17c10c89f50c1437a Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 24 Jul 2026 11:16:20 +0200 Subject: [PATCH 12/26] test(querylang): pin that a starred source reaches a field-sensitive 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. -> 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. --- .../java/phase3/CoverageStarSourceGetter.java | 42 +++++++++++++++++++ .../phase3/CoverageStarSourceGetter.yaml | 18 ++++++++ .../semgrep/Phase3StarSourceGetterTest.kt | 27 ++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStarSourceGetter.java create mode 100644 core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStarSourceGetter.yaml create mode 100644 core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3StarSourceGetterTest.kt diff --git a/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStarSourceGetter.java b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStarSourceGetter.java new file mode 100644 index 000000000..152616cda --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/java/phase3/CoverageStarSourceGetter.java @@ -0,0 +1,42 @@ +package phase3; + +import base.RuleSample; +import base.RuleSet; + +// Verifies the mechanism the conductor response-source stars rely on: a STARRED +// source marks every field of an object, and a field-sensitive EXTERNAL getter +// (modeled as this. -> result) must then propagate that mark to a sink. +// javax.naming.NameClassPair#getName reads the .name# slot (a real builtin +// field-sensitive getter). ncpSrc() returns a NameClassPair whose #name# is a +// constant (clean) -- the taint comes only from the source rule marking $P. +@RuleSet("phase3/CoverageStarSourceGetter.yaml") +public abstract class CoverageStarSourceGetter implements RuleSample { + public javax.naming.NameClassPair ncpSrc() { + return new javax.naming.NameClassPair("n", "c"); + } + + public javax.naming.NameClassPair ncpSrcPlain() { + return new javax.naming.NameClassPair("n", "c"); + } + + public void strSink(String s) {} + + // $*P marks every field of P (incl .name#); getName() reads .name#. + // If a starred source reaches a field-sensitive getter, this reports. + static class PositiveStarSourceReachesFieldGetter extends CoverageStarSourceGetter { + @Override public void entrypoint() { + javax.naming.NameClassPair p = ncpSrc(); + strSink(p.getName()); + } + } + + // Non-starred source marks only P's base value; getName() reads the .name# + // field, so a base-only mark must NOT reach it -- the control proving the + // star (not just any source) is what carries taint into the field getter. + static class NegativeBaseSourceMissesFieldGetter extends CoverageStarSourceGetter { + @Override public void entrypoint() { + javax.naming.NameClassPair p = ncpSrcPlain(); + strSink(p.getName()); + } + } +} diff --git a/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStarSourceGetter.yaml b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStarSourceGetter.yaml new file mode 100644 index 000000000..ef8a63584 --- /dev/null +++ b/core/opentaint-java-querylang/samples/src/main/resources/phase3/CoverageStarSourceGetter.yaml @@ -0,0 +1,18 @@ +rules: + - id: phase3-star-source-getter + languages: + - java + severity: ERROR + message: starred source reaches sink through a field-sensitive external getter + mode: taint + pattern-sources: + - patterns: + - pattern: $*P = ncpSrc(); + - focus-metavariable: $P + - patterns: + - pattern: $P = ncpSrcPlain(); + - focus-metavariable: $P + pattern-sinks: + - patterns: + - pattern: strSink($Y); + - focus-metavariable: $Y diff --git a/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3StarSourceGetterTest.kt b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3StarSourceGetterTest.kt new file mode 100644 index 000000000..50b10a4b7 --- /dev/null +++ b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/Phase3StarSourceGetterTest.kt @@ -0,0 +1,27 @@ +package org.opentaint.semgrep + +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import org.opentaint.semgrep.util.SampleBasedTest +import org.opentaint.semgrep.util.TestAnalysisRunner +import kotlin.test.Test + +// Verifies whether a STARRED source ($*P) propagates through a field-sensitive +// EXTERNAL getter modeled as this. -> result. This is the mechanism the +// conductor response-source stars ($*UNTRUSTED = restTemplate.exchange(...)) +// depend on: if it holds, the missing conductor findings are a MODEL gap +// (okhttp/spring getters unmodeled), not a star-mechanism gap. +// configurationRequired = true loads model/java/config; AnyAccessorEnabled +// mirrors the production unroll. +@TestInstance(PER_CLASS) +class Phase3StarSourceGetterTest : SampleBasedTest(configurationRequired = true) { + @Test + fun `star source through field-sensitive getter`() = + runTest(unrollStrategy = TestAnalysisRunner.AnyAccessorEnabled) + + @AfterAll + fun close() { + closeRunner() + } +} From bd60e0c467ae83e4b39a34a81125f661747cf9ff Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:51:15 +0300 Subject: [PATCH 13/26] fix(analyzer): Field based default get --- .../ap/ifds/analysis/JIRMethodGetDefault.kt | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodGetDefault.kt b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodGetDefault.kt index 606e19d62..05fb70221 100644 --- a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodGetDefault.kt +++ b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodGetDefault.kt @@ -27,12 +27,21 @@ class JIRMethodGetDefault( private fun TypeName.mayBeArray(): Boolean = isArray || this == objectTypeName - private val getDefaultActions = listOf( - CopyAllMarks(from = Exact(This), to = Exact(Result)) + private fun defaultField(cls: JIRClassOrInterface): PositionAccessor.FieldAccessor = + PositionAccessor.FieldAccessor(cls.name, "", objectTypeName.typeName) + + private fun defaultPosition(cls: JIRClassOrInterface) = + PositionWithAccess(This, defaultField(cls)) + + private fun getDefaultActions(cls: JIRClassOrInterface) = listOf( + CopyAllMarks(from = Exact(defaultPosition(cls)), to = Exact(Result)) ) - private val getDefaultArrayActions = listOf( - CopyAllMarks(from = Exact(This), to = Exact(PositionWithAccess(Result, PositionAccessor.ElementAccessor))) + private fun getDefaultArrayActions(cls: JIRClassOrInterface) = listOf( + CopyAllMarks( + from = Exact(defaultPosition(cls)), + to = Exact(PositionWithAccess(Result, PositionAccessor.ElementAccessor)) + ) ) fun defaultPropagationRules(method: JIRMethod): List> { @@ -42,9 +51,9 @@ class JIRMethodGetDefault( if (!config.enableDefaultPropagationForClass(method.enclosingClass)) return emptyList() - var actions = getDefaultActions + var actions = getDefaultActions(method.enclosingClass) if (method.returnType.mayBeArray()) { - actions = actions + getDefaultArrayActions + actions = actions + getDefaultArrayActions(method.enclosingClass) } val getDefaultRule = TaintPassThrough(method, mkTrue(), actions, info = null) From a252311c3ef7d73149ebea5ca0f3b553ba5f386c Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 12 Aug 2026 10:23:22 +0200 Subject: [PATCH 14/26] refactor(dataflow): drop the unroll exception unrollAccessor excluded the literal field name "" 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. --- .../kotlin/org/opentaint/common/sast/dataflow/TaintAnalyzer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/opentaint-jvm-sast-dataflow/src/main/kotlin/org/opentaint/common/sast/dataflow/TaintAnalyzer.kt b/core/opentaint-jvm-sast-dataflow/src/main/kotlin/org/opentaint/common/sast/dataflow/TaintAnalyzer.kt index 226b5ff9a..8af42901f 100644 --- a/core/opentaint-jvm-sast-dataflow/src/main/kotlin/org/opentaint/common/sast/dataflow/TaintAnalyzer.kt +++ b/core/opentaint-jvm-sast-dataflow/src/main/kotlin/org/opentaint/common/sast/dataflow/TaintAnalyzer.kt @@ -70,7 +70,7 @@ abstract class TaintAnalyzer( open val unrollStrategy: AnyAccessorUnrollStrategy = object : AnyAccessorUnrollStrategy { override fun unrollAccessor(accessor: Accessor): Boolean = when (accessor) { is ElementAccessor -> true - is FieldAccessor -> accessor.fieldName != "" + is FieldAccessor -> true is ClassStaticAccessor, is AnyAccessor, is FinalAccessor, From e54f79dc0a161bb11eb03315c102b4aa555a21a9 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 12 Aug 2026 23:56:54 +0200 Subject: [PATCH 15/26] fix(dataflow): apply the default get model only when no rule matched 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 `` 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. --- .../jvm/ap/ifds/analysis/JIRMethodCallFlowFunction.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallFlowFunction.kt b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallFlowFunction.kt index 5e537cfd4..21b482af4 100644 --- a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallFlowFunction.kt +++ b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallFlowFunction.kt @@ -297,11 +297,12 @@ class JIRMethodCallFlowFunction( } } - analysisContext.analysisManager.params.defaultGetModel?.run { - /*todo: fix owasp, propagate default only if passThroughFacts.isNone */ - val defaultRules = defaultPropagationRules(method) - val defaultPass = applyPassThrough(defaultRules, conditionEvaluator, passEvaluator) - passThroughFacts = passThroughFacts.merge(defaultPass) + if (passThroughFacts.isNone) { + analysisContext.analysisManager.params.defaultGetModel?.run { + val defaultRules = defaultPropagationRules(method) + val defaultPass = applyPassThrough(defaultRules, conditionEvaluator, passEvaluator) + passThroughFacts = passThroughFacts.merge(defaultPass) + } } passThroughFacts.onSome { evaluatedPass -> From 9b96ae2677f5752cd33ccb13d788d4ab20153178 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 13 Aug 2026 15:07:56 +0200 Subject: [PATCH 16/26] refactor(dataflow): delete the String bytes clean special case JIRTaintCleanActionEvaluator resolved the type of every cleaned position and, when it was java.lang.String, appended a hardcoded FieldAccessor(String, "", "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 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. --- .../analysis/JIRMethodCallFlowFunction.kt | 2 +- .../JIRMethodCallRuleBasedSummaryRewriter.kt | 2 +- .../jvm/ap/ifds/taint/TaintEvaluator.kt | 30 ++----------------- 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallFlowFunction.kt b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallFlowFunction.kt index 21b482af4..0487db76e 100644 --- a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallFlowFunction.kt +++ b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallFlowFunction.kt @@ -162,7 +162,7 @@ class JIRMethodCallFlowFunction( markAfterAnyAccessorResolver = null // we don't expect such marks in pass rules ) - val cleaner = JIRTaintCleanActionEvaluator(typeResolver) + val cleaner = JIRTaintCleanActionEvaluator() val factReaderBeforeCleaner = FinalFactReader(callerFact, apManager) val cleanRules = taintCtx.cleanRulesForCallStatement(statement, callExpr, returnValue, callerFact) diff --git a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallRuleBasedSummaryRewriter.kt b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallRuleBasedSummaryRewriter.kt index 1b2697e67..ab9310f10 100644 --- a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallRuleBasedSummaryRewriter.kt +++ b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodCallRuleBasedSummaryRewriter.kt @@ -91,7 +91,7 @@ class JIRMethodCallRuleBasedSummaryRewriter( val actionsForBase = userRuleDefinedActions[fact.base].orEmpty() if (actionsForBase.isEmpty()) return listOf(fact to startFactReader) - val cleanEvaluator = JIRTaintCleanActionEvaluator(typeResolver) + val cleanEvaluator = JIRTaintCleanActionEvaluator() val cleanedFact = actionsForBase.entries.applyCleanerActions( initial = EvaluatedCleanAction.initial(startFactReader) ) { (mark, actions), current -> diff --git a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/taint/TaintEvaluator.kt b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/taint/TaintEvaluator.kt index 60ee38d3a..e72f6efd6 100644 --- a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/taint/TaintEvaluator.kt +++ b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/taint/TaintEvaluator.kt @@ -21,16 +21,13 @@ import org.opentaint.dataflow.configuration.jvm.Result import org.opentaint.dataflow.configuration.jvm.This import org.opentaint.dataflow.taint.EvaluatedCleanAction import org.opentaint.dataflow.taint.PositionAccess -import org.opentaint.dataflow.taint.PositionTypeResolver import org.opentaint.dataflow.taint.TaintCleanActionEvaluator interface ConditionEvaluator { fun eval(condition: Condition): T } -class JIRTaintCleanActionEvaluator( - private val positionTypeResolver: PositionTypeResolver, -) { +class JIRTaintCleanActionEvaluator { private val evaluator = TaintCleanActionEvaluator() fun evaluate( @@ -49,28 +46,9 @@ class JIRTaintCleanActionEvaluator( ): List { val variable = action.position.resolveAp() val mark = TaintMarkAccessor(action.mark.name) - val cleaned = evaluator.removeFinalFact(initialFact, variable, mark, rule, action, action.position.cleanReach()) - - val positionType = positionTypeResolver.resolve(variable) - if (positionType?.typeName != STRING) { - return cleaned - } - - val stringBytesPosition = action.position.append(stringBytes) - val stringBytesVar = stringBytesPosition.resolveAp() - return cleaned.flatMap { f -> - evaluator.removeFinalFact(f, stringBytesVar, mark, rule, action, stringBytesPosition.cleanReach()) - } + return evaluator.removeFinalFact(initialFact, variable, mark, rule, action, action.position.cleanReach()) } - companion object { - private const val STRING = "java.lang.String" - - // todo: fix in config? - // string bytes virtual field fully reflects the string content. - // So, if we clean string, we should clean its byte content - private val stringBytes = PositionAccessor.FieldAccessor(STRING, "", "byte[]") - } } fun ActionPosition.resolveBaseAp(): AccessPathBase = when (this) { @@ -96,10 +74,6 @@ fun ActionPosition.cleanReach(): TaintCleanReach = when (this) { is ActionPosition.AnyAccessorAfter -> TaintCleanReach.ExactAndAnyField } -private fun ActionPosition.append(accessor: PositionAccessor): ActionPosition = when (this) { - is ActionPosition.Exact -> ActionPosition.Exact(PositionWithAccess(position, accessor)) - is ActionPosition.AnyAccessorAfter -> ActionPosition.AnyAccessorAfter(PositionWithAccess(position, accessor)) -} fun Position.resolveAp(): PositionAccess = resolveAp(resolveBaseAp()) From 33c0d461b0d14623f632569000478133799d79c8 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 19 Aug 2026 22:41:45 +0000 Subject: [PATCH 17/26] fix(querylang): honour focus-metavariable on sanitizers 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. --- .../conversion/taint/TaintRuleProcessing.kt | 11 +- .../TaintCleanCompositionStrategy.kt | 25 +++- .../semgrep/AccessorSanitizerScopeTest.kt | 123 ++++++++++++++++++ 3 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/AccessorSanitizerScopeTest.kt diff --git a/core/opentaint-java-querylang/src/main/kotlin/org/opentaint/semgrep/pattern/conversion/taint/TaintRuleProcessing.kt b/core/opentaint-java-querylang/src/main/kotlin/org/opentaint/semgrep/pattern/conversion/taint/TaintRuleProcessing.kt index 98c1b956d..98b829976 100644 --- a/core/opentaint-java-querylang/src/main/kotlin/org/opentaint/semgrep/pattern/conversion/taint/TaintRuleProcessing.kt +++ b/core/opentaint-java-querylang/src/main/kotlin/org/opentaint/semgrep/pattern/conversion/taint/TaintRuleProcessing.kt @@ -104,10 +104,11 @@ data class ProcessedTaintPassRule( data class ProcessedTaintCleanRule( val rule: R, val bySideEffect: Boolean, - val cleans: Set + val cleans: Set, + val focusMetaVars: Set ) { fun flatMap(body: (R) -> List): List> = - body(rule).map { ProcessedTaintCleanRule(it, bySideEffect, cleans) } + body(rule).map { ProcessedTaintCleanRule(it, bySideEffect, cleans, focusMetaVars) } } data class ProcessedTaintRule( @@ -140,7 +141,7 @@ private fun ProcessedTaintPassRule ProcessedTaintCleanRule.compositionStrategy( strategy: TaintRuleStrategy -) = TaintCleanCompositionStrategy(rule, bySideEffect, cleans, strategy) +) = TaintCleanCompositionStrategy(rule, bySideEffect, cleans, focusMetaVars, strategy) private fun RuleConversionCtx.generateEdgeCtx( rule: ProcessedTaintRule, @@ -299,7 +300,6 @@ fun RuleConversionCtx.prepareTaintNonSourceRules( val cleaners = rule.sanitizers.map { clean -> // todo: sanitizer by side effect - // todo: sanitizer focus metavar val generatedPos = MetavarAtom.create("generated_clean_pos") val cleanAutomata = clean.pattern.map { @@ -313,7 +313,8 @@ fun RuleConversionCtx.prepareTaintNonSourceRules( ProcessedTaintCleanRule( cleanAutomata, clean.bySideEffect == true, - taintMarks.mapTo(hashSetOf()) { it.mark } + taintMarks.mapTo(hashSetOf()) { it.mark }, + clean.pattern.metaVarInfo.focusMetaVars.mapTo(hashSetOf()) { MetavarAtom.create(it) } ) } diff --git a/core/opentaint-java-querylang/src/main/kotlin/org/opentaint/semgrep/pattern/conversion/taint/composition/TaintCleanCompositionStrategy.kt b/core/opentaint-java-querylang/src/main/kotlin/org/opentaint/semgrep/pattern/conversion/taint/composition/TaintCleanCompositionStrategy.kt index 93a3c49e0..549d72d55 100644 --- a/core/opentaint-java-querylang/src/main/kotlin/org/opentaint/semgrep/pattern/conversion/taint/composition/TaintCleanCompositionStrategy.kt +++ b/core/opentaint-java-querylang/src/main/kotlin/org/opentaint/semgrep/pattern/conversion/taint/composition/TaintCleanCompositionStrategy.kt @@ -16,6 +16,7 @@ class TaintCleanCompositionStrategy( private val rule: TaintAutomataEdges, private val bySideEffect: Boolean, private val cleans: Set, + private val focusMetaVars: Set, val strategy: TaintRuleStrategy ) : TaintRuleGenerationCtx.CompositionStrategy { override fun stateClean( @@ -26,12 +27,29 @@ class TaintCleanCompositionStrategy( ): List? { if (state !in rule.automata.finalAcceptStates) return null - val cleanerPos = cleanerPositions(pos) + val cleanerPos = cleanerPositions(varName, pos) return cleans.flatMap { c -> cleanerPos.map { strategy.createCleanAction(c, it) } } } - private fun cleanerPositions(pos: PositionBaseWithModifiers?): List { + /** + * `stateClean` is invoked once per metavariable the edge accesses, so [pos] is *some* position the + * pattern mentions -- for `$URI = ($REQ).getRequestURI()` it is `Result` on one invocation and + * `This` on another. When the rule names a focus metavariable, that metavariable is the sanitized + * value and the others are only there to constrain the match, so [pos] must be emitted for the + * focus invocation alone. Emitting it for every metavariable is what made an accessor sanitizer + * clean its own receiver, i.e. untaint `request` itself. + */ + private fun isFocusPosition(varName: MetavarAtom?): Boolean { + if (focusMetaVars.isEmpty()) return true + val basics = varName?.basics ?: return false + return basics.any { basic -> focusMetaVars.any { basic in it.basics } } + } + + private fun cleanerPositions( + varName: MetavarAtom?, + pos: PositionBaseWithModifiers? + ): List { val cleanerPos = mutableListOf(PositionBase.Result.base()) if (bySideEffect) { cleanerPos += PositionBase.AnyArgument(classifier = "tainted").base() @@ -52,7 +70,8 @@ class TaintCleanCompositionStrategy( // removes the taint on the flow entering the call; it is flow-specific, so a separate use of // the same variable outside this call stays tainted. For a star clean `pos` already carries // the AnyField modifier, so this stays coherent with the plain-value arm's base. - val emitPositions = (cleanerEmitPositions + listOfNotNull(pos)).distinct() + val focusPos = pos.takeIf { isFocusPosition(varName) } + val emitPositions = (cleanerEmitPositions + listOfNotNull(focusPos)).distinct() return emitPositions } diff --git a/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/AccessorSanitizerScopeTest.kt b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/AccessorSanitizerScopeTest.kt new file mode 100644 index 000000000..a96065b09 --- /dev/null +++ b/core/opentaint-java-querylang/src/test/kotlin/org/opentaint/semgrep/AccessorSanitizerScopeTest.kt @@ -0,0 +1,123 @@ +package org.opentaint.semgrep + +import org.opentaint.dataflow.configuration.jvm.serialized.PositionBase +import org.opentaint.dataflow.configuration.jvm.serialized.PositionBaseWithModifiers +import org.opentaint.dataflow.configuration.jvm.serialized.SerializedItem +import org.opentaint.dataflow.configuration.jvm.serialized.SerializedTaintConfig +import org.opentaint.semgrep.pattern.SemgrepLoadTrace +import org.opentaint.semgrep.pattern.SemgrepRuleLoader +import org.opentaint.semgrep.pattern.TaintRuleFromSemgrep +import org.opentaint.semgrep.pattern.conversion.JavaLanguageStrategy +import org.opentaint.semgrep.pattern.createTaintConfig +import kotlin.io.path.Path +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * A sanitizer's `focus-metavariable` names the value that gets sanitized. Every other metavariable in + * the pattern is there to constrain the match, so a clean action must not be emitted for it. + * + * This matters for *accessor* sanitizers -- `$SAFE = ($REQ).getSomething();` focused on `$SAFE`. + * Cleaning `$REQ` as well would untaint the receiver, and `request.getRequestURI()` says nothing about + * `request.getParameter("url")`. + */ +class AccessorSanitizerScopeTest { + private fun config(ruleText: String): SerializedTaintConfig { + val trace = SemgrepLoadTrace() + val loader = SemgrepRuleLoader(listOf(JavaLanguageStrategy())) + loader.registerRuleSet(ruleText, Path("sanitizer.yaml"), Path("."), trace) + val (rule, _) = loader.loadRules().rulesWithMeta.single() + @Suppress("UNCHECKED_CAST") + return (rule as TaintRuleFromSemgrep).createTaintConfig() + } + + private fun cleanPositions(cfg: SerializedTaintConfig): List = + cfg.cleaner.orEmpty().flatMap { it.cleans }.map { it.pos } + + private fun PositionBaseWithModifiers.isThis(): Boolean = base is PositionBase.This + + private fun PositionBaseWithModifiers.isResult(): Boolean = base is PositionBase.Result + + private fun PositionBaseWithModifiers.isArgument(): Boolean = + base is PositionBase.Argument || base is PositionBase.AnyArgument + + private fun rule(sanitizer: String) = """ + rules: + - id: san + severity: NOTE + message: x + languages: [java] + mode: taint + pattern-sources: + - pattern: ${'$'}X = src(); + pattern-sanitizers: +$sanitizer + pattern-sinks: + - patterns: + - pattern: sink(${'$'}Y); + - focus-metavariable: ${'$'}Y + """.trimIndent() + + @Test + fun `focusing an accessor result does not clean the bound receiver`() { + val cfg = config( + rule( + """ + - patterns: + - pattern: ${'$'}*URI = (javax.servlet.http.HttpServletRequest ${'$'}REQ).getRequestURI(); + - focus-metavariable: ${'$'}URI + """.trimIndent().prependIndent(" ") + ) + ) + val positions = cleanPositions(cfg) + assertTrue(positions.isNotEmpty(), "expected a cleaner to be generated") + assertTrue( + positions.none { it.isThis() }, + "the receiver is only a match constraint and must stay tainted; got $positions" + ) + assertTrue(positions.all { it.isResult() }, "expected the returned value only; got $positions") + } + + @Test + fun `an accessor sanitizer with an unbound receiver cleans only the result`() { + val cfg = config(rule(" - pattern: (javax.servlet.http.HttpServletRequest).getRequestURI()")) + val positions = cleanPositions(cfg) + assertTrue(positions.isNotEmpty(), "expected a cleaner to be generated") + assertTrue(positions.all { it.isResult() }, "expected the returned value only; got $positions") + } + + @Test + fun `pass-through sanitizer still cleans the sanitized argument`() { + // Here the focus metavar *is* the argument, and cleaning that position is required: the clean + // runs on the argument-keyed fact at call-to-start, where `Result` does not exist yet. + val cfg = config( + rule( + """ + - patterns: + - pattern: clean(${'$'}C); + - focus-metavariable: ${'$'}C + """.trimIndent().prependIndent(" ") + ) + ) + val positions = cleanPositions(cfg) + assertTrue( + positions.any { it.isArgument() }, + "the sanitized argument itself must still be cleaned; got $positions" + ) + } + + @Test + fun `without a focus metavariable every matched position is still cleaned`() { + // The narrowing is deliberately scoped to rules that declare a focus metavariable. A sanitizer + // that declares none has no way to say which value it sanitizes, so it keeps the wide + // behaviour rather than silently losing clean actions. + val cfg = config( + rule(" - pattern: ${'$'}SAFE = (javax.servlet.http.HttpServletRequest ${'$'}REQ).getRequestURI();") + ) + val positions = cleanPositions(cfg) + assertTrue( + positions.any { it.isThis() }, + "expected the focus-free form to keep cleaning every matched position; got $positions" + ) + } +} From 6219b74e2321f425d10980a325deba1b07df82b2 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 19 Aug 2026 22:42:05 +0000 Subject: [PATCH 18/26] fix(dataflow): answer the field-unfold request on fact-to-fact edges 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. --- ...ctHandlerWithAnyAccessorRequestHandling.kt | 71 +++++++++++++++---- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt index 9485b9cd6..8e9554b07 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt @@ -4,9 +4,11 @@ import org.opentaint.dataflow.ap.ifds.Accessor import org.opentaint.dataflow.ap.ifds.AnalysisRunner import org.opentaint.dataflow.ap.ifds.AnyAccessor import org.opentaint.dataflow.ap.ifds.ExclusionSet +import org.opentaint.dataflow.ap.ifds.FieldAccessor import org.opentaint.dataflow.ap.ifds.MethodSummaryEdgeApplicationUtils import org.opentaint.dataflow.ap.ifds.SideEffectKind import org.opentaint.dataflow.ap.ifds.access.FinalFactAp +import org.opentaint.dataflow.ap.ifds.access.InitialFactAp import org.opentaint.dataflow.ap.ifds.analysis.MethodSequentFlowFunction import org.opentaint.dataflow.ap.ifds.analysis.MethodSideEffectSummaryHandler @@ -19,20 +21,52 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe kind: SideEffectKind ): Set { if (kind is TaintMarkFieldUnfoldRequest) { - when (summaryEffect) { - is MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication.SummaryApRefinement -> { - if (!summaryEffect.delta.isEmpty) { - handleMarkAfterAnyFieldRequest(summaryEffect.delta, kind) - } - } + handleUnfoldRequest(summaryEffect, kind) + } + + return super.handleZeroToFact(currentFactAp, summaryEffect, kind) + } + + /** + * A callee asks for its abstract initial fact to be unfolded when a taint mark its sink needs may + * be hidden under the abstraction. The request has to be answered on fact-to-fact edges too, not + * only on zero-to-fact ones: when the caller is itself analyzed from an initial fact -- i.e. the + * tainted object was passed into the caller as well -- the callee's side effect summary arrives + * here. Dropping it loses every sink whose condition reads a *field* of a formal parameter more + * than one frame below the source. + * + * Answered only while the request is still un-refined, i.e. its fact is the bare abstraction and + * no accessor below the parameter has been materialized yet. Fact-to-fact edges vastly outnumber + * zero-to-fact ones, and refining on all of them does not terminate in any reasonable time. + */ + override fun handleFactToFact( + currentInitialFactAp: InitialFactAp, + currentFactAp: FinalFactAp, + summaryEffect: MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication, + kind: SideEffectKind + ): Set { + if (kind is TaintMarkFieldUnfoldRequest && kind.fact.getAllAccessors().isEmpty()) { + handleUnfoldRequest(summaryEffect, kind) + } - is MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication.SummaryExclusionRefinement -> { - // taint mark requested -> mark not in initial fact, delta is empty -> mark not in fact + return super.handleFactToFact(currentInitialFactAp, currentFactAp, summaryEffect, kind) + } + + private fun handleUnfoldRequest( + summaryEffect: MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication, + request: TaintMarkFieldUnfoldRequest + ) { + when (summaryEffect) { + is MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication.SummaryApRefinement -> { + if (!summaryEffect.delta.isEmpty) { + handleMarkAfterAnyFieldRequest(summaryEffect.delta, request) } } - } - return super.handleZeroToFact(currentFactAp, summaryEffect, kind) + is MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication.SummaryExclusionRefinement -> { + // taint mark requested -> mark not in initial fact, delta is empty -> mark not in fact + } + } } private fun handleMarkAfterAnyFieldRequest( @@ -41,7 +75,7 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe ) { val mark = request.mark val allAccessors = delta.getAllAccessors() - if (mark !in allAccessors) return + val deltaHasMark = mark in allAccessors val startAccessors = hashSetOf() for (accessor in delta.getStartAccessors()) { @@ -56,8 +90,19 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe anySuccessors.filterTo(startAccessors) { it !is AnyAccessor } } - val relevantStartAccessors = startAccessors.filter { accessor -> - accessor == mark || delta.readAccessor(accessor)?.getAllAccessors()?.contains(mark) ?: false + // When the caller already knows where the mark sits, refine on exactly that branch. When it + // does not -- because the caller is analyzed abstractly too and only knows the *shape* the + // value takes below the callee's parameter -- refine on that shape instead, so the callee + // materializes the accessor and can answer once the mark arrives from further up. Only a + // single concrete field qualifies: that is the shape a field-sensitive library model produces + // (`file.path`, `bean.url`), and fanning out over several accessors, or over elements, + // re-analyzes far too much of the program for the chance of finding the mark. + val relevantStartAccessors = if (deltaHasMark) { + startAccessors.filter { accessor -> + accessor == mark || delta.readAccessor(accessor)?.getAllAccessors()?.contains(mark) ?: false + } + } else { + startAccessors.filter { it is FieldAccessor }.takeIf { it.size == 1 }.orEmpty() } if (relevantStartAccessors.isEmpty()) return From 550b9bdd6abe0d7163c9736513a0b9061a258a42 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 27 Aug 2026 18:19:34 +0200 Subject: [PATCH 19/26] refactor(dataflow): delete the array-element mechanism 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. --- .../org/opentaint/dataflow/taint/TaintUtil.kt | 7 +--- .../go/analysis/GoMethodCallTaintUtil.kt | 15 -------- .../jvm/ap/ifds/JIRFactTypeChecker.kt | 13 ------- .../ap/ifds/taint/JIRMethodCallTaintUtil.kt | 24 ------------ .../rules/MethodTaintConfigurationResolver.kt | 38 ++----------------- 5 files changed, 5 insertions(+), 92 deletions(-) diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/TaintUtil.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/TaintUtil.kt index b9979319d..79f02d5f2 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/TaintUtil.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/TaintUtil.kt @@ -23,8 +23,6 @@ abstract class TaintUtil(val apManager: ApManager) { abstract fun handleReachedSink(rule: Sink, factReader: FinalFactReader?, evaluatedFacts: List) - open fun patchSinkConditionFactReader(factReaders: List): List = factReaders - fun applySinkRules( sinkRules: List>, factReader: FinalFactReader?, @@ -32,8 +30,7 @@ abstract class TaintUtil(val apManager: ApManager) { ) { if (sinkRules.isEmpty()) return - val normalConditionFactReaders = factReader?.let { conditionFact(it) }.orEmpty() - val conditionFactReaders = patchSinkConditionFactReader(normalConditionFactReaders) + val conditionFactReaders = factReader?.let { conditionFact(it) }.orEmpty() sinkRules.applyRuleWithAssumptions( apManager, @@ -45,7 +42,7 @@ abstract class TaintUtil(val apManager: ApManager) { return@applyRuleWithAssumptions } - factReader?.updateRefinement(normalConditionFactReaders) + factReader?.updateRefinement(conditionFactReaders) } diff --git a/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodCallTaintUtil.kt b/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodCallTaintUtil.kt index 81da32ce2..3a1b26d79 100644 --- a/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodCallTaintUtil.kt +++ b/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodCallTaintUtil.kt @@ -1,7 +1,5 @@ package org.opentaint.dataflow.go.analysis -import org.opentaint.dataflow.ap.ifds.AccessPathBase -import org.opentaint.dataflow.ap.ifds.ElementAccessor import org.opentaint.dataflow.ap.ifds.ExclusionSet import org.opentaint.dataflow.ap.ifds.FactTypeChecker import org.opentaint.dataflow.ap.ifds.TaintMarkAccessor @@ -17,10 +15,7 @@ import org.opentaint.dataflow.go.GoMethodCallFactMapper.mapMethodExitToReturnFlo import org.opentaint.dataflow.go.rules.GoAssignAction import org.opentaint.dataflow.go.rules.GoRuleCondition import org.opentaint.dataflow.go.rules.TaintRule -import org.opentaint.dataflow.taint.FactReader import org.opentaint.dataflow.taint.FinalFactReader -import org.opentaint.dataflow.taint.FinalFactReaderWithPrefix -import org.opentaint.dataflow.taint.PositionAccess import org.opentaint.dataflow.taint.TaintSourceActionEvaluator import org.opentaint.dataflow.taint.TaintUtil import org.opentaint.ir.go.inst.GoIRInst @@ -79,16 +74,6 @@ class GoMethodCallTaintUtil( return readers } - override fun patchSinkConditionFactReader(factReaders: List): List { - val elementWrappedReaders = factReaders.mapNotNull { reader -> - val base = reader.factAp.base as? AccessPathBase.Argument ?: return@mapNotNull null - val elementPosition = PositionAccess.Complex(PositionAccess.Simple(base), ElementAccessor) - if (!reader.containsPosition(elementPosition)) return@mapNotNull null - FinalFactReaderWithPrefix(reader, ElementAccessor) - } - return factReaders + elementWrappedReaders - } - override fun handleReachedSink( rule: TaintRule.Sink, factReader: FinalFactReader?, diff --git a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/JIRFactTypeChecker.kt b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/JIRFactTypeChecker.kt index 342ef70ca..0bd3fadac 100644 --- a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/JIRFactTypeChecker.kt +++ b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/JIRFactTypeChecker.kt @@ -2,7 +2,6 @@ package org.opentaint.dataflow.jvm.ap.ifds import it.unimi.dsi.fastutil.longs.LongLongImmutablePair import it.unimi.dsi.fastutil.longs.LongLongPair -import org.opentaint.dataflow.ap.ifds.AccessPathBase import org.opentaint.dataflow.ap.ifds.Accessor import org.opentaint.dataflow.ap.ifds.AnyAccessor import org.opentaint.dataflow.ap.ifds.ClassStaticAccessor @@ -32,7 +31,6 @@ import org.opentaint.ir.api.jvm.JIRRefType import org.opentaint.ir.api.jvm.JIRType import org.opentaint.ir.api.jvm.JIRTypeVariable import org.opentaint.ir.api.jvm.JIRUnboundWildcard -import org.opentaint.ir.api.jvm.cfg.JIRCallExpr import org.opentaint.ir.api.jvm.ext.ifArrayGetElementType import org.opentaint.ir.api.jvm.ext.isAssignable import org.opentaint.ir.api.jvm.ext.isSubClassOf @@ -192,17 +190,6 @@ class JIRFactTypeChecker(private val cp: JIRClasspath) : FactTypeChecker { return AccessorCompatibilityFilter(actualType) } - fun callArgumentMayBeArray(call: JIRCallExpr, arg: AccessPathBase.Argument): Boolean { - val argument = call.args.getOrNull(arg.idx) ?: return false - val argType = argument.type - return argType.mayBeArray() - } - - fun JIRType.mayBeArray(): Boolean { - if (this !is JIRRefType) return false - return typeMayBeArrayType(this) - } - private fun accessorActualType(accessPath: List): JIRType? { val accessor = accessPath.lastOrNull() ?: return null return when (accessor) { diff --git a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/taint/JIRMethodCallTaintUtil.kt b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/taint/JIRMethodCallTaintUtil.kt index 393db42f4..f97bac092 100644 --- a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/taint/JIRMethodCallTaintUtil.kt +++ b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/taint/JIRMethodCallTaintUtil.kt @@ -1,7 +1,5 @@ package org.opentaint.dataflow.jvm.ap.ifds.taint -import org.opentaint.dataflow.ap.ifds.AccessPathBase -import org.opentaint.dataflow.ap.ifds.ElementAccessor import org.opentaint.dataflow.ap.ifds.ExclusionSet import org.opentaint.dataflow.ap.ifds.access.ApManager import org.opentaint.dataflow.ap.ifds.access.FinalFactAp @@ -16,10 +14,7 @@ import org.opentaint.dataflow.jvm.ap.ifds.JIRMethodCallFactMapper import org.opentaint.dataflow.jvm.ap.ifds.TaintConfigUtils.accept import org.opentaint.dataflow.jvm.ap.ifds.analysis.JIRMethodAnalysisContext import org.opentaint.dataflow.jvm.util.callee -import org.opentaint.dataflow.taint.FactReader import org.opentaint.dataflow.taint.FinalFactReader -import org.opentaint.dataflow.taint.FinalFactReaderWithPrefix -import org.opentaint.dataflow.taint.PositionAccess import org.opentaint.dataflow.taint.TaintSourceActionEvaluator import org.opentaint.dataflow.taint.TaintUtil import org.opentaint.ir.api.jvm.cfg.JIRCallExpr @@ -183,25 +178,6 @@ class JIRMethodCallTaintUtil( JIRMethodCallFactMapper.mapMethodExitToReturnFlowFact(statement, this) .singleOrNull() - override fun patchSinkConditionFactReader(factReaders: List): List { - val arrayElementFactReaders = factReaders.arrayElementConditionReaders(callExpr) - return factReaders + arrayElementFactReaders - } - - private fun List.arrayElementConditionReaders(callExpr: JIRCallExpr): List = - mapNotNull { - val base = it.factAp.base as? AccessPathBase.Argument ?: return@mapNotNull null - - if (!analysisContext.factTypeChecker.callArgumentMayBeArray(callExpr, base)) { - return@mapNotNull null - } - - val arrayElementPosition = PositionAccess.Complex(PositionAccess.Simple(base), ElementAccessor) - if (!it.containsPosition(arrayElementPosition)) return@mapNotNull null - - FinalFactReaderWithPrefix(it, ElementAccessor) - } - private inline fun storeInfo(body: () -> Unit) { if (generateTrace) return body() diff --git a/core/opentaint-jvm-sast-dataflow/src/main/kotlin/org/opentaint/jvm/sast/dataflow/rules/MethodTaintConfigurationResolver.kt b/core/opentaint-jvm-sast-dataflow/src/main/kotlin/org/opentaint/jvm/sast/dataflow/rules/MethodTaintConfigurationResolver.kt index 577f89dcc..4acd293c6 100644 --- a/core/opentaint-jvm-sast-dataflow/src/main/kotlin/org/opentaint/jvm/sast/dataflow/rules/MethodTaintConfigurationResolver.kt +++ b/core/opentaint-jvm-sast-dataflow/src/main/kotlin/org/opentaint/jvm/sast/dataflow/rules/MethodTaintConfigurationResolver.kt @@ -81,7 +81,6 @@ import org.opentaint.ir.api.jvm.JIRTypedMethod import org.opentaint.ir.api.jvm.PredefinedPrimitives import org.opentaint.ir.api.jvm.TypeName import org.opentaint.ir.api.jvm.ext.allSuperHierarchySequence -import org.opentaint.ir.impl.cfg.util.isArray import org.opentaint.jvm.sast.dataflow.matchedAnnotations import java.util.concurrent.atomic.AtomicInteger @@ -190,15 +189,15 @@ class MethodTaintConfigurationResolver( ctx: AnyArgSpecializationCtx, ): TaintConfigurationItem = when (this) { is SerializedRule.EntryPoint -> { - TaintEntryPointSource(method, condition, taint.flatMap { it.resolveWithArray(ctx) }, info, serializedId) + TaintEntryPointSource(method, condition, taint.flatMap { it.resolve(ctx) }, info, serializedId) } is SerializedRule.Source -> { - TaintMethodSource(method, condition, taint.flatMap { it.resolveWithArray(ctx) }, info, serializedId) + TaintMethodSource(method, condition, taint.flatMap { it.resolve(ctx) }, info, serializedId) } is SerializedRule.MethodExitSource -> { - TaintMethodExitSource(method, condition, taint.flatMap { it.resolveWithArray(ctx) }, info, serializedId) + TaintMethodExitSource(method, condition, taint.flatMap { it.resolve(ctx) }, info, serializedId) } is SerializedRule.Sink -> { @@ -552,37 +551,6 @@ class MethodTaintConfigurationResolver( pos.resolveActionPosition(ctx, annotatedWith?.asAnnotationConstraint()) .map { AssignMark(taintMarkManager.taintMark(kind), it) } - // Source actions on an array- or Object-typed position taint the element as well as the - // position itself. The starred rules that express this explicitly land in 3-rules; until - // then the duplication has to stay here or array sources lose their element taint. - private fun SerializedTaintAssignAction.resolveWithArray(ctx: AnyArgSpecializationCtx): List = - pos.resolveActionPosition(ctx, annotatedWith?.asAnnotationConstraint()) - .flatMap { it.resolveArrayActionPosition() } - .map { AssignMark(taintMarkManager.taintMark(kind), it) } - - private fun ActionPosition.resolveArrayActionPosition(): List = when (this) { - is Exact -> position.resolveArrayPosition().map { Exact(it) } - is AnyAccessorAfter -> listOf(this) - } - - private fun Position.resolveArrayPosition(): List = when (this) { - is ClassStatic -> listOf(this) - is PositionWithAccess -> base.resolveArrayPosition().map { PositionWithAccess(it, access) } - is This -> listOf(this) - is Argument -> resolveArrayPosition(this, method.parameters.getOrNull(index)?.type) - is Result -> resolveArrayPosition(this, method.returnType) - } - - private fun resolveArrayPosition(position: Position, positionType: TypeName?): List { - if (positionType == null) return listOf(position) - - if (!positionType.isArray && positionType != objectTypeName) { - return listOf(position) - } - - return listOf(position, PositionWithAccess(position, PositionAccessor.ElementAccessor)) - } - private fun SerializedTaintPassAction.resolve(ctx: AnyArgSpecializationCtx): List = from.resolveActionPosition(ctx).flatMap { fromPos -> to.resolveActionPosition(ctx).map { toPos -> From 960cc1228d87b58c6dc8e1cd748b89bdba84659c Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 31 Aug 2026 00:13:12 +0200 Subject: [PATCH 20/26] test(dataflow): pin that only the star gives element taint 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. --- .../dataflow/AnyFieldPrimitiveAnalysisTest.kt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldPrimitiveAnalysisTest.kt b/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldPrimitiveAnalysisTest.kt index 61e05fe79..722006490 100644 --- a/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldPrimitiveAnalysisTest.kt +++ b/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldPrimitiveAnalysisTest.kt @@ -17,15 +17,17 @@ import org.opentaint.dataflow.configuration.jvm.serialized.SerializedTaintConfig import org.opentaint.dataflow.jvm.ap.ifds.taint.PrimitiveTaintExt /** - * A whole-object source must find not less than a base-only source. + * The star is the only source of element taint on a primitive array. * - * A whole-object source taints the argument twice: the base and the any-field. If the argument - * is a primitive array, an element read puts the any-field part on a primitive position. The - * type checker rejected an any-accessor there, and a reject removes the full fact. Thus the - * base part was also lost. + * The array-element mechanism gave element taint to an array position without a rule. It is + * deleted. A base-only source thus stops at an element read. A whole-object source continues, + * because the star puts the any-field part on the element. * - * These two tests are a pair. Without the fix, only the whole-object test fails. The fault - * occurs in Tree mode only. + * These two tests are a pair. Together they show that the star replaces the deleted mechanism. + * + * The whole-object test also needs the any-accessor fix. An element read puts the any-field part + * on a primitive position. Before the fix the filter removed the full fact there, and the taint + * stopped in Tree mode. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) abstract class AnyFieldPrimitiveAnalysisTest : AnalysisTest() { @@ -61,11 +63,10 @@ abstract class AnyFieldPrimitiveAnalysisTest : AnalysisTest() { PositionBaseWithModifiers.WithModifiers(Argument(0), listOf(PositionModifier.AnyField)) @Test - fun `base-only source on a primitive array reaches the element sink`() = assertReachable( + fun `base-only source on a primitive array does not reach the element sink`() = assertNotReachable( config = config(baseOnly()), testCls = TEST_CLS, entryPointName = "elementFlow", - ruleId = RULE_ID, testName = "base-only source, primitive array element" ) From 2e5482e5568b594f4f4d2f6d15a0a8c53bf0e7e0 Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:48:56 +0300 Subject: [PATCH 21/26] test(dataflow): cover interprocedural any-field unfolding --- .../dataflow/ap/ifds/MethodAnalyzer.kt | 2 +- .../MethodSideEffectSummaryHandler.kt | 2 + ...ctHandlerWithAnyAccessorRequestHandling.kt | 91 +++++++++++-------- .../AnyFieldInterproceduralSample.java | 39 ++++++++ .../AnyFieldInterproceduralAnalysisTest.kt | 53 +++++++++++ 5 files changed, 149 insertions(+), 38 deletions(-) create mode 100644 core/samples/src/main/java/test/samples/AnyFieldInterproceduralSample.java create mode 100644 core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldInterproceduralAnalysisTest.kt diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/MethodAnalyzer.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/MethodAnalyzer.kt index 1d075f780..31b8e8705 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/MethodAnalyzer.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/MethodAnalyzer.kt @@ -905,7 +905,7 @@ class NormalMethodAnalyzer( methodInitialFactBase = sub.methodInitialFactBase, sideEffectSummaries = sideEffectSummaries, ) { currentFactAp, summaryEffect, kind -> - handler.handleFactToFact(sub.currentEdge.initialFactAp, currentFactAp, summaryEffect, kind) + handler.handleFactToFact(methodEntryPoint, sub.currentEdge.initialFactAp, currentFactAp, summaryEffect, kind) } } } diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/analysis/MethodSideEffectSummaryHandler.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/analysis/MethodSideEffectSummaryHandler.kt index e444138d4..6f4a56c4d 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/analysis/MethodSideEffectSummaryHandler.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/analysis/MethodSideEffectSummaryHandler.kt @@ -1,6 +1,7 @@ package org.opentaint.dataflow.ap.ifds.analysis import org.opentaint.dataflow.ap.ifds.ExclusionSet +import org.opentaint.dataflow.ap.ifds.MethodEntryPoint import org.opentaint.dataflow.ap.ifds.MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication import org.opentaint.dataflow.ap.ifds.MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication.SummaryApRefinement import org.opentaint.dataflow.ap.ifds.MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication.SummaryExclusionRefinement @@ -24,6 +25,7 @@ interface MethodSideEffectSummaryHandler { } fun handleFactToFact( + methodEntryPoint: MethodEntryPoint, currentInitialFactAp: InitialFactAp, currentFactAp: FinalFactAp, summaryEffect: SummaryEdgeApplication, diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt index 8e9554b07..94bb19c17 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt @@ -4,7 +4,7 @@ import org.opentaint.dataflow.ap.ifds.Accessor import org.opentaint.dataflow.ap.ifds.AnalysisRunner import org.opentaint.dataflow.ap.ifds.AnyAccessor import org.opentaint.dataflow.ap.ifds.ExclusionSet -import org.opentaint.dataflow.ap.ifds.FieldAccessor +import org.opentaint.dataflow.ap.ifds.MethodEntryPoint import org.opentaint.dataflow.ap.ifds.MethodSummaryEdgeApplicationUtils import org.opentaint.dataflow.ap.ifds.SideEffectKind import org.opentaint.dataflow.ap.ifds.access.FinalFactAp @@ -27,29 +27,22 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe return super.handleZeroToFact(currentFactAp, summaryEffect, kind) } - /** - * A callee asks for its abstract initial fact to be unfolded when a taint mark its sink needs may - * be hidden under the abstraction. The request has to be answered on fact-to-fact edges too, not - * only on zero-to-fact ones: when the caller is itself analyzed from an initial fact -- i.e. the - * tainted object was passed into the caller as well -- the callee's side effect summary arrives - * here. Dropping it loses every sink whose condition reads a *field* of a formal parameter more - * than one frame below the source. - * - * Answered only while the request is still un-refined, i.e. its fact is the bare abstraction and - * no accessor below the parameter has been materialized yet. Fact-to-fact edges vastly outnumber - * zero-to-fact ones, and refining on all of them does not terminate in any reasonable time. - */ override fun handleFactToFact( + methodEntryPoint: MethodEntryPoint, currentInitialFactAp: InitialFactAp, currentFactAp: FinalFactAp, summaryEffect: MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication, kind: SideEffectKind ): Set { - if (kind is TaintMarkFieldUnfoldRequest && kind.fact.getAllAccessors().isEmpty()) { - handleUnfoldRequest(summaryEffect, kind) + if (kind !is TaintMarkFieldUnfoldRequest) { + return super.handleFactToFact(methodEntryPoint, currentInitialFactAp, currentFactAp, summaryEffect, kind) } - return super.handleFactToFact(currentInitialFactAp, currentFactAp, summaryEffect, kind) + handleUnfoldRequest(summaryEffect, kind) + + val fact = currentInitialFactAp.replaceExclusions(ExclusionSet.Empty) + val newKind = TaintMarkFieldUnfoldRequest(methodEntryPoint, fact, kind.mark) + return setOf(MethodSequentFlowFunction.Sequent.FactSideEffect(fact, newKind)) } private fun handleUnfoldRequest( @@ -75,40 +68,64 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe ) { val mark = request.mark val allAccessors = delta.getAllAccessors() - val deltaHasMark = mark in allAccessors + if (mark !in allAccessors) return + + val requests = mutableListOf() + traverseAllAccessorToMarkChains(mark, delta, request.fact, hashSetOf(), requests) + + requests.forEach { + runner.manager.handleCrossUnitSideEffectReq(request.method, it) + } + } + + private fun traverseAllAccessorToMarkChains( + mark: Accessor, + current: FinalFactAp.Delta, + fact: InitialFactAp, + visited: MutableSet, + result: MutableList + ) { + if (!visited.add(current)) return + + val relevantStartAccessors = current.relevantStartAccessors(mark) + if (relevantStartAccessors.isEmpty()) return + + val exclusion = relevantStartAccessors.fold(ExclusionSet.Empty as ExclusionSet, ExclusionSet::add) + result += fact.replaceExclusions(exclusion) + + for (accessor in relevantStartAccessors) { + if (accessor == mark) continue + + val nextDelta = current.readAccessor(accessor) ?: continue + val nextFact = fact.append(accessor) + + traverseAllAccessorToMarkChains(mark, nextDelta, nextFact, visited, result) + } + } + private fun FinalFactAp.Delta.relevantStartAccessors(mark: Accessor): List { val startAccessors = hashSetOf() - for (accessor in delta.getStartAccessors()) { + for (accessor in getStartAccessors()) { if (accessor !is AnyAccessor) { startAccessors.add(accessor) continue } - val anySuccessors = delta.readAccessor(accessor)?.getStartAccessors() + val anySuccessors = readAccessor(accessor)?.getStartAccessors() ?: continue anySuccessors.filterTo(startAccessors) { it !is AnyAccessor } } - // When the caller already knows where the mark sits, refine on exactly that branch. When it - // does not -- because the caller is analyzed abstractly too and only knows the *shape* the - // value takes below the callee's parameter -- refine on that shape instead, so the callee - // materializes the accessor and can answer once the mark arrives from further up. Only a - // single concrete field qualifies: that is the shape a field-sensitive library model produces - // (`file.path`, `bean.url`), and fanning out over several accessors, or over elements, - // re-analyzes far too much of the program for the chance of finding the mark. - val relevantStartAccessors = if (deltaHasMark) { - startAccessors.filter { accessor -> - accessor == mark || delta.readAccessor(accessor)?.getAllAccessors()?.contains(mark) ?: false - } - } else { - startAccessors.filter { it is FieldAccessor }.takeIf { it.size == 1 }.orEmpty() + return startAccessors.filter { accessor -> + accessor == mark || readAccessor(accessor)?.getAllAccessors()?.contains(mark) ?: false } + } - if (relevantStartAccessors.isEmpty()) return - - val exclusion = relevantStartAccessors.fold(ExclusionSet.Empty as ExclusionSet, ExclusionSet::add) - val sideEffectRequirement = request.fact.replaceExclusions(exclusion) - runner.manager.handleCrossUnitSideEffectReq(request.method, sideEffectRequirement) + private fun InitialFactAp.append(accessor: Accessor): InitialFactAp = with(runner.apManager) { + val singleAccessorFact = mostAbstractInitialAp(base).prependAccessor(accessor) + val empty = mostAbstractFinalAp(base) + val singleAccessorDelta = singleAccessorFact.splitDelta(empty).first().second + return concat(singleAccessorDelta) } } diff --git a/core/samples/src/main/java/test/samples/AnyFieldInterproceduralSample.java b/core/samples/src/main/java/test/samples/AnyFieldInterproceduralSample.java new file mode 100644 index 000000000..db83789e7 --- /dev/null +++ b/core/samples/src/main/java/test/samples/AnyFieldInterproceduralSample.java @@ -0,0 +1,39 @@ +package test.samples; + +public class AnyFieldInterproceduralSample { + public static class Path { + public String value; + } + + public static class File { + public Path path; + } + + public String source() { + return "tainted"; + } + + public void sink(Path path) { } + + private void consume(Path path) { + sink(path); + } + + private void readFile(File file) { + consume(file.path); + } + + private void store(String value) { + File file = new File(); + file.path = new Path(); + file.path.value = value; + + // Keeping this write and the sink in different summarized callees makes readFile's + // initial fact expose the single-field shape before it exposes the taint mark. + readFile(file); + } + + public void fieldFlow() { + store(source()); + } +} diff --git a/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldInterproceduralAnalysisTest.kt b/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldInterproceduralAnalysisTest.kt new file mode 100644 index 000000000..faa1b14b1 --- /dev/null +++ b/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldInterproceduralAnalysisTest.kt @@ -0,0 +1,53 @@ +package org.opentaint.jvm.sast.dataflow + +import org.junit.jupiter.api.Test +import org.opentaint.dataflow.ap.ifds.Accessor +import org.opentaint.dataflow.ap.ifds.FieldAccessor +import org.opentaint.dataflow.ap.ifds.access.AnyAccessorUnrollStrategy +import org.opentaint.dataflow.configuration.jvm.serialized.PositionBase.Argument +import org.opentaint.dataflow.configuration.jvm.serialized.PositionBaseWithModifiers +import org.opentaint.dataflow.configuration.jvm.serialized.PositionModifier.AnyField +import org.opentaint.dataflow.configuration.jvm.serialized.SerializedCondition +import org.opentaint.dataflow.configuration.jvm.serialized.SerializedRule +import org.opentaint.dataflow.configuration.jvm.serialized.SerializedTaintConfig +import org.opentaint.dataflow.configuration.jvm.serialized.SinkMetaData + +class AnyFieldInterproceduralAnalysisTest : AnalysisTest() { + companion object { + private const val TEST_CLASS = "test.samples.AnyFieldInterproceduralSample" + private const val TAINT_MARK = "tainted" + private const val RULE_ID = "any-field-interprocedural" + } + + override val sourceFileExtension: String = "java" + + override val analysisUnrollStrategy: AnyAccessorUnrollStrategy = object : AnyAccessorUnrollStrategy { + override fun unrollAccessor(accessor: Accessor): Boolean = accessor is FieldAccessor + } + + private val config = SerializedTaintConfig( + source = listOf(sourceRule(TEST_CLASS, "source", TAINT_MARK)), + sink = listOf( + SerializedRule.Sink( + function = functionMatcher(TEST_CLASS, "sink"), + // The sink accepts the container, so the mark has to be found below an + // arbitrary field rather than on argument 0 itself. + condition = SerializedCondition.ContainsMark( + tainted = TAINT_MARK, + pos = PositionBaseWithModifiers.WithModifiers(Argument(0), listOf(AnyField)), + ), + id = RULE_ID, + meta = SinkMetaData(note = "Taint reaches a field of sink argument"), + ) + ), + ) + + @Test + fun `any-field sink unfolds a field across two call frames`() = assertReachable( + config = config, + testCls = TEST_CLASS, + entryPointName = "fieldFlow", + ruleId = RULE_ID, + testName = "interprocedural any-field sink", + ) +} From 3cfe8a88040bb48ae337602f8c6a073e35b1b228 Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:56:56 +0300 Subject: [PATCH 22/26] reduce mark request handling workload --- ...ctHandlerWithAnyAccessorRequestHandling.kt | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt index 94bb19c17..12b5fecce 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt @@ -20,11 +20,12 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe summaryEffect: MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication, kind: SideEffectKind ): Set { - if (kind is TaintMarkFieldUnfoldRequest) { - handleUnfoldRequest(summaryEffect, kind) + if (kind !is TaintMarkFieldUnfoldRequest) { + return super.handleZeroToFact(currentFactAp, summaryEffect, kind) } - return super.handleZeroToFact(currentFactAp, summaryEffect, kind) + handleUnfoldRequest(summaryEffect, kind) + return emptySet() } override fun handleFactToFact( @@ -38,7 +39,9 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe return super.handleFactToFact(methodEntryPoint, currentInitialFactAp, currentFactAp, summaryEffect, kind) } - handleUnfoldRequest(summaryEffect, kind) + if (handleUnfoldRequest(summaryEffect, kind)) { + return emptySet() + } val fact = currentInitialFactAp.replaceExclusions(ExclusionSet.Empty) val newKind = TaintMarkFieldUnfoldRequest(methodEntryPoint, fact, kind.mark) @@ -48,11 +51,11 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe private fun handleUnfoldRequest( summaryEffect: MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication, request: TaintMarkFieldUnfoldRequest - ) { + ): Boolean { when (summaryEffect) { is MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication.SummaryApRefinement -> { if (!summaryEffect.delta.isEmpty) { - handleMarkAfterAnyFieldRequest(summaryEffect.delta, request) + return handleMarkAfterAnyFieldRequest(summaryEffect.delta, request) } } @@ -60,15 +63,17 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe // taint mark requested -> mark not in initial fact, delta is empty -> mark not in fact } } + + return false } private fun handleMarkAfterAnyFieldRequest( delta: FinalFactAp.Delta, request: TaintMarkFieldUnfoldRequest - ) { + ): Boolean { val mark = request.mark val allAccessors = delta.getAllAccessors() - if (mark !in allAccessors) return + if (mark !in allAccessors) return false val requests = mutableListOf() traverseAllAccessorToMarkChains(mark, delta, request.fact, hashSetOf(), requests) @@ -76,6 +81,8 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe requests.forEach { runner.manager.handleCrossUnitSideEffectReq(request.method, it) } + + return true } private fun traverseAllAccessorToMarkChains( From 422ebe92e6c1e0523a25a97ae3b64d9d9bf3408f Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:56:09 +0300 Subject: [PATCH 23/26] reduce mark request handling workload 2.0 --- .../FactWithMarkAfterAnyAccessorResolver.kt | 6 +- ...ctHandlerWithAnyAccessorRequestHandling.kt | 70 ++++++------------- 2 files changed, 27 insertions(+), 49 deletions(-) diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/FactWithMarkAfterAnyAccessorResolver.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/FactWithMarkAfterAnyAccessorResolver.kt index 37ac22ddf..30473fc1f 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/FactWithMarkAfterAnyAccessorResolver.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/FactWithMarkAfterAnyAccessorResolver.kt @@ -3,6 +3,7 @@ package org.opentaint.dataflow.taint import org.opentaint.dataflow.ap.ifds.MethodEntryPoint import org.opentaint.dataflow.ap.ifds.SideEffectKind import org.opentaint.dataflow.ap.ifds.TaintMarkAccessor +import org.opentaint.dataflow.ap.ifds.access.FinalFactAp import org.opentaint.dataflow.ap.ifds.access.InitialFactAp interface FactWithMarkAfterAnyAccessorResolver { @@ -12,7 +13,8 @@ interface FactWithMarkAfterAnyAccessorResolver { data class TaintMarkFieldUnfoldRequest( val method: MethodEntryPoint, val fact: InitialFactAp, - val mark: TaintMarkAccessor + val mark: TaintMarkAccessor, + val suffix: FinalFactAp.Delta? ) : SideEffectKind data class DefaultFactWithMarkAfterAnyFieldResolver( @@ -21,7 +23,7 @@ data class DefaultFactWithMarkAfterAnyFieldResolver( private val addSideEffect: (InitialFactAp, SideEffectKind) -> Unit ): FactWithMarkAfterAnyAccessorResolver { override fun resolve(mark: TaintMarkAccessor) { - addSideEffect(initialFact, TaintMarkFieldUnfoldRequest(method, initialFact, mark)) + addSideEffect(initialFact, TaintMarkFieldUnfoldRequest(method, initialFact, mark, suffix = null)) } companion object { diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt index 12b5fecce..0a48b3a73 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt @@ -5,7 +5,7 @@ import org.opentaint.dataflow.ap.ifds.AnalysisRunner import org.opentaint.dataflow.ap.ifds.AnyAccessor import org.opentaint.dataflow.ap.ifds.ExclusionSet import org.opentaint.dataflow.ap.ifds.MethodEntryPoint -import org.opentaint.dataflow.ap.ifds.MethodSummaryEdgeApplicationUtils +import org.opentaint.dataflow.ap.ifds.MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication import org.opentaint.dataflow.ap.ifds.SideEffectKind import org.opentaint.dataflow.ap.ifds.access.FinalFactAp import org.opentaint.dataflow.ap.ifds.access.InitialFactAp @@ -17,7 +17,7 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe override fun handleZeroToFact( currentFactAp: FinalFactAp, - summaryEffect: MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication, + summaryEffect: SummaryEdgeApplication, kind: SideEffectKind ): Set { if (kind !is TaintMarkFieldUnfoldRequest) { @@ -32,7 +32,7 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe methodEntryPoint: MethodEntryPoint, currentInitialFactAp: InitialFactAp, currentFactAp: FinalFactAp, - summaryEffect: MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication, + summaryEffect: SummaryEdgeApplication, kind: SideEffectKind ): Set { if (kind !is TaintMarkFieldUnfoldRequest) { @@ -43,23 +43,30 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe return emptySet() } + val suffix = when (summaryEffect) { + is SummaryEdgeApplication.SummaryExclusionRefinement -> kind.suffix + is SummaryEdgeApplication.SummaryApRefinement -> { + kind.suffix ?: summaryEffect.delta.takeIf { !it.isEmpty } + } + } + + val newKind = kind.copy(suffix = suffix) val fact = currentInitialFactAp.replaceExclusions(ExclusionSet.Empty) - val newKind = TaintMarkFieldUnfoldRequest(methodEntryPoint, fact, kind.mark) return setOf(MethodSequentFlowFunction.Sequent.FactSideEffect(fact, newKind)) } private fun handleUnfoldRequest( - summaryEffect: MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication, + summaryEffect: SummaryEdgeApplication, request: TaintMarkFieldUnfoldRequest ): Boolean { when (summaryEffect) { - is MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication.SummaryApRefinement -> { + is SummaryEdgeApplication.SummaryApRefinement -> { if (!summaryEffect.delta.isEmpty) { return handleMarkAfterAnyFieldRequest(summaryEffect.delta, request) } } - is MethodSummaryEdgeApplicationUtils.SummaryEdgeApplication.SummaryExclusionRefinement -> { + is SummaryEdgeApplication.SummaryExclusionRefinement -> { // taint mark requested -> mark not in initial fact, delta is empty -> mark not in fact } } @@ -75,42 +82,16 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe val allAccessors = delta.getAllAccessors() if (mark !in allAccessors) return false - val requests = mutableListOf() - traverseAllAccessorToMarkChains(mark, delta, request.fact, hashSetOf(), requests) + val nextAccessors = request.suffix?.startAccessors() + ?: delta.relevantStartAccessors(mark) - requests.forEach { - runner.manager.handleCrossUnitSideEffectReq(request.method, it) - } + val exclusion = nextAccessors.fold(ExclusionSet.Empty as ExclusionSet, ExclusionSet::add) + runner.manager.handleCrossUnitSideEffectReq(request.method, request.fact.replaceExclusions(exclusion)) return true } - private fun traverseAllAccessorToMarkChains( - mark: Accessor, - current: FinalFactAp.Delta, - fact: InitialFactAp, - visited: MutableSet, - result: MutableList - ) { - if (!visited.add(current)) return - - val relevantStartAccessors = current.relevantStartAccessors(mark) - if (relevantStartAccessors.isEmpty()) return - - val exclusion = relevantStartAccessors.fold(ExclusionSet.Empty as ExclusionSet, ExclusionSet::add) - result += fact.replaceExclusions(exclusion) - - for (accessor in relevantStartAccessors) { - if (accessor == mark) continue - - val nextDelta = current.readAccessor(accessor) ?: continue - val nextFact = fact.append(accessor) - - traverseAllAccessorToMarkChains(mark, nextDelta, nextFact, visited, result) - } - } - - private fun FinalFactAp.Delta.relevantStartAccessors(mark: Accessor): List { + private fun FinalFactAp.Delta.startAccessors(): Set { val startAccessors = hashSetOf() for (accessor in getStartAccessors()) { if (accessor !is AnyAccessor) { @@ -123,16 +104,11 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe anySuccessors.filterTo(startAccessors) { it !is AnyAccessor } } + return startAccessors + } - return startAccessors.filter { accessor -> + private fun FinalFactAp.Delta.relevantStartAccessors(mark: Accessor): List = + startAccessors().filter { accessor -> accessor == mark || readAccessor(accessor)?.getAllAccessors()?.contains(mark) ?: false } - } - - private fun InitialFactAp.append(accessor: Accessor): InitialFactAp = with(runner.apManager) { - val singleAccessorFact = mostAbstractInitialAp(base).prependAccessor(accessor) - val empty = mostAbstractFinalAp(base) - val singleAccessorDelta = singleAccessorFact.splitDelta(empty).first().second - return concat(singleAccessorDelta) - } } From 002ef484530c0f779767e45df926723abda70cae Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:50:56 +0300 Subject: [PATCH 24/26] reduce mark request handling workload 3.0 --- .../FactWithMarkAfterAnyAccessorResolver.kt | 4 +- ...ctHandlerWithAnyAccessorRequestHandling.kt | 26 +-- .../AnyFieldDeepInterproceduralSample.java | 153 ++++++++++++++++++ .../AnyFieldInterproceduralAnalysisTest.kt | 91 +++++++++-- 4 files changed, 250 insertions(+), 24 deletions(-) create mode 100644 core/samples/src/main/java/test/samples/AnyFieldDeepInterproceduralSample.java diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/FactWithMarkAfterAnyAccessorResolver.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/FactWithMarkAfterAnyAccessorResolver.kt index 30473fc1f..99bae8199 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/FactWithMarkAfterAnyAccessorResolver.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/FactWithMarkAfterAnyAccessorResolver.kt @@ -1,9 +1,9 @@ package org.opentaint.dataflow.taint +import org.opentaint.dataflow.ap.ifds.Accessor import org.opentaint.dataflow.ap.ifds.MethodEntryPoint import org.opentaint.dataflow.ap.ifds.SideEffectKind import org.opentaint.dataflow.ap.ifds.TaintMarkAccessor -import org.opentaint.dataflow.ap.ifds.access.FinalFactAp import org.opentaint.dataflow.ap.ifds.access.InitialFactAp interface FactWithMarkAfterAnyAccessorResolver { @@ -14,7 +14,7 @@ data class TaintMarkFieldUnfoldRequest( val method: MethodEntryPoint, val fact: InitialFactAp, val mark: TaintMarkAccessor, - val suffix: FinalFactAp.Delta? + val suffix: Accessor? ) : SideEffectKind data class DefaultFactWithMarkAfterAnyFieldResolver( diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt index 0a48b3a73..d4820ee91 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt @@ -43,16 +43,24 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe return emptySet() } - val suffix = when (summaryEffect) { - is SummaryEdgeApplication.SummaryExclusionRefinement -> kind.suffix - is SummaryEdgeApplication.SummaryApRefinement -> { - kind.suffix ?: summaryEffect.delta.takeIf { !it.isEmpty } - } + val nextRequests = kind.nextRequests(summaryEffect) + val fact = currentInitialFactAp.replaceExclusions(ExclusionSet.Empty) + return nextRequests.mapTo(hashSetOf()) { + MethodSequentFlowFunction.Sequent.FactSideEffect(fact, it) } + } - val newKind = kind.copy(suffix = suffix) - val fact = currentInitialFactAp.replaceExclusions(ExclusionSet.Empty) - return setOf(MethodSequentFlowFunction.Sequent.FactSideEffect(fact, newKind)) + private fun TaintMarkFieldUnfoldRequest.nextRequests( + effect: SummaryEdgeApplication + ): List = when (effect) { + is SummaryEdgeApplication.SummaryExclusionRefinement -> listOf(this) + is SummaryEdgeApplication.SummaryApRefinement -> { + if (suffix != null || effect.delta.isEmpty) { + listOf(this) + } else { + effect.delta.startAccessors().map { copy(suffix = it) } + } + } } private fun handleUnfoldRequest( @@ -82,7 +90,7 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe val allAccessors = delta.getAllAccessors() if (mark !in allAccessors) return false - val nextAccessors = request.suffix?.startAccessors() + val nextAccessors = request.suffix?.let { setOf(it) } ?: delta.relevantStartAccessors(mark) val exclusion = nextAccessors.fold(ExclusionSet.Empty as ExclusionSet, ExclusionSet::add) diff --git a/core/samples/src/main/java/test/samples/AnyFieldDeepInterproceduralSample.java b/core/samples/src/main/java/test/samples/AnyFieldDeepInterproceduralSample.java new file mode 100644 index 000000000..083fca1ae --- /dev/null +++ b/core/samples/src/main/java/test/samples/AnyFieldDeepInterproceduralSample.java @@ -0,0 +1,153 @@ +package test.samples; + +/** + * The same any-field flow as {@link AnyFieldInterproceduralSample}, at a range of call depths. + * + * Every frame between the write and the sink is entered with the container as a formal parameter, + * so each is analyzed from its own initial fact and the field-unfold request the sink condition + * raises has to travel the chain on fact-to-fact edges rather than zero-to-fact ones. Fact-to-fact + * edges vastly outnumber zero-to-fact ones, which is why answering the request on all of them is + * what costs; the depth here decides how far the request must climb before the mark comes in view. + * + * Each depth gets its own disjoint chain on purpose. Sharing the frames would give the climb a + * shortcut: the shallower chain's writing frame is also a caller of the shared sink frame, so the + * request gets answered there and never exercises the depth under test. + * + * Sibling fields are read on the way down so the frames do not all present the same refinement + * delta. That breadth is what the request's identity multiplies against: if the caller's delta is + * part of the key, every frame stores and re-broadcasts its own copy of the same question. + */ +public class AnyFieldDeepInterproceduralSample { + public static class Path { + public String value; + } + + public static class Holder { + public Path path; + public Path spare; + public String note; + } + + public static class Node { + public Path payload; + public Node next; + } + + /** + * Self-recursive descent through a repeated field. Each recursive frame is entered with a fact + * one `.next` deeper than its caller, so the refinement delta the caller brings is exactly the + * tail by which its own initial fact extends -- the `arg0.next.* -> n.next.*` application of a + * request already applied at `arg0.* -> n.*`. This is the shape of ImportController#joinKV. + */ + private void walk(Node n) { + sink(n.payload); + if (n.next != null) { + walk(n.next); + } + } + + private void recursiveStore(String value) { + Node tail = new Node(); + tail.payload = new Path(); + tail.payload.value = value; + + Node head = new Node(); + head.payload = new Path(); + head.next = tail; + + walk(head); + } + + public void fieldFlowRecursive() { recursiveStore(source()); } + + public String source() { + return "tainted"; + } + + public void sink(Path path) { } + + private void touch(Path p) { } + + private void note(String s) { } + + // ---- depth 1: 1 frame(s) between the write and the sink ---- + private void d01_f00(Holder h) { sink(h.path); } + + private void d01_store(String value) { + Holder holder = new Holder(); + holder.path = new Path(); + holder.path.value = value; + + d01_f00(holder); + } + + public void fieldFlowDepth1() { d01_store(source()); } + + // ---- depth 2: 2 frame(s) between the write and the sink ---- + private void d02_f00(Holder h) { sink(h.path); } + private void d02_f01(Holder h) { d02_f00(h); } + + private void d02_store(String value) { + Holder holder = new Holder(); + holder.path = new Path(); + holder.path.value = value; + + d02_f01(holder); + } + + public void fieldFlowDepth2() { d02_store(source()); } + + // ---- depth 3: 3 frame(s) between the write and the sink ---- + private void d03_f00(Holder h) { sink(h.path); } + private void d03_f01(Holder h) { d03_f00(h); } + private void d03_f02(Holder h) { touch(h.spare); d03_f01(h); } + + private void d03_store(String value) { + Holder holder = new Holder(); + holder.path = new Path(); + holder.path.value = value; + + d03_f02(holder); + } + + public void fieldFlowDepth3() { d03_store(source()); } + + // ---- depth 5: 5 frame(s) between the write and the sink ---- + private void d05_f00(Holder h) { sink(h.path); } + private void d05_f01(Holder h) { d05_f00(h); } + private void d05_f02(Holder h) { touch(h.spare); d05_f01(h); } + private void d05_f03(Holder h) { d05_f02(h); } + private void d05_f04(Holder h) { note(h.note); d05_f03(h); } + + private void d05_store(String value) { + Holder holder = new Holder(); + holder.path = new Path(); + holder.path.value = value; + + d05_f04(holder); + } + + public void fieldFlowDepth5() { d05_store(source()); } + + // ---- depth 10: 10 frame(s) between the write and the sink ---- + private void d10_f00(Holder h) { sink(h.path); } + private void d10_f01(Holder h) { d10_f00(h); } + private void d10_f02(Holder h) { touch(h.spare); d10_f01(h); } + private void d10_f03(Holder h) { d10_f02(h); } + private void d10_f04(Holder h) { note(h.note); d10_f03(h); } + private void d10_f05(Holder h) { d10_f04(h); } + private void d10_f06(Holder h) { touch(h.spare); d10_f05(h); } + private void d10_f07(Holder h) { d10_f06(h); } + private void d10_f08(Holder h) { note(h.note); d10_f07(h); } + private void d10_f09(Holder h) { d10_f08(h); } + + private void d10_store(String value) { + Holder holder = new Holder(); + holder.path = new Path(); + holder.path.value = value; + + d10_f09(holder); + } + + public void fieldFlowDepth10() { d10_store(source()); } +} diff --git a/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldInterproceduralAnalysisTest.kt b/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldInterproceduralAnalysisTest.kt index faa1b14b1..1c95bcf15 100644 --- a/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldInterproceduralAnalysisTest.kt +++ b/core/src/test/kotlin/org/opentaint/jvm/sast/dataflow/AnyFieldInterproceduralAnalysisTest.kt @@ -1,6 +1,7 @@ package org.opentaint.jvm.sast.dataflow import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout import org.opentaint.dataflow.ap.ifds.Accessor import org.opentaint.dataflow.ap.ifds.FieldAccessor import org.opentaint.dataflow.ap.ifds.access.AnyAccessorUnrollStrategy @@ -11,14 +12,31 @@ import org.opentaint.dataflow.configuration.jvm.serialized.SerializedCondition import org.opentaint.dataflow.configuration.jvm.serialized.SerializedRule import org.opentaint.dataflow.configuration.jvm.serialized.SerializedTaintConfig import org.opentaint.dataflow.configuration.jvm.serialized.SinkMetaData +import java.util.concurrent.TimeUnit class AnyFieldInterproceduralAnalysisTest : AnalysisTest() { companion object { private const val TEST_CLASS = "test.samples.AnyFieldInterproceduralSample" + private const val DEEP_TEST_CLASS = "test.samples.AnyFieldDeepInterproceduralSample" private const val TAINT_MARK = "tainted" private const val RULE_ID = "any-field-interprocedural" + private const val DEEP_RULE_ID = "any-field-interprocedural-deep" } + /** + * The sink accepts the container, so the mark has to be found below an arbitrary field rather + * than on argument 0 itself -- which is what raises the field-unfold request in the first place. + */ + private fun anyFieldSink(testClass: String, ruleId: String) = SerializedRule.Sink( + function = functionMatcher(testClass, "sink"), + condition = SerializedCondition.ContainsMark( + tainted = TAINT_MARK, + pos = PositionBaseWithModifiers.WithModifiers(Argument(0), listOf(AnyField)), + ), + id = ruleId, + meta = SinkMetaData(note = "Taint reaches a field of sink argument"), + ) + override val sourceFileExtension: String = "java" override val analysisUnrollStrategy: AnyAccessorUnrollStrategy = object : AnyAccessorUnrollStrategy { @@ -27,19 +45,12 @@ class AnyFieldInterproceduralAnalysisTest : AnalysisTest() { private val config = SerializedTaintConfig( source = listOf(sourceRule(TEST_CLASS, "source", TAINT_MARK)), - sink = listOf( - SerializedRule.Sink( - function = functionMatcher(TEST_CLASS, "sink"), - // The sink accepts the container, so the mark has to be found below an - // arbitrary field rather than on argument 0 itself. - condition = SerializedCondition.ContainsMark( - tainted = TAINT_MARK, - pos = PositionBaseWithModifiers.WithModifiers(Argument(0), listOf(AnyField)), - ), - id = RULE_ID, - meta = SinkMetaData(note = "Taint reaches a field of sink argument"), - ) - ), + sink = listOf(anyFieldSink(TEST_CLASS, RULE_ID)), + ) + + private val deepConfig = SerializedTaintConfig( + source = listOf(sourceRule(DEEP_TEST_CLASS, "source", TAINT_MARK)), + sink = listOf(anyFieldSink(DEEP_TEST_CLASS, DEEP_RULE_ID)), ) @Test @@ -50,4 +61,58 @@ class AnyFieldInterproceduralAnalysisTest : AnalysisTest() { ruleId = RULE_ID, testName = "interprocedural any-field sink", ) + + /** + * The same flow at a range of call depths. Each frame is entered with the container as a formal + * parameter, so the request travels on fact-to-fact edges the whole way. These pin how far the + * field-unfold request is allowed to climb: a bound that only answers un-refined requests stops + * finding the flow once the request has picked up accessors on the way up, and the depth at + * which that happens is exactly what these cases record. + */ + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + fun `any-field sink unfolds a field at depth 1`() = assertDeepReachable(1) + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + fun `any-field sink unfolds a field at depth 2`() = assertDeepReachable(2) + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + fun `any-field sink unfolds a field at depth 3`() = assertDeepReachable(3) + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + fun `any-field sink unfolds a field at depth 5`() = assertDeepReachable(5) + + /** + * Ten frames. This is the cost case: without a bound on the climb the analyzer exhausts its own + * IFDS budget here rather than reporting the flow. + */ + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + fun `any-field sink unfolds a field at depth 10`() = assertDeepReachable(10) + + /** + * A self-recursive descent through a repeated field. Each frame's refinement delta is the tail + * by which its own initial fact extends, so this is the shape any "the delta is the edge fact's + * tail, so skip it" bound has to be checked against. The depth ladder never produces it. + */ + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + fun `any-field sink unfolds a field through a recursive walk`() = assertReachable( + config = deepConfig, + testCls = DEEP_TEST_CLASS, + entryPointName = "fieldFlowRecursive", + ruleId = DEEP_RULE_ID, + testName = "recursive interprocedural any-field sink", + ) + + private fun assertDeepReachable(depth: Int) = assertReachable( + config = deepConfig, + testCls = DEEP_TEST_CLASS, + entryPointName = "fieldFlowDepth$depth", + ruleId = DEEP_RULE_ID, + testName = "interprocedural any-field sink at depth $depth", + ) } From f67696168e23ab54f6aea983c8c20b4fb360f777 Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:47:10 +0300 Subject: [PATCH 25/26] Fix exclusion --- ...MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt index d4820ee91..bd2ec670e 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt @@ -44,7 +44,11 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe } val nextRequests = kind.nextRequests(summaryEffect) - val fact = currentInitialFactAp.replaceExclusions(ExclusionSet.Empty) + val ex = when (summaryEffect) { + is SummaryEdgeApplication.SummaryApRefinement -> ExclusionSet.Empty + is SummaryEdgeApplication.SummaryExclusionRefinement -> summaryEffect.exclusion + } + val fact = currentInitialFactAp.replaceExclusions(ex) return nextRequests.mapTo(hashSetOf()) { MethodSequentFlowFunction.Sequent.FactSideEffect(fact, it) } From eeaed058a09da794abc30e4c84d8ba764fa49d1a Mon Sep 17 00:00:00 2001 From: Valentyn Sobol <8640896+Saloed@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:04:42 +0000 Subject: [PATCH 26/26] refactor(dataflow): hold the unfold demand per analysed method 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) 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) Claude-Session: https://claude.ai/code/session_018R4xeH4QFAH7RrPcKFoQGZ --- .../ap/ifds/analysis/MethodAnalysisContext.kt | 8 ++ .../dataflow/taint/MarkUnfoldDemand.kt | 67 +++++++++++++++ ...ctHandlerWithAnyAccessorRequestHandling.kt | 19 ++++- .../dataflow/taint/MarkUnfoldDemandTest.kt | 83 +++++++++++++++++++ .../dataflow/go/analysis/GoAnalysisManager.kt | 2 +- .../go/analysis/GoMethodAnalysisContext.kt | 3 + .../go/analysis/GoMethodSideEffectHandler.kt | 4 +- .../ap/ifds/analysis/JIRAnalysisManager.kt | 2 +- .../ifds/analysis/JIRMethodAnalysisContext.kt | 3 + .../analysis/JIRMethodSideEffectHandler.kt | 4 +- 10 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MarkUnfoldDemand.kt create mode 100644 core/opentaint-dataflow-core/opentaint-dataflow/src/test/kotlin/org/opentaint/dataflow/taint/MarkUnfoldDemandTest.kt diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/analysis/MethodAnalysisContext.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/analysis/MethodAnalysisContext.kt index 29b22f302..65e465b43 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/analysis/MethodAnalysisContext.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/ap/ifds/analysis/MethodAnalysisContext.kt @@ -1,10 +1,18 @@ package org.opentaint.dataflow.ap.ifds.analysis import org.opentaint.dataflow.ap.ifds.MethodEntryPoint +import org.opentaint.dataflow.taint.MarkUnfoldDemand interface MethodAnalysisContext { val methodEntryPoint: MethodEntryPoint + /** + * Accessors already demanded by answers to a `TaintMarkFieldUnfoldRequest` handled while + * analysing this method. One per analysed method, so a question is only ever conflated with + * another asked in the same frame. + */ + val markUnfoldDemand: MarkUnfoldDemand + // todo: remove, required for trace generation val methodCallFactMapper: MethodCallFactMapper } diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MarkUnfoldDemand.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MarkUnfoldDemand.kt new file mode 100644 index 000000000..6bfd1331a --- /dev/null +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MarkUnfoldDemand.kt @@ -0,0 +1,67 @@ +package org.opentaint.dataflow.taint + +import org.opentaint.dataflow.ap.ifds.AccessPathBase +import org.opentaint.dataflow.ap.ifds.Accessor +import org.opentaint.dataflow.ap.ifds.MethodEntryPoint +import java.util.concurrent.ConcurrentHashMap + +/** + * The accessors already demanded by answers to a [TaintMarkFieldUnfoldRequest]. + * + * A request asks one question: is [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 therefore only grows, and an answer that contributes nothing new has not + * failed to answer -- it has repeated an answer already given. + * + * Contributing an accessor a second time is not a refinement: the split it asks for produces a + * fact that ends in `[any]` again, one accessor further down, which re-raises the same question. + * On a self-similar shape -- `CharSequence#content`, or `MapKey`/`MapValue`/`Element` over erased + * generics, where the type checker has nothing to stop on -- that loop has no fixed point, and + * each round re-abstracts the whole accumulated fact tree of the base and re-broadcasts a side + * effect requirement across the asking frame's transitive callers. + * + * One instance per analysed method, held on its + * [org.opentaint.dataflow.ap.ifds.analysis.MethodAnalysisContext]. A request climbs through many + * frames, and each frame it passes through filters against its own record -- so two frames are + * never made to agree about a question, and nothing here is shared across the analysis. + * `ConcurrentHashMap.newKeySet().add` is still the atomic step, so workers sharing one method's + * context cannot both be told the same accessor is fresh. + * + * Within a method the question is keyed on the ASKING frame (the one the request came from, not + * the one holding this map), the base the abstraction sits on, and the mark -- NOT on the + * current refinement of the fact, which is itself the product of earlier answers. That is where + * the precision goes: `arg0.*` and `arg0.x.*` are the same question here, so a frame already + * asked to split on `y` is not asked again below `x`, and a flow needing `arg0.x.y` is not + * reported. Keying on the refinement instead makes every round of the iteration its own question + * and the filter a no-op -- which is exactly the loop it is here to cut, so the conflation is + * load-bearing rather than an oversight. + */ +class MarkUnfoldDemand { + private data class Question( + /** The frame the request came from, which is not in general the frame holding this map. */ + val method: MethodEntryPoint, + val base: AccessPathBase, + val mark: Accessor, + ) + + private val demanded = ConcurrentHashMap>() + + /** + * Records [accessors] as demanded for the question and returns those that were not demanded + * before. An empty result means this answer repeats one already given. + */ + fun demand( + method: MethodEntryPoint, + base: AccessPathBase, + mark: Accessor, + accessors: Collection, + ): List { + if (accessors.isEmpty()) return emptyList() + + val alreadyDemanded = demanded.computeIfAbsent(Question(method, base, mark)) { + ConcurrentHashMap.newKeySet() + } + + return accessors.filter { alreadyDemanded.add(it) } + } +} diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt index bd2ec670e..44acc3685 100644 --- a/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/main/kotlin/org/opentaint/dataflow/taint/MethodSideEffectHandlerWithAnyAccessorRequestHandling.kt @@ -9,12 +9,16 @@ import org.opentaint.dataflow.ap.ifds.MethodSummaryEdgeApplicationUtils.SummaryE import org.opentaint.dataflow.ap.ifds.SideEffectKind import org.opentaint.dataflow.ap.ifds.access.FinalFactAp import org.opentaint.dataflow.ap.ifds.access.InitialFactAp +import org.opentaint.dataflow.ap.ifds.analysis.MethodAnalysisContext import org.opentaint.dataflow.ap.ifds.analysis.MethodSequentFlowFunction import org.opentaint.dataflow.ap.ifds.analysis.MethodSideEffectSummaryHandler interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffectSummaryHandler { val runner: AnalysisRunner + /** The method being analysed -- the frame these requests are arriving at, not the one that asked. */ + val analysisContext: MethodAnalysisContext + override fun handleZeroToFact( currentFactAp: FinalFactAp, summaryEffect: SummaryEdgeApplication, @@ -97,7 +101,20 @@ interface MethodSideEffectHandlerWithAnyAccessorRequestHandling : MethodSideEffe val nextAccessors = request.suffix?.let { setOf(it) } ?: delta.relevantStartAccessors(mark) - val exclusion = nextAccessors.fold(ExclusionSet.Empty as ExclusionSet, ExclusionSet::add) + // The demand for one question only grows. An accessor already demanded for it does not + // refine the abstraction a second time -- the split it asks for ends in `[any]` again, + // one accessor further down, and re-raises the same question. See [MarkUnfoldDemand]. + val newAccessors = analysisContext.markUnfoldDemand.demand( + request.method, request.fact.base, mark, nextAccessors + ) + + // Nothing fresh: this answer asks for a split that has already been asked for. The + // request itself is NOT consumed -- 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. + if (newAccessors.isEmpty()) return false + + val exclusion = newAccessors.fold(ExclusionSet.Empty as ExclusionSet, ExclusionSet::add) runner.manager.handleCrossUnitSideEffectReq(request.method, request.fact.replaceExclusions(exclusion)) return true diff --git a/core/opentaint-dataflow-core/opentaint-dataflow/src/test/kotlin/org/opentaint/dataflow/taint/MarkUnfoldDemandTest.kt b/core/opentaint-dataflow-core/opentaint-dataflow/src/test/kotlin/org/opentaint/dataflow/taint/MarkUnfoldDemandTest.kt new file mode 100644 index 000000000..68cf07137 --- /dev/null +++ b/core/opentaint-dataflow-core/opentaint-dataflow/src/test/kotlin/org/opentaint/dataflow/taint/MarkUnfoldDemandTest.kt @@ -0,0 +1,83 @@ +package org.opentaint.dataflow.taint + +import org.opentaint.dataflow.ap.ifds.AccessPathBase +import org.opentaint.dataflow.ap.ifds.Accessor +import org.opentaint.dataflow.ap.ifds.EmptyMethodContext +import org.opentaint.dataflow.ap.ifds.FieldAccessor +import org.opentaint.dataflow.ap.ifds.MethodEntryPoint +import org.opentaint.dataflow.ap.ifds.TaintMarkAccessor +import org.opentaint.ir.api.common.cfg.CommonInst +import org.opentaint.ir.api.common.cfg.CommonInstLocation +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * What the demand remembers, and -- the part that matters -- what it deliberately forgets. + * + * The key is the asking frame, the base and the mark. It is NOT the fact, and that is the whole + * point: the fact being refined is itself the product of earlier answers, so keying on it makes + * every round of the iteration its own question and the filter a no-op. Keying without it stops + * the iteration, and gives up telling `arg0.*` apart from `arg0.x.*`. + * + * Both halves are pinned here, so a later change that "fixes" the conflation is seen for what + * it is: the loop coming back. + */ +class MarkUnfoldDemandTest { + + private val frameA = MethodEntryPoint(EmptyMethodContext, FakeInst("a")) + private val frameB = MethodEntryPoint(EmptyMethodContext, FakeInst("b")) + + private val mark = TaintMarkAccessor("tainted") + private val otherMark = TaintMarkAccessor("other") + + private val x: Accessor = FieldAccessor("C", "x", "C") + private val y: Accessor = FieldAccessor("C", "y", "C") + + private val base = AccessPathBase.Argument(0) + private val otherBase = AccessPathBase.Argument(1) + + /** + * The loss, stated as a test rather than as a comment: the two calls stand for the same + * frame asking about the same base and mark, one of them about `arg0.*` and the other about + * `arg0.y.*`. Nothing in the signature can tell them apart -- there is no fact parameter -- + * so the second is told `x` has been demanded already, and `arg0.y.x` is never asked for. + */ + @Test + fun `an accessor is fresh once, whatever the fact asking for it`() { + val demand = MarkUnfoldDemand() + + assertEquals(listOf(x), demand.demand(frameA, base, mark, listOf(x))) + assertEquals(emptyList(), demand.demand(frameA, base, mark, listOf(x))) + } + + @Test + fun `only the part that is new comes back`() { + val demand = MarkUnfoldDemand() + + demand.demand(frameA, base, mark, listOf(x)) + assertEquals(listOf(y), demand.demand(frameA, base, mark, listOf(x, y))) + } + + @Test + fun `the frame, the base and the mark each separate questions`() { + val demand = MarkUnfoldDemand() + demand.demand(frameA, base, mark, listOf(x)) + + assertEquals(listOf(x), demand.demand(frameB, base, mark, listOf(x))) + assertEquals(listOf(x), demand.demand(frameA, otherBase, mark, listOf(x))) + assertEquals(listOf(x), demand.demand(frameA, base, otherMark, listOf(x))) + } + + @Test + fun `nothing demanded is nothing fresh`() { + val demand = MarkUnfoldDemand() + + assertEquals(emptyList(), demand.demand(frameA, base, mark, emptyList())) + } + + /** The demand only ever uses an entry point as a map key, so identity is all it needs. */ + private class FakeInst(private val id: String) : CommonInst { + override val location: CommonInstLocation get() = error("not used as a location") + override fun toString(): String = id + } +} diff --git a/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoAnalysisManager.kt b/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoAnalysisManager.kt index a72b68abb..59210a326 100644 --- a/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoAnalysisManager.kt +++ b/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoAnalysisManager.kt @@ -164,7 +164,7 @@ class GoAnalysisManager( statement: CommonInst, runner: AnalysisRunner, ): MethodSideEffectSummaryHandler { - return GoMethodSideEffectHandler(runner) + return GoMethodSideEffectHandler(runner, analysisContext) } override fun getMethodStartPrecondition( diff --git a/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodAnalysisContext.kt b/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodAnalysisContext.kt index f6ce7b2e3..3afbf9aa9 100644 --- a/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodAnalysisContext.kt +++ b/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodAnalysisContext.kt @@ -3,6 +3,7 @@ package org.opentaint.dataflow.go.analysis import org.opentaint.dataflow.ap.ifds.MethodEntryPoint import org.opentaint.dataflow.ap.ifds.TaintAnalysisManager import org.opentaint.dataflow.ap.ifds.analysis.MethodAnalysisContext +import org.opentaint.dataflow.taint.MarkUnfoldDemand import org.opentaint.dataflow.ap.ifds.analysis.MethodCallFactMapper import org.opentaint.dataflow.go.GoClosureTracker.ClosureTracker import org.opentaint.dataflow.go.GoMethodCallFactMapper @@ -20,6 +21,8 @@ class GoMethodAnalysisContext( val taint: GoTaintAnalysisContext, val aliasAnalysis: GoLocalAliasAnalysis, ) : MethodAnalysisContext { + override val markUnfoldDemand: MarkUnfoldDemand = MarkUnfoldDemand() + init { taint.bindAnalysisContext(this) } diff --git a/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodSideEffectHandler.kt b/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodSideEffectHandler.kt index bf0fdd230..dea2a758f 100644 --- a/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodSideEffectHandler.kt +++ b/core/opentaint-dataflow-core/opentaint-go-dataflow/src/main/kotlin/org/opentaint/dataflow/go/analysis/GoMethodSideEffectHandler.kt @@ -1,8 +1,10 @@ package org.opentaint.dataflow.go.analysis import org.opentaint.dataflow.ap.ifds.AnalysisRunner +import org.opentaint.dataflow.ap.ifds.analysis.MethodAnalysisContext import org.opentaint.dataflow.taint.MethodSideEffectHandlerWithAnyAccessorRequestHandling class GoMethodSideEffectHandler( - override val runner: AnalysisRunner + override val runner: AnalysisRunner, + override val analysisContext: MethodAnalysisContext ) : MethodSideEffectHandlerWithAnyAccessorRequestHandling diff --git a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRAnalysisManager.kt b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRAnalysisManager.kt index 297a7aab1..9eaefa44b 100644 --- a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRAnalysisManager.kt +++ b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRAnalysisManager.kt @@ -246,7 +246,7 @@ class JIRAnalysisManager( jIRDowncast(statement) jIRDowncast(analysisContext) - return JIRMethodSideEffectHandler(runner) + return JIRMethodSideEffectHandler(runner, analysisContext) } override fun getMethodCallPrecondition( diff --git a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodAnalysisContext.kt b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodAnalysisContext.kt index d9837eac0..f8fc4d814 100644 --- a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodAnalysisContext.kt +++ b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodAnalysisContext.kt @@ -5,6 +5,7 @@ import org.opentaint.dataflow.ap.ifds.MethodEntryPoint import org.opentaint.dataflow.ap.ifds.TaintAnalysisManager.Phase import org.opentaint.dataflow.ap.ifds.TaintMarkAccessor import org.opentaint.dataflow.ap.ifds.analysis.MethodAnalysisContext +import org.opentaint.dataflow.taint.MarkUnfoldDemand import org.opentaint.dataflow.ap.ifds.analysis.MethodCallFactMapper import org.opentaint.dataflow.jvm.ap.ifds.JIRFactTypeChecker import org.opentaint.dataflow.jvm.ap.ifds.JIRLambdaTracker @@ -25,6 +26,8 @@ class JIRMethodAnalysisContext( val aliasAnalysis: JIRLocalAliasAnalysis?, val taint: JIRTaintAnalysisContext, ) : MethodAnalysisContext { + override val markUnfoldDemand: MarkUnfoldDemand = MarkUnfoldDemand() + init { taint.bindAnalysisContext(this) } diff --git a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodSideEffectHandler.kt b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodSideEffectHandler.kt index 0ad1697d5..0f345b561 100644 --- a/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodSideEffectHandler.kt +++ b/core/opentaint-dataflow-core/opentaint-jvm-dataflow/src/main/kotlin/org/opentaint/dataflow/jvm/ap/ifds/analysis/JIRMethodSideEffectHandler.kt @@ -1,8 +1,10 @@ package org.opentaint.dataflow.jvm.ap.ifds.analysis import org.opentaint.dataflow.ap.ifds.AnalysisRunner +import org.opentaint.dataflow.ap.ifds.analysis.MethodAnalysisContext import org.opentaint.dataflow.taint.MethodSideEffectHandlerWithAnyAccessorRequestHandling class JIRMethodSideEffectHandler( - override val runner: AnalysisRunner + override val runner: AnalysisRunner, + override val analysisContext: MethodAnalysisContext ) : MethodSideEffectHandlerWithAnyAccessorRequestHandling