Skip to content

Multiple taint analyses as ProductDomain#2543

Draft
MaxAtoms wants to merge 6 commits into
2537-security-randomness-analysisfrom
2537-taint-analysis-determinism
Draft

Multiple taint analyses as ProductDomain#2543
MaxAtoms wants to merge 6 commits into
2537-security-randomness-analysisfrom
2537-taint-analysis-determinism

Conversation

@MaxAtoms

@MaxAtoms MaxAtoms commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

A rough sketch for discussion of what a combination of multiple taint analyses could look like

Closes #2500, #2501 (once finalized)

@MaxAtoms MaxAtoms self-assigned this Jun 9, 2026
@MaxAtoms MaxAtoms added the abstract interpretation Related to abstract interpretation label Jun 9, 2026
@MaxAtoms MaxAtoms changed the title 2537 taint analysis determinism Multiple taint analyses as ProductDomain Jun 10, 2026
Base automatically changed from 2502-taint-analysis-violation-detection to staging/validity-linting June 10, 2026 09:08
@MaxAtoms MaxAtoms changed the base branch from staging/validity-linting to 2537-security-randomness-analysis June 10, 2026 10:21
MaxAtoms added 5 commits June 10, 2026 12:30
Add TaintAnalysisDefinition.compose to combine multiple taint analyses into a
product of their lattice values, evaluated simultaneously during a single
control-flow traversal (direct and reduced products).

- extract shared resolveTaint helper used by single and composite visitors
- add CompositeTaintInferenceVisitor over a TaintProductDomain state
- add createVisitor/RunnableTaintAnalysisDefinition and TaintAnalysis.addComposite
- add composite test helper and tests

diff --git a/src/taint-analysis/builder/taint-analysis-definition.ts b/src/taint-analysis/builder/taint-analysis-definition.ts
index 948789f..65559f5 100644
--- a/src/taint-analysis/builder/taint-analysis-definition.ts
+++ b/src/taint-analysis/builder/taint-analysis-definition.ts
@@ -1,14 +1,49 @@
 import type { AnyAbstractDomain } from '../../abstract-interpretation/domains/abstract-domain';
 import type { TaintMapper } from '../function-mapper';
-import type { AbsintVisitorConfiguration } from '../../abstract-interpretation/absint-visitor';
+import type { AbsintVisitorConfiguration, AbstractInterpretationVisitor } from '../../abstract-interpretation/absint-visitor';
+import type { AnyStateDomain } from '../../abstract-interpretation/domains/state-domain-like';
+import type { TaintReduction } from '../taint-product-domain';
+import type { TaintComponent } from '../composite-taint-visitor';
+import { CompositeTaintInferenceVisitor } from '../composite-taint-visitor';
+import { TaintInferenceVisitor } from '../taint-visitor';
+import { guard } from '../../util/assert';
+
+export type TaintAnalysisName<Definition> =
+	Definition extends RunnableTaintAnalysisDefinition<infer Name> ? Name : never;

 export type TaintAnalysisName<Definition> = Definition extends TaintAnalysisDefinition<infer Name, infer _Domain, infer _Config> ? Name : never;
 export type TaintAnalysisDomain<Definition> = Definition extends TaintAnalysisDefinition<infer _Name, infer Domain> ? Domain : never;

+/**
+ * The common interface of all (runnable) taint analysis definitions, i.e. single {@link TaintAnalysisDefinition|definitions}
+ * and {@link CompositeTaintAnalysisDefinition|composite definitions}. A runnable definition knows its name, an optional
+ * report message, and how to create the abstract interpretation visitor that conducts the analysis.
+ */
+export interface RunnableTaintAnalysisDefinition<Name extends string = string> {
+	/** The unique name of the taint analysis. */
+	readonly name: Name;
+	/** The optional message reported when the analysis produces a finding. */
+	readonly msg?: string;
+	/** Creates the abstract interpretation visitor that conducts the taint analysis for the given visitor configuration. */
+	createVisitor(config: AbsintVisitorConfiguration): AbstractInterpretationVisitor<AnyStateDomain>;
+}
+
+/** Options for composing multiple taint analyses into a {@link CompositeTaintAnalysisDefinition}. */
+export interface ComposeOptions {
+	/**
+	 * Optional reductions turning the direct product into a reduced product.
+	 * Each reduction may refine the inferred taints of the component analyses based on each other.
+	 */
+	reductions?: readonly TaintReduction[];
+	/** The optional message reported when the composite analysis produces a finding. */
+	report?:     string;
+}
+
 /**
  * Fluent builder class for defining new taint analyses.
  */
