From 53e9a410b4dc2454f3362601f82056999bfddbc8 Mon Sep 17 00:00:00 2001 From: akanthed Date: Sun, 2 Aug 2026 00:00:08 +0530 Subject: [PATCH] Add SVG dataflow diagrams, extend trace coverage, and trace AI001 across file boundaries Renders every finding's source->flow->sink trace as an inline SVG node-link diagram in HTML reports instead of a flat text list. Extends dataflow tracing to MCP003, AI005, AI012, and VEC003, and teaches AI001 to follow tainted values across function/file boundaries (capped below proven evidence, cycle-safe). Also fixes AI012 never firing due to its own JSON.parse detection colliding with its ".parse(" validation-pattern check. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 13 + README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/scanner/reporter.ts | 98 ++++++-- src/scanner/rules/llm-rule-utils.ts | 47 ++++ .../rules/mcp-unvalidated-tool-result.ts | 75 ++++-- src/scanner/rules/prompt-injection-concat.ts | 237 +++++++++++++++++- src/scanner/rules/unsafe-output-handling.ts | 114 ++++++--- .../rules/unvalidated-structured-output.ts | 62 +++-- src/scanner/rules/vec-user-ingestion.ts | 65 +++-- .../safe/structured_output_validated.ts | 23 ++ .../vulnerable/multihop/mutual_recursion/a.ts | 5 + .../multihop/mutual_recursion/api.ts | 5 + .../vulnerable/multihop/mutual_recursion/b.ts | 16 ++ .../vulnerable/multihop/three_file/api.ts | 9 + .../multihop/three_file/lib/llmclient.ts | 11 + .../multihop/three_file/lib/middle.ts | 5 + .../vulnerable/multihop/two_file/api.ts | 8 + .../multihop/two_file/lib/llmclient.ts | 14 ++ .../unvalidated_structured_output.ts | 15 ++ test/corpus.test.js | 32 +++ 22 files changed, 758 insertions(+), 104 deletions(-) create mode 100644 test-fixtures/safe/structured_output_validated.ts create mode 100644 test-fixtures/vulnerable/multihop/mutual_recursion/a.ts create mode 100644 test-fixtures/vulnerable/multihop/mutual_recursion/api.ts create mode 100644 test-fixtures/vulnerable/multihop/mutual_recursion/b.ts create mode 100644 test-fixtures/vulnerable/multihop/three_file/api.ts create mode 100644 test-fixtures/vulnerable/multihop/three_file/lib/llmclient.ts create mode 100644 test-fixtures/vulnerable/multihop/three_file/lib/middle.ts create mode 100644 test-fixtures/vulnerable/multihop/two_file/api.ts create mode 100644 test-fixtures/vulnerable/multihop/two_file/lib/llmclient.ts create mode 100644 test-fixtures/vulnerable/unvalidated_structured_output.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b22045..55087bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.7.0 — 2026-08-01 + +### Added +- **SVG dataflow diagrams in HTML reports.** `--output report.html` now renders every finding's source→flow→sink trace as an inline node-link diagram instead of a flat text list, with a dashed arrow + "cross-file" label wherever a step crosses a file boundary. +- **Trace coverage extended to four more rules** — MCP003 (tool result elevated to system-role), AI005 (unsafe output handling), AI012 (unvalidated structured output), and VEC003 (user content ingested into a vector store) now carry a full `source → sink` (or `source → flow → sink`) trace, matching AI001's existing dataflow evidence. +- **AI001 now traces across function and file boundaries.** When a tainted parameter is passed into a locally-resolved helper function (same project, import-resolved, not a name guess), the scanner follows the call up to 2 hops and builds a multi-file trace through the real call graph, capped at `likely` evidence (never `proven`) and guarded against cycles (mutual recursion produces exactly one finding, not an infinite loop or duplicates). + +### Fixed +- **AI012 could never fire.** `hasSchemaValidationNearby`'s pattern list included bare `.parse(`, which always matched the rule's own `JSON.parse(` detection target, making the rule permanently silent regardless of input. Fixed with a validator-specific pattern that excludes `JSON.parse` while still catching real schema calls (`mySchema.parse(...)`); locked in with a new vulnerable/safe fixture pair. + +### Notes +- The interprocedural AI001 walker's main value is an accurate, honestly-capped cross-file trace and visualization — not new recall on its own. The rule's existing per-parameter taint fallback already flags the same callee sink locations in isolation (by design, since caller context can be internal); the walker's job is to prove and display the real path when one exists, not to catch cases the base rule was missing. + ## 0.6.1 — 2026-08-01 ### Fixed diff --git a/README.md b/README.md index 0e02bed..2221984 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ Scanning clean? Add the badge to your own README: | Rule | What it proves | OWASP | |------|----------------|-------| -| AI001 | User input flows into a system/developer prompt (traced source → sink) | LLM01 | +| AI001 | User input flows into a system/developer prompt (traced source → sink, including across function/file boundaries) | LLM01 | | AI002 | Prompt content or secrets written to logs (in files that use an LLM SDK) | LLM02 | | AI003 | LLM call in a request handler with no auth check before it | LLM10 | | AI004 | Whole user/session object serialized into a prompt (field-picking is not flagged) | LLM02 | diff --git a/package-lock.json b/package-lock.json index f930f87..8014872 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "secureai-scan", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "secureai-scan", - "version": "0.6.1", + "version": "0.7.0", "license": "MIT", "dependencies": { "commander": "^15.0.0", diff --git a/package.json b/package.json index ef9fa31..8574e10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "secureai-scan", - "version": "0.6.1", + "version": "0.7.0", "description": "AI security scanner with dataflow evidence — prompt injection, MCP tool poisoning, Agent Skill poisoning, RAG poisoning. Evidence-tiered findings (no false-positive noise by default), SARIF for GitHub code scanning, AI-BOM with OWASP LLM/ASI/MCP Top 10 mapping.", "author": "Akshay Kanthed", "license": "MIT", diff --git a/src/scanner/reporter.ts b/src/scanner/reporter.ts index 469f3e5..8179d5a 100644 --- a/src/scanner/reporter.ts +++ b/src/scanner/reporter.ts @@ -605,6 +605,74 @@ function escapeHtml(value: string): string { .replace(/'/g, "'"); } +function truncateText(value: string, maxChars: number): string { + return value.length > maxChars ? value.slice(0, maxChars - 1) + "…" : value; +} + +function traceKindColorVar(kind: TraceStep["kind"]): string { + if (kind === "source") return "var(--high)"; + if (kind === "sink") return "var(--critical)"; + return "var(--accent)"; +} + +/** + * Renders a source→flow→sink trace as an inline SVG node-link diagram + * instead of a flat list, so a multi-hop finding reads as dataflow rather + * than a call stack. No charting dependency — hand-rolled SVG, consistent + * with the rest of this report being a single self-contained HTML file. + * `idKey` must be unique per occurrence: SVG ids are referenced via + * url(#id) and, while harmless when duplicated (same content), are only + * spec-valid when unique across the document. + */ +function renderTraceSvg(trace: TraceStep[], idKey: string): string { + if (trace.length === 0) return ""; + + const NODE_W = 200; + const NODE_H = 56; + const GAP = 56; + const TOP = 22; + const PAD = 12; + const markerId = `trace-arrow-${idKey}`; + + const totalW = trace.length * NODE_W + (trace.length - 1) * GAP + PAD * 2; + const totalH = PAD + TOP + NODE_H + PAD; + + let arrows = ""; + let nodes = ""; + + trace.forEach((step, i) => { + const x = PAD + i * (NODE_W + GAP); + const y = PAD + TOP; + const color = traceKindColorVar(step.kind); + const loc = truncateText(`${step.file}:${step.line}`, 30); + const note = truncateText(step.note, 34); + + if (i > 0) { + const prev = trace[i - 1]; + const crossFile = prev.file !== step.file; + const x1 = PAD + (i - 1) * (NODE_W + GAP) + NODE_W; + const x2 = x; + const ay = y + NODE_H / 2; + arrows += ``; + if (crossFile) { + arrows += `cross-file`; + } + } + + nodes += ` + ${step.kind.toUpperCase()} + + ${escapeHtml(loc)} + ${escapeHtml(note)}`; + }); + + return `
"))}"> + + ${arrows} + ${nodes} +
`; +} + function formatHtml(report: ReportModel): string { const { summary } = report; @@ -621,17 +689,11 @@ function formatHtml(report: ReportModel): string { const groupsHtml = report.groups .map((group) => { const occs = group.occurrences - .map((occ) => { - const trace = occ.trace - ? `
${occ.trace - .map( - (s) => - `
${s.kind}${escapeHtml( - `${s.file}:${s.line}`, - )}${escapeHtml(s.note)}
`, - ) - .join("")}
` - : ""; + .map((occ, occIdx) => { + const trace = + occ.trace && occ.trace.length > 0 + ? renderTraceSvg(occ.trace, `${group.ruleId}-${occIdx}`) + : ""; const snippet = occ.snippet ? `
${occ.snippet
                 .map(
@@ -735,11 +797,15 @@ function formatHtml(report: ReportModel): string {
   .occurrences li { margin-bottom: 12px; }
   code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 13px;
     background: color-mix(in srgb, var(--border) 40%, transparent); border-radius: 4px; padding: 1px 5px; }
-  .trace { margin: 8px 0; border-left: 2px solid var(--border); padding-left: 12px; }
-  .trace-step { display: flex; gap: 10px; align-items: baseline; font-size: 13px; margin: 2px 0; }
-  .trace-kind { width: 52px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
-  .trace-kind.source { color: var(--high); }
-  .trace-kind.sink { color: var(--critical); }
+  .trace-diagram { margin: 10px 0; overflow-x: auto; }
+  .trace-diagram svg { display: block; }
+  .trace-node { fill: var(--card); stroke-width: 2; }
+  .trace-kind-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; }
+  .trace-loc { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; fill: var(--text); }
+  .trace-note { font-size: 11px; fill: var(--muted); }
+  .trace-arrow-line { stroke: var(--muted); stroke-width: 2; }
+  .trace-arrow-head { fill: var(--muted); }
+  .trace-crossfile { font-size: 9px; fill: var(--accent); text-transform: uppercase; letter-spacing: 0.05em; }
   .snippet { background: color-mix(in srgb, var(--border) 25%, transparent); border: 1px solid var(--border);
     border-radius: 6px; padding: 8px 0; overflow-x: auto; margin: 8px 0 0; }
   .code-line { display: grid; grid-template-columns: 48px 1fr; gap: 10px; padding: 0 12px;
diff --git a/src/scanner/rules/llm-rule-utils.ts b/src/scanner/rules/llm-rule-utils.ts
index eecac45..9b6f35b 100644
--- a/src/scanner/rules/llm-rule-utils.ts
+++ b/src/scanner/rules/llm-rule-utils.ts
@@ -342,3 +342,50 @@ export function isRequestLikeNode(node: Node): boolean {
   const text = node.getText().toLowerCase();
   return /\b(req|request|ctx)\s*\./.test(text) || /\b(body|query|params)\b/.test(text);
 }
+
+/**
+ * Resolves a bare-identifier call (`buildPrompt(x)`) to the FunctionDeclaration
+ * it invokes, but only when the resolution is completely unambiguous and the
+ * declaration lives in a file this scan actually parsed. Used to follow
+ * tainted data across a function-call boundary in interprocedural taint
+ * tracing (AI001) — deliberately has no name-heuristic fallback path (unlike
+ * resolveLlmSink): a call this can't cleanly resolve is a call the caller
+ * must not follow, since trusting an unseen function's behavior sight-unseen
+ * is the riskiest step in that analysis. Covers both a same-file helper
+ * (symbol resolves straight to the FunctionDeclaration) and an imported one
+ * (symbol resolves to an ImportSpecifier/Clause/NamespaceImport, one more
+ * hop via getAliasedSymbol() reaches the real declaration). Anything else —
+ * a reassigned function reference, a method call, an overload set, an
+ * external/node_modules declaration — resolves to something other than a
+ * single FunctionDeclaration inside `projectFiles` and is rejected.
+ */
+export function resolveLocalCallTarget(
+  call: Node,
+  projectFiles: Set,
+): Node | undefined {
+  if (!Node.isCallExpression(call)) return undefined;
+  const callee = call.getExpression();
+  if (!Node.isIdentifier(callee)) return undefined;
+
+  const symbol = callee.getSymbol();
+  if (!symbol) return undefined;
+
+  let targetDecls = symbol.getDeclarations();
+  if (
+    targetDecls.length === 1 &&
+    (Node.isImportSpecifier(targetDecls[0]) ||
+      Node.isImportClause(targetDecls[0]) ||
+      Node.isNamespaceImport(targetDecls[0]))
+  ) {
+    const aliased = symbol.getAliasedSymbol?.();
+    if (!aliased) return undefined;
+    targetDecls = aliased.getDeclarations();
+  }
+
+  if (targetDecls.length !== 1) return undefined;
+  const decl = targetDecls[0];
+  if (!Node.isFunctionDeclaration(decl)) return undefined;
+  if (!projectFiles.has(decl.getSourceFile())) return undefined;
+
+  return decl;
+}
diff --git a/src/scanner/rules/mcp-unvalidated-tool-result.ts b/src/scanner/rules/mcp-unvalidated-tool-result.ts
index 8f67ecc..020eaff 100644
--- a/src/scanner/rules/mcp-unvalidated-tool-result.ts
+++ b/src/scanner/rules/mcp-unvalidated-tool-result.ts
@@ -1,5 +1,5 @@
 import { Node, SyntaxKind } from "ts-morph";
-import type { Finding, Rule, RuleContext } from "../types.js";
+import type { Finding, Rule, RuleContext, TraceStep } from "../types.js";
 import { getNodeLine, getRelativeFilePath } from "../../utils/ast.js";
 import { isLikelyLlmCall } from "./llm-rule-utils.js";
 import { evidenceConfidence, demoteEvidence, isTestFilePath, hasSanitizationNearby } from "../confidence.js";
@@ -25,15 +25,19 @@ function isToolResultVar(name: string): boolean {
   return TOOL_RESULT_PATTERNS.some((p) => name.toLowerCase().includes(p));
 }
 
-function collectToolResultVars(fnNode: Node): Set {
-  const vars = new Set();
+interface ToolResultOrigin {
+  line: number;
+  note: string;
+}
+
+function collectToolResultVars(fnNode: Node): Map {
+  const vars = new Map();
   for (const decl of fnNode.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
     const name = decl.getName();
-    if (isToolResultVar(name)) vars.add(name);
     const init = decl.getInitializer();
-    if (init) {
-      const initText = init.getText().toLowerCase();
-      if (TOOL_RESULT_PATTERNS.some((p) => initText.includes(p))) vars.add(name);
+    const initText = init?.getText().toLowerCase() ?? "";
+    if (isToolResultVar(name) || TOOL_RESULT_PATTERNS.some((p) => initText.includes(p))) {
+      vars.set(name, { line: getNodeLine(decl), note: `MCP tool result \`${name}\`` });
     }
   }
 
@@ -42,15 +46,27 @@ function collectToolResultVars(fnNode: Node): Set {
     for (const param of (fnNode as any).getParameters()) {
       const nameNode = param.getNameNode?.();
       if (nameNode && Node.isIdentifier(nameNode) && isToolResultVar(nameNode.getText())) {
-        vars.add(nameNode.getText());
+        vars.set(nameNode.getText(), {
+          line: getNodeLine(param),
+          note: `MCP tool result parameter \`${nameNode.getText()}\``,
+        });
       }
     }
   }
   return vars;
 }
 
-function toolResultElevatedToHighTrustRole(call: Node, toolVars: Set): boolean {
-  if (!Node.isCallExpression(call)) return false;
+interface ElevationMatch {
+  varName: string;
+  role: string;
+  contentLine: number;
+}
+
+function findElevatedToolResultUsage(
+  call: Node,
+  toolVars: Map,
+): ElevationMatch | undefined {
+  if (!Node.isCallExpression(call)) return undefined;
   for (const arg of call.getArguments()) {
     if (!Node.isObjectLiteralExpression(arg)) continue;
     const messagesNode = arg
@@ -76,11 +92,20 @@ function toolResultElevatedToHighTrustRole(call: Node, toolVars: Set): b
 
       const roleVal = roleProp.getInitializer()?.getText().replace(/['"]/g, "").toLowerCase();
       if (!roleVal || !ELEVATED_ROLES.includes(roleVal)) continue;
-      const contentText = contentProp.getInitializer()?.getText() ?? "";
-      if ([...toolVars].some((v) => contentText.includes(v))) return true;
+      const contentInit = contentProp.getInitializer();
+      const contentText = contentInit?.getText() ?? "";
+      for (const varName of toolVars.keys()) {
+        if (contentText.includes(varName)) {
+          return {
+            varName,
+            role: roleVal,
+            contentLine: contentInit ? getNodeLine(contentInit) : getNodeLine(el),
+          };
+        }
+      }
     }
   }
-  return false;
+  return undefined;
 }
 
 export const ruleMcpUnvalidatedToolResult: Rule = {
@@ -109,9 +134,30 @@ export const ruleMcpUnvalidatedToolResult: Rule = {
 
         for (const call of fnNode.getDescendantsOfKind(SyntaxKind.CallExpression)) {
           if (!isLikelyLlmCall(call)) continue;
-          if (!toolResultElevatedToHighTrustRole(call, toolVars)) continue;
+          const match = findElevatedToolResultUsage(call, toolVars);
+          if (!match) continue;
 
           const evidence: Evidence = hasSanitization ? demoteEvidence("likely") : "likely";
+          const origin = toolVars.get(match.varName)!;
+          const sinkLine = getNodeLine(call);
+
+          const trace: TraceStep[] = [
+            { kind: "source", file: relPath, line: origin.line, note: origin.note },
+          ];
+          if (match.contentLine !== origin.line) {
+            trace.push({
+              kind: "flow",
+              file: relPath,
+              line: match.contentLine,
+              note: `placed in ${match.role}-role message content`,
+            });
+          }
+          trace.push({
+            kind: "sink",
+            file: relPath,
+            line: sinkLine,
+            note: `${call.getExpression().getText()} — ${match.role} role`,
+          });
 
           findings.push({
             rule_id: "MCP003",
@@ -126,6 +172,7 @@ export const ruleMcpUnvalidatedToolResult: Rule = {
               "Always place tool results in the 'tool' role (not 'system' or 'developer'). Validate and sanitize tool outputs before including them in any message context. Use output schemas to restrict the shape of tool responses.",
             confidence: evidenceConfidence(evidence),
             evidence,
+            trace,
           });
         }
       }
diff --git a/src/scanner/rules/prompt-injection-concat.ts b/src/scanner/rules/prompt-injection-concat.ts
index 65b82f3..ace542d 100644
--- a/src/scanner/rules/prompt-injection-concat.ts
+++ b/src/scanner/rules/prompt-injection-concat.ts
@@ -1,8 +1,15 @@
-import { Node, SyntaxKind } from "ts-morph";
+import { Node, SyntaxKind, type SourceFile } from "ts-morph";
 import type { Evidence, Finding, Rule, RuleContext, TraceStep } from "../types.js";
 import { getNodeLine, getRelativeFilePath, isStringConcatenation } from "../../utils/ast.js";
-import { evidenceConfidence, demoteEvidence, isTestFilePath } from "../confidence.js";
-import { getPromptParts, resolveLlmSink } from "./llm-rule-utils.js";
+import { evidenceConfidence, demoteEvidence, isTestFilePath, hasSanitizationNearby } from "../confidence.js";
+import { getPromptParts, resolveLlmSink, resolveLocalCallTarget } from "./llm-rule-utils.js";
+
+// Interprocedural walk: at most this many function-call boundaries crossed
+// between the tainted source and the LLM sink. Stricter than the depth>5
+// guard on import-alias unwrapping elsewhere in this codebase — each hop
+// here means trusting an entire second function's behavior based on static
+// shape alone, a materially riskier bet than unwrapping a variable alias.
+const MAX_INTERPROCEDURAL_HOPS = 2;
 
 interface TaintInfo {
   /** identifier name → how it became tainted */
@@ -37,10 +44,22 @@ function isFunctionLike(node: Node): boolean {
  * Collect tainted identifiers in a function scope, propagating through
  * variable declarations whose initializer references a tainted identifier
  * (template literals, concatenation, direct copies). Iterates to fixpoint.
+ *
+ * `seed`, when provided, taints a specific parameter with an origin carried
+ * in from a caller — this is how the interprocedural walk (below) continues
+ * tracing a request-derived value across a function-call boundary without
+ * duplicating any of this propagation logic.
  */
-function collectTaint(fnNode: Node): TaintInfo {
+function collectTaint(
+  fnNode: Node,
+  seed?: { paramName: string; origin: string; line: number },
+): TaintInfo {
   const tainted = new Map();
 
+  if (seed) {
+    tainted.set(seed.paramName, { origin: seed.origin, line: seed.line, viaTemplate: false });
+  }
+
   if (isFunctionLike(fnNode)) {
     const fn = fnNode as import("ts-morph").FunctionDeclaration;
     for (const param of fn.getParameters()) {
@@ -165,12 +184,166 @@ function isDynamicComposition(node: Node, taint: TaintInfo): boolean {
     .length > 0;
 }
 
+/** First tainted argument (and its position) passed into a call, if any. */
+function findTaintedArgIndex(
+  call: Node,
+  taint: TaintInfo,
+): { index: number; ref: { name: string; origin: string; originLine: number } } | undefined {
+  if (!Node.isCallExpression(call)) return undefined;
+  const args = call.getArguments();
+  for (let i = 0; i < args.length; i += 1) {
+    const ref = findTaintedRef(args[i], taint);
+    if (ref) return { index: i, ref };
+  }
+  return undefined;
+}
+
+/**
+ * Continues a prompt-injection trace inside a locally-resolved callee
+ * function, reusing the exact same single-function detection logic
+ * (`isDynamicComposition` / `findTaintedRef` / `resolveLlmSink` /
+ * `getPromptParts`) as the base case in `run()` below — this function only
+ * adds the recursion, trace accumulation, and evidence capping around it.
+ */
+function traceInterproceduralSink(
+  fnNode: Node,
+  taint: TaintInfo,
+  relFile: string,
+  precedingSteps: TraceStep[],
+  rootOrigin: string,
+  crossedTestFile: boolean,
+  hopsRemaining: number,
+  visited: Set,
+  projectFiles: Set,
+  rootPath: string,
+  findings: Finding[],
+): void {
+  for (const call of fnNode.getDescendantsOfKind(SyntaxKind.CallExpression)) {
+    const sink = resolveLlmSink(call);
+
+    if (sink) {
+      for (const part of getPromptParts(call)) {
+        if (part.role === "user" || part.role === "assistant" || part.role === "tool") continue;
+        if (!isDynamicComposition(part.node, taint)) continue;
+        const taintedRef = findTaintedRef(part.node, taint);
+        if (!taintedRef) continue;
+
+        const isSystemRole = part.role === "system" || part.role === "developer";
+        const sinkLine = getNodeLine(part.node);
+
+        // Interprocedural findings are capped at "likely" even when the sink
+        // is import-resolved: the dataflow crossed a function boundary that
+        // was trusted based on static shape (parameter binding + no visible
+        // sanitization), not fully verified the way a single-function trace
+        // is. Never "proven" — see the plan's precision-risk rationale.
+        let evidence: Evidence = "likely";
+        if (crossedTestFile) evidence = demoteEvidence(evidence);
+        if (!rootOrigin.startsWith("request data")) evidence = demoteEvidence(evidence);
+
+        const trace: TraceStep[] = [...precedingSteps];
+        if (taintedRef.name !== taintedRef.origin && !Node.isIdentifier(part.node)) {
+          trace.push({
+            kind: "flow",
+            file: relFile,
+            line: sinkLine,
+            note: `interpolated via \`${taintedRef.name}\``,
+          });
+        } else if (Node.isIdentifier(part.node)) {
+          trace.push({
+            kind: "flow",
+            file: relFile,
+            line: sinkLine,
+            note: `passed as \`${part.node.getText()}\``,
+          });
+        }
+        trace.push({
+          kind: "sink",
+          file: relFile,
+          line: getNodeLine(call),
+          note: `${sink.callText} — ${isSystemRole ? `${part.role} role` : `${part.role} field`} (${sink.provider})`,
+        });
+
+        findings.push({
+          rule_id: "AI001",
+          title: "Prompt injection via user input",
+          severity: isSystemRole ? "high" : "medium",
+          file: relFile,
+          line: sinkLine,
+          summary: isSystemRole
+            ? `User-controlled data reaches a ${part.role}-role prompt through a helper function call.`
+            : "User-controlled data is mixed into the prompt string through a helper function call.",
+          description: isSystemRole
+            ? `Data originating from ${rootOrigin} flows through one or more function calls into the ${part.role} prompt of a ${sink.provider} call. Anything a user types becomes privileged instructions: "ignore previous instructions" attacks work directly.`
+            : `Data originating from ${rootOrigin} flows through one or more function calls into the prompt string of a ${sink.provider} call, mixing untrusted text with instructions in the same trust context.`,
+          recommendation:
+            "Keep system/developer prompts static. Pass user input as a separate user-role message, and validate/sanitize any value a helper function forwards into a prompt.",
+          confidence: evidenceConfidence(evidence),
+          evidence,
+          trace,
+        });
+      }
+      continue;
+    }
+
+    if (hopsRemaining <= 0) continue;
+
+    const argMatch = findTaintedArgIndex(call, taint);
+    if (!argMatch) continue;
+
+    const target = resolveLocalCallTarget(call, projectFiles);
+    if (!target || !Node.isFunctionDeclaration(target) || visited.has(target)) continue;
+    if (hasSanitizationNearby(target.getText())) continue;
+
+    const param = target.getParameters()[argMatch.index];
+    if (!param) continue;
+    const paramNameNode = param.getNameNode();
+    if (!Node.isIdentifier(paramNameNode) || param.isRestParameter()) continue;
+
+    const paramName = paramNameNode.getText();
+    const calleeRelFile = getRelativeFilePath(rootPath, target.getSourceFile());
+    const calleeTestFile = isTestFilePath(calleeRelFile);
+
+    const calleeTaint = collectTaint(target, {
+      paramName,
+      origin: argMatch.ref.origin,
+      line: getNodeLine(param),
+    });
+
+    const crossingSteps: TraceStep[] = [
+      ...precedingSteps,
+      {
+        kind: "flow",
+        file: relFile,
+        line: getNodeLine(call),
+        note: `passed to \`${call.getExpression().getText()}(...)\` in ${calleeRelFile}`,
+      },
+    ];
+
+    visited.add(target);
+    traceInterproceduralSink(
+      target,
+      calleeTaint,
+      calleeRelFile,
+      crossingSteps,
+      rootOrigin,
+      crossedTestFile || calleeTestFile,
+      hopsRemaining - 1,
+      visited,
+      projectFiles,
+      rootPath,
+      findings,
+    );
+    visited.delete(target);
+  }
+}
+
 export const rulePromptInjectionConcat: Rule = {
   id: "AI001",
   title: "Prompt injection via user input",
   severity: "high",
   run(context: RuleContext): Finding[] {
     const findings: Finding[] = [];
+    const projectFiles = new Set(context.sourceFiles);
 
     for (const sourceFile of context.sourceFiles) {
       const relFile = getRelativeFilePath(context.rootPath, sourceFile);
@@ -184,7 +357,61 @@ export const rulePromptInjectionConcat: Rule = {
 
         for (const call of fnNode.getDescendantsOfKind(SyntaxKind.CallExpression)) {
           const sink = resolveLlmSink(call);
-          if (!sink) continue;
+          if (!sink) {
+            // Not a direct LLM sink — if tainted data flows into a call this
+            // scan can resolve to a real local function declaration, keep
+            // tracing inside it (interprocedural continuation, bounded by
+            // MAX_INTERPROCEDURAL_HOPS). Any resolution failure along the
+            // way — ambiguous target, external function, sanitized param —
+            // stops the walk rather than guessing.
+            const argMatch = findTaintedArgIndex(call, taint);
+            if (!argMatch) continue;
+
+            const target = resolveLocalCallTarget(call, projectFiles);
+            if (!target || !Node.isFunctionDeclaration(target)) continue;
+            if (hasSanitizationNearby(target.getText())) continue;
+
+            const param = target.getParameters()[argMatch.index];
+            if (!param) continue;
+            const paramNameNode = param.getNameNode();
+            if (!Node.isIdentifier(paramNameNode) || param.isRestParameter()) continue;
+
+            const paramName = paramNameNode.getText();
+            const calleeRelFile = getRelativeFilePath(context.rootPath, target.getSourceFile());
+            const calleeTestFile = isTestFilePath(calleeRelFile);
+
+            const calleeTaint = collectTaint(target, {
+              paramName,
+              origin: argMatch.ref.origin,
+              line: getNodeLine(param),
+            });
+
+            const steps: TraceStep[] = [
+              { kind: "source", file: relFile, line: argMatch.ref.originLine, note: argMatch.ref.origin },
+              {
+                kind: "flow",
+                file: relFile,
+                line: getNodeLine(call),
+                note: `passed to \`${call.getExpression().getText()}(...)\` in ${calleeRelFile}`,
+              },
+            ];
+
+            const visited = new Set([fnNode, target]);
+            traceInterproceduralSink(
+              target,
+              calleeTaint,
+              calleeRelFile,
+              steps,
+              argMatch.ref.origin,
+              testFile || calleeTestFile,
+              MAX_INTERPROCEDURAL_HOPS - 1,
+              visited,
+              projectFiles,
+              context.rootPath,
+              findings,
+            );
+            continue;
+          }
 
           for (const part of getPromptParts(call)) {
             // Untrusted input inside a *user/assistant-role* message is the
diff --git a/src/scanner/rules/unsafe-output-handling.ts b/src/scanner/rules/unsafe-output-handling.ts
index edd891d..9ae2925 100644
--- a/src/scanner/rules/unsafe-output-handling.ts
+++ b/src/scanner/rules/unsafe-output-handling.ts
@@ -1,5 +1,5 @@
 import { Node, SyntaxKind } from "ts-morph";
-import type { Finding, Rule, RuleContext } from "../types.js";
+import type { Finding, Rule, RuleContext, TraceStep } from "../types.js";
 import { getNodeLine, getRelativeFilePath } from "../../utils/ast.js";
 import { evidenceConfidence } from "../confidence.js";
 import { isLikelyLlmCall, resolveLlmSink } from "./llm-rule-utils.js";
@@ -16,8 +16,13 @@ const DANGEROUS_CALLEES = [
   "raw",
 ];
 
-function collectLlmOutputIdentifiers(functionNode: Node): Set {
-  const outputs = new Set();
+interface OutputOrigin {
+  line: number;
+  note: string;
+}
+
+function collectLlmOutputIdentifiers(functionNode: Node): Map {
+  const outputs = new Map();
 
   for (const declaration of functionNode.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
     const initializer = declaration.getInitializer();
@@ -30,11 +35,16 @@ function collectLlmOutputIdentifiers(functionNode: Node): Set {
         .getDescendantsOfKind(SyntaxKind.CallExpression)
         .some(isLikelyLlmCall)
     ) {
-      outputs.add(declaration.getName().toLowerCase());
+      const name = declaration.getName();
+      outputs.set(name.toLowerCase(), {
+        line: getNodeLine(declaration),
+        note: `LLM response \`${name}\``,
+      });
     }
   }
 
   // Propagate through derivations: const code = completion.choices[0].message.content
+  // — the origin stays the earliest LLM-call assignment in the chain.
   let changed = true;
   let passes = 0;
   while (changed && passes < 4) {
@@ -45,12 +55,15 @@ function collectLlmOutputIdentifiers(functionNode: Node): Set {
       if (outputs.has(name)) continue;
       const initializer = declaration.getInitializer();
       if (!initializer) continue;
-      const derived = initializer
+      const derivedFrom = initializer
         .getDescendantsOfKind(SyntaxKind.Identifier)
-        .some((id) => outputs.has(id.getText().toLowerCase()));
-      const direct = Node.isIdentifier(initializer) && outputs.has(initializer.getText().toLowerCase());
-      if (derived || direct) {
-        outputs.add(name);
+        .find((id) => outputs.has(id.getText().toLowerCase()));
+      const parentKey =
+        Node.isIdentifier(initializer) && outputs.has(initializer.getText().toLowerCase())
+          ? initializer.getText().toLowerCase()
+          : derivedFrom?.getText().toLowerCase();
+      if (parentKey) {
+        outputs.set(name, outputs.get(parentKey)!);
         changed = true;
       }
     }
@@ -59,18 +72,21 @@ function collectLlmOutputIdentifiers(functionNode: Node): Set {
   return outputs;
 }
 
-function callUsesLlmOutput(call: Node, llmOutputs: Set): boolean {
+/** First LLM-output identifier referenced in a call's arguments, if any. */
+function matchingLlmOutputArg(call: Node, llmOutputs: Map): string | undefined {
   if (!Node.isCallExpression(call)) {
-    return false;
+    return undefined;
   }
-  return call
-    .getArguments()
-    .some((arg) =>
-      arg
-        .getDescendantsOfKind(SyntaxKind.Identifier)
-        .some((identifier) => llmOutputs.has(identifier.getText().toLowerCase())) ||
-      (Node.isIdentifier(arg) && llmOutputs.has(arg.getText().toLowerCase())),
-    );
+  for (const arg of call.getArguments()) {
+    if (Node.isIdentifier(arg) && llmOutputs.has(arg.getText().toLowerCase())) {
+      return arg.getText().toLowerCase();
+    }
+    const hit = arg
+      .getDescendantsOfKind(SyntaxKind.Identifier)
+      .find((identifier) => llmOutputs.has(identifier.getText().toLowerCase()));
+    if (hit) return hit.getText().toLowerCase();
+  }
+  return undefined;
 }
 
 function isDangerousSink(call: Node): boolean {
@@ -97,18 +113,21 @@ function isDangerousSink(call: Node): boolean {
   return true;
 }
 
-function isInnerHtmlAssignment(node: Node, llmOutputs: Set): boolean {
+/** LLM-output identifier assigned into .innerHTML/.outerHTML, if any. */
+function innerHtmlLlmOutputMatch(node: Node, llmOutputs: Map): string | undefined {
   if (!Node.isBinaryExpression(node)) {
-    return false;
+    return undefined;
   }
   const left = node.getLeft().getText().toLowerCase();
   if (!left.endsWith(".innerhtml") && !left.endsWith(".outerhtml")) {
-    return false;
+    return undefined;
   }
   return node
     .getRight()
     .getDescendantsOfKind(SyntaxKind.Identifier)
-    .some((identifier) => llmOutputs.has(identifier.getText().toLowerCase()));
+    .find((identifier) => llmOutputs.has(identifier.getText().toLowerCase()))
+    ?.getText()
+    .toLowerCase();
 }
 
 export const ruleUnsafeOutputHandling: Rule = {
@@ -119,6 +138,8 @@ export const ruleUnsafeOutputHandling: Rule = {
     const findings: Finding[] = [];
 
     for (const sourceFile of context.sourceFiles) {
+      const relFile = getRelativeFilePath(context.rootPath, sourceFile);
+
       for (const functionNode of sourceFile.getDescendants()) {
         if (
           !Node.isFunctionDeclaration(functionNode) &&
@@ -135,15 +156,28 @@ export const ruleUnsafeOutputHandling: Rule = {
         }
 
         for (const call of functionNode.getDescendantsOfKind(SyntaxKind.CallExpression)) {
-          if (!isDangerousSink(call) || !callUsesLlmOutput(call, llmOutputs)) {
-            continue;
-          }
+          if (!isDangerousSink(call)) continue;
+          const matched = matchingLlmOutputArg(call, llmOutputs);
+          if (!matched) continue;
+          const origin = llmOutputs.get(matched)!;
+          const sinkLine = getNodeLine(call);
+
+          const trace: TraceStep[] = [
+            { kind: "source", file: relFile, line: origin.line, note: origin.note },
+            {
+              kind: "sink",
+              file: relFile,
+              line: sinkLine,
+              note: `passed to \`${call.getExpression().getText()}\``,
+            },
+          ];
+
           findings.push({
             rule_id: "AI005",
             title: "Unsafe LLM output handling",
             severity: "critical",
-            file: getRelativeFilePath(context.rootPath, sourceFile),
-            line: getNodeLine(call),
+            file: relFile,
+            line: sinkLine,
             summary: "LLM output is passed to a dangerous sink.",
             description:
               "Model output flows into code execution, command execution, database, HTML, or parser behavior without an obvious validation boundary.",
@@ -151,19 +185,32 @@ export const ruleUnsafeOutputHandling: Rule = {
               "Validate model output against a strict schema and keep it away from eval, shell, SQL, HTML, and dynamic execution sinks.",
             confidence: evidenceConfidence("likely"),
             evidence: "likely",
+            trace,
           });
         }
 
         for (const assignment of functionNode.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
-          if (!isInnerHtmlAssignment(assignment, llmOutputs)) {
-            continue;
-          }
+          const matched = innerHtmlLlmOutputMatch(assignment, llmOutputs);
+          if (!matched) continue;
+          const origin = llmOutputs.get(matched)!;
+          const sinkLine = getNodeLine(assignment);
+
+          const trace: TraceStep[] = [
+            { kind: "source", file: relFile, line: origin.line, note: origin.note },
+            {
+              kind: "sink",
+              file: relFile,
+              line: sinkLine,
+              note: `assigned to \`${assignment.getLeft().getText()}\``,
+            },
+          ];
+
           findings.push({
             rule_id: "AI005",
             title: "Unsafe LLM output handling",
             severity: "critical",
-            file: getRelativeFilePath(context.rootPath, sourceFile),
-            line: getNodeLine(assignment),
+            file: relFile,
+            line: sinkLine,
             summary: "LLM output is assigned to HTML.",
             description:
               "Model output is rendered as HTML without an obvious sanitizer, which can turn prompt output into script execution.",
@@ -171,6 +218,7 @@ export const ruleUnsafeOutputHandling: Rule = {
               "Render model output as text or sanitize it with a proven HTML sanitizer before assigning it to DOM HTML sinks.",
             confidence: evidenceConfidence("likely"),
             evidence: "likely",
+            trace,
           });
         }
       }
diff --git a/src/scanner/rules/unvalidated-structured-output.ts b/src/scanner/rules/unvalidated-structured-output.ts
index 95798aa..c475c70 100644
--- a/src/scanner/rules/unvalidated-structured-output.ts
+++ b/src/scanner/rules/unvalidated-structured-output.ts
@@ -1,5 +1,5 @@
 import { Node, SyntaxKind } from "ts-morph";
-import type { Finding, Rule, RuleContext } from "../types.js";
+import type { Finding, Rule, RuleContext, TraceStep } from "../types.js";
 import { getNodeLine, getRelativeFilePath } from "../../utils/ast.js";
 import { isLikelyLlmCall } from "./llm-rule-utils.js";
 import { evidenceConfidence, demoteEvidence, isTestFilePath } from "../confidence.js";
@@ -9,7 +9,6 @@ const VALIDATION_PATTERNS = [
   "zod",
   "yup",
   "joi",
-  ".parse(",
   ".validate(",
   ".safeParse(",
   "ajv",
@@ -20,27 +19,43 @@ const VALIDATION_PATTERNS = [
   "is(",
 ];
 
-function collectLlmResponseVars(fnNode: Node): Set {
-  const vars = new Set();
+// Bare ".parse(" is a genuine schema-validation signal (myValidator.parse(x)),
+// but as a plain substring it also matches JSON.parse( itself — the exact
+// call this rule flags always appears in the same function, in the same
+// text, as the "safe" pattern that's supposed to suppress it. That made this
+// rule unfireable on its own target pattern. A negative lookbehind excludes
+// the JSON.parse case specifically while still catching real .parse() calls.
+const GENERIC_PARSE_PATTERN = /(? {
+  const vars = new Map();
 
   for (const call of fnNode.getDescendantsOfKind(SyntaxKind.CallExpression)) {
     if (!isLikelyLlmCall(call)) continue;
     // Find the variable the call result is assigned to
     const parent = call.getParent();
+    let declNode: Node | undefined;
     if (Node.isVariableDeclaration(parent)) {
-      vars.add(parent.getName());
-    }
-    if (Node.isAwaitExpression(parent)) {
+      declNode = parent;
+    } else if (Node.isAwaitExpression(parent)) {
       const grandParent = parent.getParent();
-      if (Node.isVariableDeclaration(grandParent)) {
-        vars.add(grandParent.getName());
-      }
+      if (Node.isVariableDeclaration(grandParent)) declNode = grandParent;
+    }
+    if (declNode && Node.isVariableDeclaration(declNode)) {
+      const name = declNode.getName();
+      vars.set(name, { line: getNodeLine(declNode), note: `LLM response \`${name}\`` });
     }
   }
 
   // Propagate through property accesses: content = response.choices[0].message.content
   // Iterate to a fixed point so multi-hop chains (a = llmCall(); b = a.x; c = b.y)
-  // are all captured, without ever adding a var on name alone.
+  // are all captured, without ever adding a var on name alone. The origin
+  // carried forward stays the earliest LLM-call assignment in the chain.
   let changed = true;
   while (changed) {
     changed = false;
@@ -49,8 +64,11 @@ function collectLlmResponseVars(fnNode: Node): Set {
       const init = decl.getInitializer();
       if (!init) continue;
       const initText = init.getText();
-      if ([...vars].some((v) => initText.startsWith(v + ".") || initText.startsWith(v + "["))) {
-        vars.add(decl.getName());
+      const parentKey = [...vars.keys()].find(
+        (v) => initText.startsWith(v + ".") || initText.startsWith(v + "["),
+      );
+      if (parentKey) {
+        vars.set(decl.getName(), vars.get(parentKey)!);
         changed = true;
       }
     }
@@ -61,7 +79,10 @@ function collectLlmResponseVars(fnNode: Node): Set {
 
 function hasSchemaValidationNearby(fnNode: Node): boolean {
   const fnText = fnNode.getText().toLowerCase();
-  return VALIDATION_PATTERNS.some((p) => fnText.includes(p.toLowerCase()));
+  return (
+    VALIDATION_PATTERNS.some((p) => fnText.includes(p.toLowerCase())) ||
+    GENERIC_PARSE_PATTERN.test(fnText)
+  );
 }
 
 export const ruleUnvalidatedStructuredOutput: Rule = {
@@ -94,14 +115,22 @@ export const ruleUnvalidatedStructuredOutput: Rule = {
           if (exprText !== "json.parse") continue;
 
           const argText = call.getArguments()[0]?.getText() ?? "";
-          if (![...llmVars].some((v) => argText.includes(v))) continue;
+          const matched = [...llmVars.keys()].find((v) => argText.includes(v));
+          if (!matched) continue;
+          const origin = llmVars.get(matched)!;
+          const sinkLine = getNodeLine(call);
+
+          const trace: TraceStep[] = [
+            { kind: "source", file: relPath, line: origin.line, note: origin.note },
+            { kind: "sink", file: relPath, line: sinkLine, note: "JSON.parse(...) with no schema validation" },
+          ];
 
           findings.push({
             rule_id: "AI012",
             title: "LLM output used as structured data without schema validation",
             severity: "medium",
             file: relPath,
-            line: getNodeLine(call),
+            line: sinkLine,
             summary: "LLM response parsed with JSON.parse without a schema validator.",
             description:
               "LLM outputs are non-deterministic. Parsing them directly as structured data without a schema (Zod, Yup, Joi, etc.) allows unexpected shapes, missing fields, or injected keys to propagate silently into application logic.",
@@ -109,6 +138,7 @@ export const ruleUnvalidatedStructuredOutput: Rule = {
               "Validate parsed JSON against a strict schema (e.g. z.object({...}).parse(JSON.parse(raw))). Consider using structured output mode (response_format: { type: 'json_schema' }) to constrain model output at the API level.",
             confidence: evidenceConfidence(isTest ? demoteEvidence("likely") : "likely"),
             evidence: isTest ? demoteEvidence("likely") : "likely",
+            trace,
           });
         }
       }
diff --git a/src/scanner/rules/vec-user-ingestion.ts b/src/scanner/rules/vec-user-ingestion.ts
index 3152638..88866ab 100644
--- a/src/scanner/rules/vec-user-ingestion.ts
+++ b/src/scanner/rules/vec-user-ingestion.ts
@@ -1,5 +1,5 @@
 import { Node, SyntaxKind } from "ts-morph";
-import type { Finding, Rule, RuleContext } from "../types.js";
+import type { Finding, Rule, RuleContext, TraceStep } from "../types.js";
 import { getNodeLine, getRelativeFilePath } from "../../utils/ast.js";
 import { evidenceConfidence, demoteEvidence, isTestFilePath, hasSanitizationNearby } from "../confidence.js";
 
@@ -56,8 +56,13 @@ function isVectorIngestionCall(node: Node): boolean {
 // object (matches the REQUEST_SOURCES prefixes above, minus the dot).
 const REQUEST_PARAM_NAMES = new Set(["req", "request", "ctx"]);
 
-function collectTaintedVars(fnNode: Node): Set {
-  const tainted = new Set();
+interface TaintOrigin {
+  line: number;
+  note: string;
+}
+
+function collectTaintedVars(fnNode: Node): Map {
+  const tainted = new Map();
 
   if ("getParameters" in fnNode) {
     for (const param of (fnNode as any).getParameters()) {
@@ -70,7 +75,10 @@ function collectTaintedVars(fnNode: Node): Set {
       // link. Real request-derived taint is still caught below via
       // REQUEST_SOURCES member-access on the initializer.
       if (nameNode && Node.isIdentifier(nameNode) && REQUEST_PARAM_NAMES.has(nameNode.getText().toLowerCase())) {
-        tainted.add(nameNode.getText());
+        tainted.set(nameNode.getText(), {
+          line: getNodeLine(param),
+          note: `request data \`${nameNode.getText()}\``,
+        });
       }
     }
   }
@@ -80,23 +88,31 @@ function collectTaintedVars(fnNode: Node): Set {
     if (!init) continue;
     const initText = init.getText();
     if (REQUEST_SOURCES.some((src) => initText.includes(src))) {
-      tainted.add(decl.getName());
-    }
-    if (Node.isIdentifier(init) && tainted.has(init.getText())) {
-      tainted.add(decl.getName());
+      tainted.set(decl.getName(), { line: getNodeLine(decl), note: `request data \`${initText}\`` });
+    } else if (Node.isIdentifier(init) && tainted.has(init.getText())) {
+      tainted.set(decl.getName(), tainted.get(init.getText())!);
     }
   }
 
   return tainted;
 }
 
-function userInputFlowsToIngestion(call: Node, tainted: Set): boolean {
-  if (!Node.isCallExpression(call)) return false;
+/**
+ * Matches the tainted argument reaching an ingestion call. Returns the
+ * tainted variable name when it flows through a tracked declaration (usable
+ * for a trace), or the sentinel below when the request object is read
+ * inline in the call itself — same location as the sink, so no meaningful
+ * two-hop trace exists.
+ */
+const INLINE_TAINT = Symbol("inline-taint");
+
+function taintedArgMatch(call: Node, tainted: Map): string | typeof INLINE_TAINT | undefined {
+  if (!Node.isCallExpression(call)) return undefined;
   const argsText = call.getArguments().map((a) => a.getText()).join(" ");
-  return (
-    REQUEST_SOURCES.some((src) => argsText.includes(src)) ||
-    [...tainted].some((v) => argsText.includes(v))
-  );
+  const varMatch = [...tainted.keys()].find((v) => argsText.includes(v));
+  if (varMatch) return varMatch;
+  if (REQUEST_SOURCES.some((src) => argsText.includes(src))) return INLINE_TAINT;
+  return undefined;
 }
 
 export const ruleVecUserIngestion: Rule = {
@@ -123,15 +139,31 @@ export const ruleVecUserIngestion: Rule = {
 
         for (const call of fnNode.getDescendantsOfKind(SyntaxKind.CallExpression)) {
           if (!isVectorIngestionCall(call)) continue;
-          if (!userInputFlowsToIngestion(call, tainted)) continue;
+          const matched = taintedArgMatch(call, tainted);
+          if (!matched) continue;
           if (hasSanitization) continue;
 
+          const sinkLine = getNodeLine(call);
+          let trace: TraceStep[] | undefined;
+          if (matched !== INLINE_TAINT) {
+            const origin = tainted.get(matched)!;
+            trace = [
+              { kind: "source", file: relPath, line: origin.line, note: origin.note },
+              {
+                kind: "sink",
+                file: relPath,
+                line: sinkLine,
+                note: `ingested via \`${call.getExpression().getText()}\``,
+              },
+            ];
+          }
+
           findings.push({
             rule_id: "VEC003",
             title: "User-controlled content ingested into vector store",
             severity: "high",
             file: relPath,
-            line: getNodeLine(call),
+            line: sinkLine,
             summary: "User-supplied content is being stored in a vector database without sanitization.",
             description:
               "Allowing users to directly ingest content into a shared vector store is a training/retrieval data poisoning attack. A malicious user can plant documents containing prompt injection payloads. When those documents are later retrieved via similarity search, the injected instructions are silently executed by the LLM.",
@@ -139,6 +171,7 @@ export const ruleVecUserIngestion: Rule = {
               "Validate and sanitize all user-provided content before ingestion. Isolate user-submitted documents in a quarantine namespace pending review. Consider scanning ingested content for injection patterns before making it available to retrieval pipelines.",
             confidence: evidenceConfidence(isTest ? demoteEvidence("likely") : "likely"),
             evidence: isTest ? demoteEvidence("likely") : "likely",
+            ...(trace ? { trace } : {}),
           });
         }
       }
diff --git a/test-fixtures/safe/structured_output_validated.ts b/test-fixtures/safe/structured_output_validated.ts
new file mode 100644
index 0000000..78d3706
--- /dev/null
+++ b/test-fixtures/safe/structured_output_validated.ts
@@ -0,0 +1,23 @@
+// Safe: LLM JSON response is validated with a Zod schema before use. Added
+// after fixing AI012's hasSchemaValidationNearby — the previous plain
+// ".parse(" substring match collided with JSON.parse itself, so this
+// fixture locks in that a real schema-validator .parse() call (Zod's, here)
+// still correctly suppresses the finding once JSON.parse is excluded from
+// the "safe" match specifically.
+import OpenAI from "openai";
+import { z } from "zod";
+
+const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
+
+const OrderSchema = z.object({ id: z.string(), total: z.number() });
+
+export async function extractOrder() {
+  const completion = await openai.chat.completions.create({
+    model: "gpt-4.1",
+    messages: [{ role: "user", content: "Extract the order as JSON." }],
+  });
+
+  const raw = completion.choices[0].message.content ?? "";
+  const order = OrderSchema.parse(JSON.parse(raw));
+  return order;
+}
diff --git a/test-fixtures/vulnerable/multihop/mutual_recursion/a.ts b/test-fixtures/vulnerable/multihop/mutual_recursion/a.ts
new file mode 100644
index 0000000..2908830
--- /dev/null
+++ b/test-fixtures/vulnerable/multihop/mutual_recursion/a.ts
@@ -0,0 +1,5 @@
+import { callB } from "./b";
+
+export function callA(userInput: string) {
+  callB(userInput);
+}
diff --git a/test-fixtures/vulnerable/multihop/mutual_recursion/api.ts b/test-fixtures/vulnerable/multihop/mutual_recursion/api.ts
new file mode 100644
index 0000000..540adf7
--- /dev/null
+++ b/test-fixtures/vulnerable/multihop/mutual_recursion/api.ts
@@ -0,0 +1,5 @@
+import { callA } from "./a";
+
+export async function handler(req: { body: { input: string } }) {
+  callA(req.body.input);
+}
diff --git a/test-fixtures/vulnerable/multihop/mutual_recursion/b.ts b/test-fixtures/vulnerable/multihop/mutual_recursion/b.ts
new file mode 100644
index 0000000..3291509
--- /dev/null
+++ b/test-fixtures/vulnerable/multihop/mutual_recursion/b.ts
@@ -0,0 +1,16 @@
+import { callA } from "./a";
+import OpenAI from "openai";
+
+const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
+
+// Calls back into a.ts before reaching the LLM sink — proves the walker's
+// cycle guard produces exactly one finding rather than looping or
+// duplicating when the call graph isn't a simple chain.
+export function callB(userInput: string) {
+  callA(userInput);
+  const systemPrompt = `System instructions. Said: ${userInput}`;
+  openai.chat.completions.create({
+    model: "gpt-4.1",
+    messages: [{ role: "system", content: systemPrompt }],
+  });
+}
diff --git a/test-fixtures/vulnerable/multihop/three_file/api.ts b/test-fixtures/vulnerable/multihop/three_file/api.ts
new file mode 100644
index 0000000..d8b25e2
--- /dev/null
+++ b/test-fixtures/vulnerable/multihop/three_file/api.ts
@@ -0,0 +1,9 @@
+// Vulnerable: request data crosses TWO function-call boundaries
+// (api.ts -> lib/middle.ts -> lib/llmclient.ts) before reaching an LLM
+// system prompt. Proves the interprocedural walker's hop cap of 2 still
+// finds a real 3-file chain.
+import { middleware } from "./lib/middle";
+
+export async function handler(req: { body: { input: string } }) {
+  await middleware(req.body.input);
+}
diff --git a/test-fixtures/vulnerable/multihop/three_file/lib/llmclient.ts b/test-fixtures/vulnerable/multihop/three_file/lib/llmclient.ts
new file mode 100644
index 0000000..afcb4bb
--- /dev/null
+++ b/test-fixtures/vulnerable/multihop/three_file/lib/llmclient.ts
@@ -0,0 +1,11 @@
+import OpenAI from "openai";
+
+const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
+
+export async function callLlm(userInput: string) {
+  const systemPrompt = `System: do X. User said: ${userInput}`;
+  return openai.chat.completions.create({
+    model: "gpt-4.1",
+    messages: [{ role: "system", content: systemPrompt }],
+  });
+}
diff --git a/test-fixtures/vulnerable/multihop/three_file/lib/middle.ts b/test-fixtures/vulnerable/multihop/three_file/lib/middle.ts
new file mode 100644
index 0000000..2521f32
--- /dev/null
+++ b/test-fixtures/vulnerable/multihop/three_file/lib/middle.ts
@@ -0,0 +1,5 @@
+import { callLlm } from "./llmclient";
+
+export async function middleware(userInput: string) {
+  return callLlm(userInput);
+}
diff --git a/test-fixtures/vulnerable/multihop/two_file/api.ts b/test-fixtures/vulnerable/multihop/two_file/api.ts
new file mode 100644
index 0000000..9dc7edd
--- /dev/null
+++ b/test-fixtures/vulnerable/multihop/two_file/api.ts
@@ -0,0 +1,8 @@
+// Vulnerable: request data crosses one function-call boundary into
+// lib/llmclient.ts, where it reaches an LLM system prompt. Proves the
+// interprocedural (cross-file) taint trace added for AI001.
+import { askWithSystemPrompt } from "./lib/llmclient";
+
+export async function handler(req: { body: { input: string } }) {
+  await askWithSystemPrompt(req.body.input);
+}
diff --git a/test-fixtures/vulnerable/multihop/two_file/lib/llmclient.ts b/test-fixtures/vulnerable/multihop/two_file/lib/llmclient.ts
new file mode 100644
index 0000000..b3832ae
--- /dev/null
+++ b/test-fixtures/vulnerable/multihop/two_file/lib/llmclient.ts
@@ -0,0 +1,14 @@
+// Part of a 2-file interprocedural chain: request data flows from ../api.ts
+// into this function's parameter, which is interpolated into a system
+// prompt right here — the LLM call itself lives in the crossed-into file.
+import OpenAI from "openai";
+
+const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
+
+export async function askWithSystemPrompt(userInput: string) {
+  const systemPrompt = `System: do X. User said: ${userInput}`;
+  return openai.chat.completions.create({
+    model: "gpt-4.1",
+    messages: [{ role: "system", content: systemPrompt }],
+  });
+}
diff --git a/test-fixtures/vulnerable/unvalidated_structured_output.ts b/test-fixtures/vulnerable/unvalidated_structured_output.ts
new file mode 100644
index 0000000..6d4f5a9
--- /dev/null
+++ b/test-fixtures/vulnerable/unvalidated_structured_output.ts
@@ -0,0 +1,15 @@
+// Vulnerable: LLM response is parsed as JSON with no schema validation.
+import OpenAI from "openai";
+
+const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
+
+export async function extractOrder() {
+  const completion = await openai.chat.completions.create({
+    model: "gpt-4.1",
+    messages: [{ role: "user", content: "Extract the order as JSON." }],
+  });
+
+  const raw = completion.choices[0].message.content ?? "";
+  const order = JSON.parse(raw);
+  return order;
+}
diff --git a/test/corpus.test.js b/test/corpus.test.js
index 9b2c6fc..a56805e 100644
--- a/test/corpus.test.js
+++ b/test/corpus.test.js
@@ -61,9 +61,14 @@ const EXPECTED_VULNERABLE = [
   ["AI011", "vulnerable/multiagent_trust.ts"],
   ["MCP001", "vulnerable/mcp_tool_metadata.ts"],
   ["MCP003", "vulnerable/mcp_tool_result.ts"],
+  ["AI012", "vulnerable/unvalidated_structured_output.ts"],
   ["VEC002", "vulnerable/vec_unbounded_search.ts"],
   ["VEC003", "vulnerable/vec_user_ingestion.ts"],
   ["VEC004", "vulnerable/vec_ingest_no_namespace.ts"],
+  // Phase C — AI001 interprocedural (cross-function/cross-file) taint trace.
+  ["AI001", "vulnerable/multihop/two_file/lib/llmclient.ts"],
+  ["AI001", "vulnerable/multihop/three_file/lib/llmclient.ts"],
+  ["AI001", "vulnerable/multihop/mutual_recursion/b.ts"],
 ];
 
 for (const [ruleId, file] of EXPECTED_VULNERABLE) {
@@ -97,3 +102,30 @@ test("AI001 finding carries a source→sink trace", () => {
   assert.equal(ai001.trace[ai001.trace.length - 1].kind, "sink");
   assert.equal(ai001.evidence, "proven");
 });
+
+test("AI001 interprocedural (2-file) finding has a multi-hop, cross-file trace capped below proven", () => {
+  const finding = defaultTier.find(
+    (f) => f.rule_id === "AI001" && norm(f.file).includes("vulnerable/multihop/two_file/lib/llmclient.ts"),
+  );
+  assert.ok(finding, "interprocedural AI001 finding expected");
+  assert.ok(finding.trace.length > 2, "expected more than a single-function 2-3 step trace");
+  const files = new Set(finding.trace.map((s) => norm(s.file)));
+  assert.ok(files.size > 1, "expected the trace to span more than one file");
+  assert.notEqual(finding.evidence, "proven", "interprocedural findings must never be proven");
+});
+
+test("AI001 interprocedural (3-file, 2-hop) finding still fires within the hop cap", () => {
+  const finding = defaultTier.find(
+    (f) => f.rule_id === "AI001" && norm(f.file).includes("vulnerable/multihop/three_file/lib/llmclient.ts"),
+  );
+  assert.ok(finding, "3-file interprocedural AI001 finding expected");
+  const files = new Set(finding.trace.map((s) => norm(s.file)));
+  assert.ok(files.size >= 3, "expected the trace to span all three files in the chain");
+});
+
+test("AI001 interprocedural walk is cycle-safe: mutual recursion yields exactly one finding", () => {
+  const hits = defaultTier.filter(
+    (f) => f.rule_id === "AI001" && norm(f.file).includes("vulnerable/multihop/mutual_recursion/"),
+  );
+  assert.equal(hits.length, 1, `expected exactly one finding, got ${JSON.stringify(hits.map((f) => `${f.file}:${f.line}`))}`);
+});