Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
98 changes: 82 additions & 16 deletions src/scanner/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <marker> 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 += `<line x1="${x1}" y1="${ay}" x2="${x2 - 8}" y2="${ay}" class="trace-arrow-line"${crossFile ? ' stroke-dasharray="5,4"' : ""} marker-end="url(#${markerId})" />`;
if (crossFile) {
arrows += `<text x="${(x1 + x2) / 2}" y="${ay - 8}" text-anchor="middle" class="trace-crossfile">cross-file</text>`;
}
}

nodes += `
<text x="${x + NODE_W / 2}" y="${PAD + 12}" text-anchor="middle" class="trace-kind-label" fill="${color}">${step.kind.toUpperCase()}</text>
<rect x="${x}" y="${y}" width="${NODE_W}" height="${NODE_H}" rx="8" class="trace-node" stroke="${color}" />
<text x="${x + NODE_W / 2}" y="${y + 21}" text-anchor="middle" class="trace-loc">${escapeHtml(loc)}</text>
<text x="${x + NODE_W / 2}" y="${y + 40}" text-anchor="middle" class="trace-note">${escapeHtml(note)}</text>`;
});

return `<div class="trace-diagram"><svg viewBox="0 0 ${totalW} ${totalH}" width="${totalW}" height="${totalH}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dataflow trace: ${escapeHtml(trace.map((s) => `${s.kind} at ${s.file}:${s.line}`).join(" -> "))}">
<defs><marker id="${markerId}" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="trace-arrow-head" /></marker></defs>
${arrows}
${nodes}
</svg></div>`;
}

function formatHtml(report: ReportModel): string {
const { summary } = report;

Expand All @@ -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
? `<div class="trace">${occ.trace
.map(
(s) =>
`<div class="trace-step"><span class="trace-kind ${s.kind}">${s.kind}</span><code>${escapeHtml(
`${s.file}:${s.line}`,
)}</code><span>${escapeHtml(s.note)}</span></div>`,
)
.join("")}</div>`
: "";
.map((occ, occIdx) => {
const trace =
occ.trace && occ.trace.length > 0
? renderTraceSvg(occ.trace, `${group.ruleId}-${occIdx}`)
: "";
const snippet = occ.snippet
? `<pre class="snippet">${occ.snippet
.map(
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions src/scanner/rules/llm-rule-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SourceFile>,
): 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;
}
75 changes: 61 additions & 14 deletions src/scanner/rules/mcp-unvalidated-tool-result.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,15 +25,19 @@ function isToolResultVar(name: string): boolean {
return TOOL_RESULT_PATTERNS.some((p) => name.toLowerCase().includes(p));
}

function collectToolResultVars(fnNode: Node): Set<string> {
const vars = new Set<string>();
interface ToolResultOrigin {
line: number;
note: string;
}

function collectToolResultVars(fnNode: Node): Map<string, ToolResultOrigin> {
const vars = new Map<string, ToolResultOrigin>();
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}\`` });
}
}

Expand All @@ -42,15 +46,27 @@ function collectToolResultVars(fnNode: Node): Set<string> {
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<string>): boolean {
if (!Node.isCallExpression(call)) return false;
interface ElevationMatch {
varName: string;
role: string;
contentLine: number;
}

function findElevatedToolResultUsage(
call: Node,
toolVars: Map<string, ToolResultOrigin>,
): ElevationMatch | undefined {
if (!Node.isCallExpression(call)) return undefined;
for (const arg of call.getArguments()) {
if (!Node.isObjectLiteralExpression(arg)) continue;
const messagesNode = arg
Expand All @@ -76,11 +92,20 @@ function toolResultElevatedToHighTrustRole(call: Node, toolVars: Set<string>): 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 = {
Expand Down Expand Up @@ -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",
Expand All @@ -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,
});
}
}
Expand Down
Loading