-export class TaintAnalysisDefinition<Name extends string = string, Domain extends AnyAbstractDomain = AnyAbstractDomain, Config extends AbsintVisitorConfiguration = AbsintVisitorConfiguration> {
+export class TaintAnalysisDefinition<Name extends string = string, Domain extends AnyAbstractDomain = AnyAbstractDomain, Config extends AbsintVisitorConfiguration = AbsintVisitorConfiguration>
+implements RunnableTaintAnalysisDefinition<Name> {
 	public readonly domain: Domain;
 	public mapper:          TaintMapper<Domain> = [];
 	public name:            Name;
@@ -40,4 +75,61 @@ export class TaintAnalysisDefinition<Name extends string = string, Domain extend
 		this._msg = msg;
 		return this;
 	}
-}
\ No newline at end of file
+
+	public createVisitor(config: AbsintVisitorConfiguration): AbstractInterpretationVisitor<AnyStateDomain> {
+		return new TaintInferenceVisitor(this.domain, this.mapper, { ...this.config, ...config });
+	}
+
+	/**
+	 * Composes at least two taint analysis definitions into a single composite taint analysis.
+	 * The component analyses are evaluated simultaneously during a single control-flow traversal and their taints are
+	 * combined into a product of the lattice values per each CFG node (see {@link CompositeTaintInferenceVisitor}).
+	 * @param name        - The unique name of the resulting composite taint analysis
+	 * @param definitions - The component taint analysis definitions to compose (must have unique names)
+	 * @param options     - Optional reductions (for a reduced product) and a report message
+	 */
+	public static compose<Name extends string>(
+		name: Name,
+		definitions: readonly TaintAnalysisDefinition<string>[],
+		options?: ComposeOptions
+	): CompositeTaintAnalysisDefinition<Name> {
+		return new CompositeTaintAnalysisDefinition(name, definitions, options);
+	}
+}
+
+/**
+ * A composite taint analysis definition combining multiple {@link TaintAnalysisDefinition|component analyses} into a
+ * product (or reduced product) taint analysis. Create instances via {@link TaintAnalysisDefinition.compose}.
+ */
+export class CompositeTaintAnalysisDefinition<Name extends string> implements RunnableTaintAnalysisDefinition<Name> {
+	public readonly name:        Name;
+	public readonly definitions: readonly TaintAnalysisDefinition<string>[];
+	public readonly reductions:  readonly TaintReduction[];
+
+	public msg: string | undefined;
+
+	constructor(name: Name, definitions: readonly TaintAnalysisDefinition<string>[], options?: ComposeOptions) {
+		guard(definitions.length >= 2, 'A composite taint analysis must combine at least two taint analysis definitions');
+		const names = definitions.map(def => def.name);
+		guard(new Set(names).size === names.length, 'A composite taint analysis requires unique component analysis names');
+
+		this.name = name;
+		this.definitions = definitions;
+		this.reductions = options?.reductions ?? [];
+		this.msg = options?.report;
+	}
+
+	public report(msg: string): this {
+		this.msg = msg;
+		return this;
+	}
+
+	public createVisitor(config: AbsintVisitorConfiguration): AbstractInterpretationVisitor<AnyStateDomain> {
+		const components: TaintComponent[] = this.definitions.map(def => ({
+			name:   def.name,
+			domain: def.domain,
+			mapper: def.mapper,
+		}));
+		return new CompositeTaintInferenceVisitor(components, this.reductions, config);
+	}
+}
diff --git a/src/taint-analysis/builder/taint-analysis.ts b/src/taint-analysis/builder/taint-analysis.ts
index 5f6ea9f..68c8b9a 100644
--- a/src/taint-analysis/builder/taint-analysis.ts
+++ b/src/taint-analysis/builder/taint-analysis.ts
@@ -1,11 +1,15 @@
 import type { FlowrAnalyzer, ReadonlyFlowrAnalysisProvider } from '../../project/flowr-analyzer';
 import type { TaintAnalysisDefinition, TaintAnalysisDomain } from './taint-analysis-definition';
 import type { AnyPredefinedTaintAnalysisName } from '../predefined/predefined';
+import type { CompositeTaintAnalysisDefinition, RunnableTaintAnalysisDefinition, TaintAnalysisDefinition } from './taint-analysis-definition';
+import type { AnyPredefinedAnalysisName } from '../predefined/predefined';
 import { predefinedTaintAnalyses } from '../predefined/predefined';
 import { TaintInferenceVisitor } from '../taint-visitor';
+import type { AbsintVisitorConfiguration, AbstractInterpretationVisitor } from '../../abstract-interpretation/absint-visitor';
+import type { AnyStateDomain } from '../../abstract-interpretation/domains/state-domain-like';

 export interface TaintInferenceResult<Analysis extends TaintAnalysisDefinition> {
-	visitor:  TaintInferenceVisitor<TaintAnalysisDomain<Analysis>>
+	visitor:  AbstractInterpretationVisitor<TaintAnalysisDomain<Analysis>>
 	finding?: string
 }

@@ -15,7 +19,7 @@ export interface TaintInferenceResult<Analysis extends TaintAnalysisDefinition>
  */
 export class TaintAnalysis<Defs extends readonly string[] = []> {
 	private readonly analyzer: ReadonlyFlowrAnalysisProvider;
-	private readonly defs:     TaintAnalysisDefinition<Defs[number]>[] = [];
+	private readonly defs:     RunnableTaintAnalysisDefinition<Defs[number]>[] = [];

 	constructor(analyzer: ReadonlyFlowrAnalysisProvider) {
 		this.analyzer = analyzer;
@@ -31,20 +35,29 @@ export class TaintAnalysis<Defs extends readonly string[] = []> {
 		return this as unknown as TaintAnalysis<readonly [...Defs, Name]>;
 	}

+	/**
+	 * Add a composite taint analysis that combines multiple taint analyses into a product of their lattice values.
+	 * @see {@link TaintAnalysisDefinition.compose} to create a composite taint analysis definition.
+	 */
+	public addComposite<Name extends string>(def: CompositeTaintAnalysisDefinition<Name>): TaintAnalysis<readonly [...Defs, Name]> {
+		this.defs.push(def);
+		return this as unknown as TaintAnalysis<readonly [...Defs, Name]>;
+	}
+
 	/**
 	 * Run one or multiple taint analyses.
-	 * Note: Requires a prior call to {@link TaintAnalysis.add} or {@link TaintAnalysis.addPredefined} to add at least one taint analysis.
+	 * Note: Requires a prior call to {@link TaintAnalysis.add}, {@link TaintAnalysis.addComposite}, or {@link TaintAnalysis.addPredefined} to add at least one taint analysis.
 	 */
 	public async run(): Promise<Map<Defs[number], TaintInferenceResult<TaintAnalysisDefinition<Defs[number]>>>> {
 		const results: Map<Defs[number], TaintInferenceResult<TaintAnalysisDefinition<Defs[number]>>> = new Map();
+		const baseConfig: AbsintVisitorConfiguration = {
+			controlFlow:   await this.analyzer.controlflow(),
+			ctx:           this.analyzer.inspectContext(),
+			dfg:           (await this.analyzer.dataflow()).graph,
+			normalizedAst: await this.analyzer.normalize()
+		};
 		for(const def of this.defs) {
-			const visitor = new TaintInferenceVisitor(def.domain, def.mapper, {
-				...def.config,
-				controlFlow:   await this.analyzer.controlflow(),
-				ctx:           this.analyzer.inspectContext(),
-				dfg:           (await this.analyzer.dataflow()).graph,
-				normalizedAst: await this.analyzer.normalize()
-			});
+			const visitor = def.createVisitor(baseConfig);
 			visitor.start();

 			const endState = visitor.getEndState();
diff --git a/src/taint-analysis/composite-taint-visitor.ts b/src/taint-analysis/composite-taint-visitor.ts
new file mode 100644
index 000000000..3944c30
--- /dev/null
+++ b/src/taint-analysis/composite-taint-visitor.ts
@@ -0,0 +1,74 @@
+import type { AbsintVisitorConfiguration } from '../abstract-interpretation/absint-visitor';
+import { AbstractInterpretationVisitor } from '../abstract-interpretation/absint-visitor';
+import type { DataflowGraphVertexFunctionCall } from '../dataflow/graph/vertex';
+import type { AnyAbstractDomain } from '../abstract-interpretation/domains/abstract-domain';
+import type { TaintMapper } from './function-mapper';
+import { mapFnCallToTaint, resolveTaint } from './function-mapper';
+import { StateAbstractDomain } from '../abstract-interpretation/domains/state-abstract-domain';
+import type { TaintProduct, TaintReduction } from './taint-product-domain';
+import { TaintProductDomain } from './taint-product-domain';
+
+/**
+ * A single component of a composite taint analysis, i.e. one of the combined taint analyses.
+ * It bundles the analysis name (used as the product key), its (value) abstract domain, and its function mapper.
+ * Note that {@link TaintAnalysisDefinition} structurally conforms to this interface.
+ * @template Domain - The (value) abstract domain of the component analysis
+ */
+export interface TaintComponent<Domain extends AnyAbstractDomain = AnyAbstractDomain> {
+	/** The unique name of the component analysis, used as the key within the {@link TaintProduct}. */
+	readonly name:   string;
+	/** The (value) abstract domain (taint lattice) of the component analysis. */
+	readonly domain: Domain;
+	/** The function mapper relating function names to the tainting behaviour of the component analysis. */
+	readonly mapper: TaintMapper<Domain>;
+}
+
+/**
+ * Abstract interpretation visitor for conducting composite taint analyses.
+ *
+ * In contrast to the single-domain {@link TaintInferenceVisitor}, this visitor evaluates multiple component taint
+ * analyses simultaneously during a single control-flow traversal and combines their taints into a product of the
+ * lattice values per each CFG node. The product is stored in a {@link StateAbstractDomain} of a
+ * {@link TaintProductDomain}, so joins at CFG merge points (as well as widening, meet, and narrowing) are performed
+ * component-wise. Optional reductions turn the otherwise direct product into a reduced product, allowing the
+ * component analyses to refine each other.
+ *
+ * Please prefer using {@link TaintAnalysisDefinition.compose} together with the {@link FlowrAnalyzer.taint} method to
+ * create a composite taint analysis.
+ */
+export class CompositeTaintInferenceVisitor extends AbstractInterpretationVisitor<StateAbstractDomain<TaintProductDomain>> {
+	private readonly components: readonly TaintComponent[];
+
+	constructor(
+		components: readonly TaintComponent[],
+		reductions: readonly TaintReduction[],
+		visitorConfig: AbsintVisitorConfiguration
+	) {
+		const template = Object.fromEntries(components.map(component => [component.name, component.domain])) as Required<TaintProduct>;
+		super(
+			{ ...visitorConfig, ignoreUnsupportedFunctions: false },
+			StateAbstractDomain.top(TaintProductDomain.top(template, reductions))
+		);
+		this.components = components;
+	}
+
+	protected override onFunctionCall({ call }: { call: DataflowGraphVertexFunctionCall }): void {
+		super.onFunctionCall({ call });
+
+		const node = this.getNormalizedAst(call.id);
+
+		if(node === undefined) {
+			return;
+		}
+		const product: TaintProduct = {};
+
+		for(const component of this.components) {
+			const taint = mapFnCallToTaint(node, component.mapper, this.config.dfg, this.config.ctx);
+
+			// project the product state of an argument node onto the component of this analysis (defaulting to Top)
+			product[component.name] = resolveTaint(taint, component.domain, argId =>
+				this.getAbstractValue(argId)?.value[component.name] ?? component.domain.top());
+		}
+		this.currentState.set(node.info.id, this.currentState.domain.create(product));
+	}
+}
diff --git a/src/taint-analysis/function-mapper.ts b/src/taint-analysis/function-mapper.ts
index 112d259..9d81680 100644
--- a/src/taint-analysis/function-mapper.ts
+++ b/src/taint-analysis/function-mapper.ts
@@ -13,6 +13,8 @@ import {
 import type { DataflowGraph } from '../dataflow/graph/graph';
 import type { ReadOnlyFlowrAnalyzerContext } from '../project/context/flowr-analyzer-context';
 import type { PotentiallyEmptyRArgument } from '../r-bridge/lang-4.x/ast/model/nodes/r-function-call';
+import { EmptyArgument } from '../r-bridge/lang-4.x/ast/model/nodes/r-function-call';
+import type { NodeId } from '../r-bridge/lang-4.x/ast/model/processing/node-id';
 import { guard } from '../util/assert';

 export type ResolvedTaint<Domain extends AnyAbstractDomain> =
@@ -74,6 +76,36 @@ export function mapFnCallToTaint<Domain extends AnyAbstractDomain>(
 	}
 }

+/**
+ * Resolves a {@link ResolvedTaint} into the concrete abstract value of the given (value) abstract domain.
+ *
+ * This contains the shared logic used by both the single-analysis {@link TaintInferenceVisitor} and the
+ * composite (product) taint visitor: a resolved taint is either a fixed taint, a conditional taint that
+ * combines the (projected) taints of its argument nodes, or absent (which maps to the Top element).
+ * @param taint      - The resolved taint to map into an abstract value (e.g. obtained via {@link mapFnCallToTaint})
+ * @param domain     - The (value) abstract domain the resulting abstract value belongs to
+ * @param projectArg - Resolves the abstract value of an argument node within `domain` (e.g. the projection of a
+ *                     product state onto the component of the analysis); may return `undefined` if no value is known
+ * @returns The abstract value to store for the function call within the given domain
+ */
+export function resolveTaint<Domain extends AnyAbstractDomain>(
+	taint: ResolvedTaint<Domain>,
+	domain: Domain,
+	projectArg: (id: NodeId) => Domain | undefined
+): Domain {
+	if(!taint) {
+		return domain.top() as Domain;
+	} else if('taint' in taint) {
+		return domain.create(taint.taint);
+	}
+	const taints = taint.taintArgs
+		.map(arg => (arg === EmptyArgument || !arg.value?.info) ? undefined : projectArg(arg.value.info.id))
+		.filter((value): value is Domain => value !== undefined)
+		.map(value => value.value as AbstractValue<Domain>);
+
+	return domain.create(taint.condition(taint.valArgs, taints));
+}
+
 export type TaintMapper<Domain extends AnyAbstractDomain> = TaintMapping<Domain>[];

 export type TaintMapping<Domain extends AnyAbstractDomain> = {
diff --git a/src/taint-analysis/taint-product-domain.ts b/src/taint-analysis/taint-product-domain.ts
new file mode 100644
index 000000000..13babff
--- /dev/null
+++ b/src/taint-analysis/taint-product-domain.ts
@@ -0,0 +1,51 @@
+import type { AbstractProduct } from '../abstract-interpretation/domains/partial-product-domain';
+import { PartialProductDomain } from '../abstract-interpretation/domains/partial-product-domain';
+
+/**
+ * The abstract product mapping the name of a (component) taint analysis to its (value) abstract domain.
+ * Each property of the product holds the inferred taint of the respective component analysis.
+ */
+export type TaintProduct = AbstractProduct;
+
+/**
+ * A reduction function for a {@link TaintProductDomain} turning the otherwise direct product into a reduced product.
+ * It may refine the inferred taints of the component analyses based on each other.
+ */
+export type TaintReduction = (value: TaintProduct) => TaintProduct;
+
+/**
+ * A product abstract domain combining the (value) abstract domains of multiple taint analyses.
+ *
+ * The product is a named Cartesian product keyed by the component analysis names, providing component-wise
+ * {@link join}, {@link meet}, {@link widen}, {@link narrow}, and order operations (inherited from
+ * {@link PartialProductDomain}). The optional {@link reductions} turn the direct product into a reduced product:
+ * they are applied whenever a new product value is created (including the results of joins at CFG merge points),
+ * allowing the component analyses to refine each other.
+ *
+ * Note: reductions are applied in {@link create} (i.e. after construction) instead of via the
+ * {@link PartialProductDomain} reduce hook, since the latter runs during the base constructor before instance
+ * fields (such as the reductions) are initialized.
+ */
+export class TaintProductDomain extends PartialProductDomain<TaintProduct> {
+	public readonly reductions: readonly TaintReduction[];
+
+	constructor(value: TaintProduct, domain: Required<TaintProduct>, reductions: readonly TaintReduction[] = []) {
+		super(value, domain);
+		this.reductions = reductions;
+	}
+
+	public create(value: TaintProduct): this;
+	public create(value: TaintProduct): TaintProductDomain {
+		const reduced = (this.reductions ?? []).reduce((current, reduction) => reduction(current), value);
+		return new TaintProductDomain(reduced, this.domain, this.reductions);
+	}
+
+	/**
+	 * Creates the Top element of the taint product domain (an empty product) for the given component domains.
+	 * @param domain     - The component (value) abstract domains keyed by their analysis name
+	 * @param reductions - The optional reductions for a reduced product
+	 */
+	public static top(domain: Required<TaintProduct>, reductions: readonly TaintReduction[] = []): TaintProductDomain {
+		return new TaintProductDomain({}, domain, reductions);
+	}
+}
diff --git a/src/taint-analysis/taint-visitor.ts b/src/taint-analysis/taint-visitor.ts
index 94fc593..1e7f5f1 100644
--- a/src/taint-analysis/taint-visitor.ts
+++ b/src/taint-analysis/taint-visitor.ts
@@ -1,13 +1,12 @@
 import type { AbsintVisitorConfiguration } from '../abstract-interpretation/absint-visitor';
 import { AbstractInterpretationVisitor } from '../abstract-interpretation/absint-visitor';
 import type { DataflowGraphVertexFunctionCall } from '../dataflow/graph/vertex';
-import type { AbstractValue, AnyAbstractDomain } from '../abstract-interpretation/domains/abstract-domain';
+import type { AnyAbstractDomain } from '../abstract-interpretation/domains/abstract-domain';
 import type { ResolvedTaint, TaintMapper } from './function-mapper';
-import { mapFnCallToTaint } from './function-mapper';
+import { mapFnCallToTaint, resolveTaint } from './function-mapper';
 import type { NodeId } from '../r-bridge/lang-4.x/ast/model/processing/node-id';
 import type { AnyStateDomain } from '../abstract-interpretation/domains/state-domain-like';
 import { StateAbstractDomain } from '../abstract-interpretation/domains/state-abstract-domain';
-import { EmptyArgument } from '../r-bridge/lang-4.x/ast/model/nodes/r-function-call';

 /**
  * Abstract interpretation visitor for conducting taint analyses (i.e., applying finite taint lattices on the control-flow graph).
@@ -37,26 +36,7 @@ export class TaintInferenceVisitor<Domain extends AnyAbstractDomain> extends Abs
 	}

 	private applyFnCall(id: NodeId, taint: ResolvedTaint<Domain>) {
-		if(!taint) {
-			this.currentState.set(id, this.domain.top());
-		} else if('taint' in taint) {
-			this.currentState.set(id, this.domain.create(taint.taint));
-		} else {
-			const taints = taint.taintArgs?.map(t => {
-				if(t === EmptyArgument || !t.value?.info) {
-					this.currentState.set(id, this.domain.top());
-					return;
-				}
-				const abstractValue = this.getAbstractValue(t.value.info.id);
-				if(!abstractValue) {
-					this.currentState.set(id, this.domain.top());
-					return;
-				}
-				return abstractValue;
-			});
-			const taintResult = taints.filter(t => t !== undefined).map(t => t.value);
-			const newValue = taint.condition(taint.valArgs, taintResult as AbstractValue<Domain>[]);
-			return this.currentState.set(id, this.domain.create(newValue));
-		}
+		const value = resolveTaint(taint, this.domain, argId => this.getAbstractValue(argId));
+		this.currentState.set(id, value);
 	}
 }
\ No newline at end of file
diff --git a/test/functionality/taint-analysis/composite.test.ts b/test/functionality/taint-analysis/composite.test.ts
new file mode 100644
index 000000000..a1c055c
--- /dev/null
+++ b/test/functionality/taint-analysis/composite.test.ts
@@ -0,0 +1,83 @@
+import { describe, test } from 'vitest';
+import { testCompositeTaintAnalysis } from './helper';
+import { TaintAnalysisDefinition } from '../../../src/taint-analysis/builder/taint-analysis-definition';
+import { FiniteDomainBuilder } from '../../../src/taint-analysis/builder/domain';
+import { Identifier } from '../../../src/dataflow/environments/identifier';
+import { Bottom, Top } from '../../../src/abstract-interpretation/domains/lattice';
+import { scaleAnalysis, Unscaled, ZScore } from '../../../src/taint-analysis/predefined/scale-analysis';
+import { Deterministic, randomnessAnalysis } from '../../../src/taint-analysis/predefined/randomness-analysis';
+import type { TaintReduction } from '../../../src/taint-analysis/taint-product-domain';
+
+describe('Composite Taint Analysis', () => {
+	describe('direct product of two custom analyses', () => {
+		const TagA = Symbol('A');
+		const TagB = Symbol('B');
+
+		const domainA = new FiniteDomainBuilder<Top, Bottom, [Top, Bottom, typeof TagA]>()
+			.addLeqOrder(Bottom, TagA)
+			.addLeqOrder(TagA, Top)
+			.build();
+		const domainB = new FiniteDomainBuilder<Top, Bottom, [Top, Bottom, typeof TagB]>()
+			.addLeqOrder(Bottom, TagB)
+			.addLeqOrder(TagB, Top)
+			.build();
+
+		const alpha = new TaintAnalysisDefinition('alpha', domainA)
+			.through([{ identifier: Identifier.make('c'), taint: TagA }]);
+		const beta = new TaintAnalysisDefinition('beta', domainB)
+			.through([{ identifier: Identifier.make('list'), taint: TagB }]);
+
+		const composed = TaintAnalysisDefinition.compose('alpha-x-beta', [alpha, beta]);
+
+		test('each component only tags its own source, the other is Top', async() => {
+			await testCompositeTaintAnalysis(`
+				x <- c(1, 2, 3)
+				y <- list(1, 2, 3)`,
+			composed,
+			{
+				'1@x': { alpha: TagA, beta: Top },
+				'2@y': { alpha: Top, beta: TagB },
+			});
+		});
+	});
+
+	describe('direct product of predefined scale and randomness analyses', () => {
+		const composed = TaintAnalysisDefinition.compose('scale-x-randomness', [scaleAnalysis, randomnessAnalysis]);
+
+		test('combines the per-node taint of both analyses', async() => {
+			await testCompositeTaintAnalysis(`
+				x <- c(1, 2, 3, 4, 5)
+				x <- scale(x)`,
+			composed,
+			{
+				'1@x': { scale: Unscaled, randomness: Deterministic },
+				'2@x': { scale: ZScore, randomness: Top },
+			});
+		});
+	});
+
+	describe('reduced product of predefined scale and randomness analyses', () => {
+		// reduction: once a value is z-score scaled, treat the randomness component as Bottom (a contrived interaction)
+		const collapseRandomnessOnZScore: TaintReduction = value => {
+			if(value['scale']?.value === ZScore && value['randomness'] !== undefined) {
+				return { ...value, randomness: value['randomness'].bottom() };
+			}
+			return value;
+		};
+
+		const composed = TaintAnalysisDefinition.compose('scale-x-randomness-reduced', [scaleAnalysis, randomnessAnalysis], {
+			reductions: [collapseRandomnessOnZScore]
+		});
+
+		test('the reduction refines the randomness component based on the scale component', async() => {
+			await testCompositeTaintAnalysis(`
+				x <- c(1, 2, 3, 4, 5)
+				x <- scale(x)`,
+			composed,
+			{
+				'1@x': { scale: Unscaled, randomness: Deterministic },
+				'2@x': { scale: ZScore, randomness: Bottom },
+			});
+		});
+	});
+});
diff --git a/test/functionality/taint-analysis/helper.ts b/test/functionality/taint-analysis/helper.ts
index 165166c..ab18815 100644
--- a/test/functionality/taint-analysis/helper.ts
+++ b/test/functionality/taint-analysis/helper.ts
@@ -8,10 +8,14 @@ import type {
 	AnyPredefinedTaintAnalysisName
 } from '../../../src/taint-analysis/predefined/predefined';
 import { predefinedTaintAnalyses } from '../../../src/taint-analysis/predefined/predefined';
-import type { TaintAnalysisDefinition } from '../../../src/taint-analysis/builder/taint-analysis-definition';
+import type { CompositeTaintAnalysisDefinition, TaintAnalysisDefinition } from '../../../src/taint-analysis/builder/taint-analysis-definition';
+import type { TaintProductDomain } from '../../../src/taint-analysis/taint-product-domain';

 export type TaintAnalysisExpectation = Record<SlicingCriterion, symbol | undefined>;

+/** Expectation for a composite taint analysis mapping each criterion to the expected taint per component analysis. */
+export type CompositeTaintExpectation = Record<SlicingCriterion, Record<string, symbol | undefined>>;
+
 /**
  * Helper function for conducting a singular taint analysis and asserting the expected taints.
  * @param code - The code to analyse
@@ -68,4 +72,38 @@ export async function testTaintAnalyses(code: string, analyses: Set<[string, Tai
 			}
 		}
 	}
+}
+
+/**
+ * Helper function for conducting a composite taint analysis and asserting the expected per-component taints.
+ * @param code - The code to analyse
+ * @param composite - The composite taint analysis definition (created via {@link TaintAnalysisDefinition.compose})
+ * @param expectation - Expected taints per component analysis for each criterion
+ */
+export async function testCompositeTaintAnalysis(
+	code: string,
+	composite: CompositeTaintAnalysisDefinition<string>,
+	expectation: CompositeTaintExpectation
+) {
+	const analyzer = await new FlowrAnalyzerBuilder()
+		.setEngine('tree-sitter')
+		.build();
+
+	analyzer.addRequest(code.trim());
+	const analysis = analyzer.taint() as unknown as AnyTaintAnalysis;
+	analysis.addComposite(composite);
+
+	const results = await analysis.run();
+	const result = results.get(composite.name);
+	guard(result, 'Expected composite taint analysis results are missing');
+
+	for(const [criterion, expectedComponents] of Record.entries(expectation)) {
+		const product = getInferredValueForCriterion(result.visitor, criterion) as TaintProductDomain | undefined;
+
+		for(const [name, expectedValue] of Record.entries(expectedComponents)) {
+			const actualValue = product?.value[name]?.value;
+			assert.equal(actualValue, expectedValue,
+				`Expected inferred taint for criterion "${criterion}" of component "${name}" to be ${String(expectedValue?.toString())}, but got ${String(actualValue?.toString())}`);
+		}
+	}
 }
\ No newline at end of file
PartialProductDomain's constructor invokes the overridable reduce() before subclasses initialize their fields. MultiValueDomain.reduce read this.reductions, which was still undefined at that point, so every construction threw a TypeError - making MultiValueDomain and MultiValueStateDomain unusable, including their reductions feature.

Lift the reductions into PartialProductDomain so the field is set before reduce is called, preserving the reduce-after-clone semantics. Add regression tests.
Now that MultiValueDomain reductions work, replace the bespoke TaintProductDomain with the canonical MultiValueStateDomain for the composite taint analysis state, reducing duplication. Behaviour and tests are unchanged.
@MaxAtoms MaxAtoms force-pushed the 2537-taint-analysis-determinism branch from 1ae714c to b814d50 Compare June 10, 2026 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

abstract interpretation Related to abstract interpretation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant