Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
23 changes: 23 additions & 0 deletions .changeset/fix-security-server-rule-false-positives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"oxlint-plugin-react-doctor": patch
---

Fix false positives in three rules (#838, #837, #839).

- `agent-tool-capability-risk` / `mcp-tool-capability-risk` (#838): capability
keywords are now matched in code only. Words inside a tool's `description`
string (e.g. "ALWAYS fetch the underlying numbers first") and the AI-SDK
`execute:` handler key no longer satisfy the dangerous-capability gate. A
handler that actually calls `exec`/`fetch`/`readFile`/etc. still fires.

- `url-prefilled-privileged-action` (#837): the validating-helper lookbehind now
allows a member-access object between the helper call and the read, so
`sanitizeAuthCallbackURL(url.searchParams.get("callbackURL"))` and
`resolveSafeAuthCallbackURL(url.searchParams.get(...))` are recognized as
validated and suppressed.

- `server-sequential-independent-await` (#839): declared-name collection now
recurses into nested destructuring patterns, so
`const [{ slug }, { isEnabled }] = await Promise.all(...)` followed by an
`await` that reads `slug`/`isEnabled` is correctly treated as dependent and
not flagged as a waterfall.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vite-plus/test";
import { runScanRule } from "../../../test-utils/run-scan-rule.js";
import { agentToolCapabilityRisk } from "./agent-tool-capability-risk.js";

describe("security-scan/agent-tool-capability-risk — regressions", () => {
it("stays silent when capability words appear only in the description and execute handler key", () => {
const content = [
'import { tool } from "ai";',
"export const listItems = tool({",
' description: "List items. ALWAYS fetch the underlying numbers first.",',
" inputSchema: z.object({ organizationId: z.string() }),",
" execute: async ({ organizationId }) => {",
' if (organizationId !== allowedOrgId) return { error: "Access denied" };',
" return prisma.item.findMany({ where: { organizationId } });",
" },",
"});",
].join("\n");
const findings = runScanRule(agentToolCapabilityRisk, {
relativePath: "src/lib/tools/campaign-stats.ts",
content,
});
expect(findings).toHaveLength(0);
});

it("flags a tool handler that actually shells out", () => {
const content = [
'import { tool } from "ai";',
"export const runShell = tool({",
' description: "Run a command",',
" inputSchema: z.object({ cmd: z.string() }),",
" execute: async ({ cmd }) => {",
" return exec(cmd);",
" },",
"});",
].join("\n");
const findings = runScanRule(agentToolCapabilityRisk, {
relativePath: "src/lib/tools/run-shell.ts",
content,
});
expect(findings).toHaveLength(1);
});

it("flags a tool handler that actually calls fetch in code", () => {
const content = [
'import { tool } from "ai";',
"export const getData = tool({",
' description: "Get data",',
" execute: async ({ url }) => fetch(url),",
"});",
].join("\n");
const findings = runScanRule(agentToolCapabilityRisk, {
relativePath: "src/lib/tools/get-data.ts",
content,
});
expect(findings).toHaveLength(1);
});

it("flags a tool that names a dangerous module in its import specifier", () => {
const content = [
'import { tool } from "ai";',
'import { execFile } from "node:child_process";',
"export const runCommandTool = tool({",
' description: "Run a repository maintenance command",',
" execute: async ({ command }) => {",
" execFile(command, []);",
" return { ok: true };",
" },",
"});",
].join("\n");
const findings = runScanRule(agentToolCapabilityRisk, {
relativePath: "src/agents/tools/run-command-tool.ts",
content,
});
expect(findings).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export const agentToolCapabilityRisk = defineRule({
isProductionSourcePath(file.relativePath) &&
AGENT_TOOL_CONTEXT_PATH_PATTERN.test(file.relativePath),
pattern: AGENT_TOOL_DEFINITION_PATTERN,
requireAll: [AGENT_TOOL_DANGEROUS_CAPABILITY_PATTERN],
// In code only: the AI-SDK `execute:` handler key and capability words in
// `description` prose ("...fetch the numbers...") must not satisfy the gate.
requireAllInCode: [AGENT_TOOL_DANGEROUS_CAPABILITY_PATTERN],
message:
"An agent-callable tool appears to expose network, filesystem, shell, or code-execution capability.",
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export const mcpToolCapabilityRisk = defineRule({
scan: scanByPattern({
shouldScan: (file) => isProductionSourcePath(file.relativePath),
pattern: MCP_TOOL_SURFACE_PATTERN,
requireAll: [MCP_IMPORT_PATTERN, AGENT_TOOL_DANGEROUS_CAPABILITY_PATTERN],
requireAll: [MCP_IMPORT_PATTERN],
// In code only: a capability word inside a tool `description` string is
// documentation, not an exposed primitive, so it must not satisfy the gate.
requireAllInCode: [AGENT_TOOL_DANGEROUS_CAPABILITY_PATTERN],
message:
"An MCP tool/resource/prompt handler appears to expose file, shell, network, or code-execution capability.",
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,28 @@ describe("security-scan/url-prefilled-privileged-action — regressions", () =>
});
expect(findings).toHaveLength(0);
});

it("stays silent when a member-access read is wrapped in an infix-named validator (resolveSafe…)", () => {
const findings = runScanRule(urlPrefilledPrivilegedAction, {
relativePath: "src/app/login/page.tsx",
content: `url.searchParams.set("callbackURL",\n resolveSafeAuthCallbackURL(url.searchParams.get("callbackURL")));\n`,
});
expect(findings).toHaveLength(0);
});

it("stays silent when an aliased sanitiz*-named validator wraps a member-access read", () => {
const findings = runScanRule(urlPrefilledPrivilegedAction, {
relativePath: "src/app/login/page.tsx",
content: `import { resolveSafeAuthCallbackURL as sanitizeAuthCallbackURL } from "~/lib/auth-callback";\nconst safe = sanitizeAuthCallbackURL(url.searchParams.get("callbackURL"));\n`,
});
expect(findings).toHaveLength(0);
});

it("still flags an unvalidated callbackUrl read passed through a non-validating call", () => {
const findings = runScanRule(urlPrefilledPrivilegedAction, {
relativePath: "src/app/login/page.tsx",
content: `const callback = decodeURIComponent(url.searchParams.get("callbackUrl"));\n`,
});
expect(findings).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import { scanByPattern } from "./utils/scan-by-pattern.js";
// No `email`/`user`: prefilled emails and username route params are benign
// booking/contact UX, not privileged actions. The lookbehind skips reads
// already wrapped in a validating helper (`getRelativeNextPath(...get("next"))`).
// The validator name is matched as an infix (lookbehind scans every suffix, so
// `resolveSafeAuthCallbackURL(...)` is recognized via its `Safe` segment), and
// a member-access object between the helper and the read is allowed so
// `sanitizeAuthCallbackURL(url.searchParams.get("callbackURL"))` is suppressed.
const PRIVILEGED_QUERY_PARAM_PATTERN =
/(?<!(?:safe|valid|sanitiz|relativ|allowlist|whitelist)[\w$]*\(\s*(?:new\s+)?)\b(?:searchParams|useSearchParams\s*\(\s*\)|URLSearchParams\s*\([^)]{0,120}\))(?:[?!])?\.get(?:All)?\s*\(\s*["'](?:userstoinvite|role|permission|sharingaction|invite|admin|next|continue|returnTo|redirect_uri|callbackUrl)["']|\bsearchParams\.(?:userstoinvite|role|permission|sharingaction|invite|admin|returnTo|redirect_uri|callbackUrl)\b/i;
/(?<!(?:safe|valid|sanitiz|relativ|allowlist|whitelist)[\w$]*\(\s*(?:new\s+)?(?:[\w$]+\s*\.\s*)*)\b(?:searchParams|useSearchParams\s*\(\s*\)|URLSearchParams\s*\([^)]{0,120}\))(?:[?!])?\.get(?:All)?\s*\(\s*["'](?:userstoinvite|role|permission|sharingaction|invite|admin|next|continue|returnTo|redirect_uri|callbackUrl)["']|\bsearchParams\.(?:userstoinvite|role|permission|sharingaction|invite|admin|returnTo|redirect_uri|callbackUrl)\b/i;

export const urlPrefilledPrivilegedAction = defineRule({
id: "url-prefilled-privileged-action",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SOURCE_FILE_PATTERN } from "../../../constants/security-scan.js";
import type { FileScan, ScannedFile } from "../../../utils/file-scan.js";
import { getMatchLocation } from "./get-match-location.js";
import { stripCommentsPreservingPositions } from "./strip-comments-preserving-positions.js";
import { stripStringLiteralsKeepingModuleSpecifiers } from "./strip-string-literals-keeping-module-specifiers.js";

export interface ScanByPatternInput {
readonly shouldScan: (file: ScannedFile) => boolean;
Expand All @@ -11,13 +12,19 @@ export interface ScanByPatternInput {
// Conjunction gates: every pattern must also match somewhere in the file
// (e.g. an MCP import that proves the matched tool surface is MCP).
readonly requireAll?: ReadonlyArray<RegExp>;
// Conjunction gates that must match in CODE only — comments and prose
// string-literal contents are blanked before testing (module-specifier
// strings are kept, since an import path is code), so a capability keyword
// that appears solely inside a `description` string doesn't count.
readonly requireAllInCode?: ReadonlyArray<RegExp>;
// Veto: a match anywhere in the file suppresses the finding (e.g. a
// signature-verification call that answers the rule's concern).
readonly suppressWhen?: RegExp;
readonly message: string;
}

const strippedContentCache = new WeakMap<ScannedFile, string>();
const codeOnlyContentCache = new WeakMap<ScannedFile, string>();

// Comments are a recurring false-positive source ("Ajv compiles schemas via
// `new Function(...)`"); blank them for JS/TS files before pattern matching.
Expand All @@ -31,14 +38,39 @@ export const getScannableContent = (file: ScannedFile): string => {
return strippedContent;
};

// Comments AND prose string-literal contents blanked (import paths kept) — the
// "is this keyword in real code?" view used by `requireAllInCode` gates so prose
// inside a `description` string can't satisfy a capability gate, while a
// dangerous import like `from "node:child_process"` still counts.
const getCodeOnlyContent = (file: ScannedFile): string => {
const cachedContent = codeOnlyContentCache.get(file);
if (cachedContent !== undefined) return cachedContent;
const codeOnlyContent = stripStringLiteralsKeepingModuleSpecifiers(getScannableContent(file));
codeOnlyContentCache.set(file, codeOnlyContent);
return codeOnlyContent;
};

export const scanByPattern =
({ shouldScan, pattern, requireAll, suppressWhen, message }: ScanByPatternInput): FileScan =>
({
shouldScan,
pattern,
requireAll,
requireAllInCode,
suppressWhen,
message,
}: ScanByPatternInput): FileScan =>
(file) => {
if (!shouldScan(file)) return [];
const content = getScannableContent(file);
if (requireAll !== undefined && !requireAll.every((gate) => gate.test(content))) {
return [];
}
if (
requireAllInCode !== undefined &&
!requireAllInCode.every((gate) => gate.test(getCodeOnlyContent(file)))
) {
return [];
}
const patterns = pattern instanceof RegExp ? [pattern] : pattern;
const matchedPattern = patterns.find((candidate) => candidate.test(content));
if (matchedPattern === undefined) return [];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vite-plus/test";
import { stripStringLiteralsKeepingModuleSpecifiers } from "./strip-string-literals-keeping-module-specifiers.js";

describe("security-scan/utils/strip-string-literals-keeping-module-specifiers", () => {
it("blanks string contents while preserving offsets, delimiters, and newlines", () => {
const source = `const description = "ALWAYS fetch the numbers first";\nconst run = exec(cmd);`;
const stripped = stripStringLiteralsKeepingModuleSpecifiers(source);
expect(stripped).toHaveLength(source.length);
expect(stripped).not.toContain("fetch");
expect(stripped.split("\n")[0]).toMatch(/^const description = " +";$/);
expect(stripped.split("\n")[1]).toBe("const run = exec(cmd);");
});

it("blanks template-literal text but keeps newlines for multi-line strings", () => {
const source = "const help = `line one\nfetch line two`;\nconst keep = true;";
const stripped = stripStringLiteralsKeepingModuleSpecifiers(source);
expect(stripped).not.toContain("fetch");
expect(stripped.split("\n")).toHaveLength(3);
expect(stripped.split("\n")[2]).toBe("const keep = true;");
});

it("does not let an escaped quote close the string early", () => {
const source = `const note = "say \\"exec\\" out loud"; const real = spawn(cmd);`;
const stripped = stripStringLiteralsKeepingModuleSpecifiers(source);
expect(stripped).not.toContain("exec");
expect(stripped).toContain("spawn(cmd)");
});

it("keeps module-specifier strings: import, export-from, and require paths", () => {
const source = [
`import { execFile } from "node:child_process";`,
`export { readFile } from "node:fs/promises";`,
`const vm = require("node:vm");`,
`const dynamic = import("node:child_process");`,
].join("\n");
const stripped = stripStringLiteralsKeepingModuleSpecifiers(source);
expect(stripped).toContain("node:child_process");
expect(stripped).toContain("node:fs/promises");
expect(stripped).toContain("node:vm");
expect(stripped).toBe(source);
});

it("blanks member-access strings that merely look like an import", () => {
const source = `const copy = Buffer.from("fetch the bytes");`;
const stripped = stripStringLiteralsKeepingModuleSpecifiers(source);
expect(stripped).not.toContain("fetch");
expect(stripped).toMatch(/^const copy = Buffer\.from\(" +"\);$/);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Builds the "is this keyword in real code?" view used by capability/keyword
// gates: string-literal *contents* are blanked with spaces so a keyword that
// appears only in prose — a tool's human-readable `description`, "ALWAYS fetch
// the numbers first" — can't satisfy a gate. Module-specifier strings
// (`from "node:child_process"`, `require("node:fs")`) are kept, because an
// import path is code, not prose, and naming a dangerous module is a real
// capability signal. Delimiters, newlines, and every offset are preserved so a
// blanked region still maps 1:1 onto the original file. Expects comment-stripped
// input so a quote inside a comment is never treated as a string delimiter.
const MODULE_SPECIFIER_KEYWORDS = new Set(["from", "import", "require"]);

const isModuleSpecifierQuote = (content: string, quoteIndex: number): boolean => {
let cursor = quoteIndex - 1;
while (cursor >= 0 && /\s/.test(content[cursor])) cursor -= 1;
if (content[cursor] === "(") {
cursor -= 1;
while (cursor >= 0 && /\s/.test(content[cursor])) cursor -= 1;
}
const wordEnd = cursor;
while (cursor >= 0 && /[\w$]/.test(content[cursor])) cursor -= 1;
const precedingWord = content.slice(cursor + 1, wordEnd + 1);
if (!MODULE_SPECIFIER_KEYWORDS.has(precedingWord)) return false;
// A member access (`Buffer.from("…")`, `db.import("…")`) is not an import.
return content[cursor] !== ".";
};

export const stripStringLiteralsKeepingModuleSpecifiers = (content: string): string => {
const characters = content.split("");
let stringDelimiter: string | null = null;
let isModuleSpecifier = false;
let index = 0;

while (index < content.length) {
const character = content[index];

if (stringDelimiter !== null) {
if (character === "\\") {
if (!isModuleSpecifier) {
characters[index] = " ";
if (content[index + 1] !== undefined && content[index + 1] !== "\n") {
characters[index + 1] = " ";
}
}
index += 2;
continue;
}
if (character === stringDelimiter) {
stringDelimiter = null;
index += 1;
continue;
}
if (!isModuleSpecifier && character !== "\n") characters[index] = " ";
index += 1;
continue;
Comment thread
cursor[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Template literal expressions (${...}) are incorrectly blanked as string content, causing false negatives

The stripStringLiteralsKeepingModuleSpecifiers function treats backtick-delimited template literals as simple strings, blanking all content between the opening and closing backtick. However, template literals can contain ${...} interpolation expressions that are executable code, not prose. For example, a tool handler like:

execute: async ({ cmd }) => `Result: ${exec(cmd)}`

would have exec(cmd) blanked because it's "inside" the template literal, even though it's a real function call. Since requireAllInCode gates (used by agent-tool-capability-risk and mcp-tool-capability-risk) test against this code-only view, dangerous capabilities inside template expressions will not be detected — the rules will produce false negatives for genuinely dangerous tools that happen to invoke exec/fetch/spawn etc. inside template expressions.

Prompt for agents
The stripStringLiteralsKeepingModuleSpecifiers function in packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/strip-string-literals-keeping-module-specifiers.ts needs to handle template literal expressions (${...}). Currently, when a backtick is encountered, it blanks everything until the closing backtick, including code inside ${...} interpolation expressions.

The fix requires tracking a nesting depth for template expressions. When inside a backtick-delimited string and a `${` sequence is encountered, the parser should stop treating the content as string text and instead treat what follows as code (i.e., don't blank it). It needs to track brace depth so it knows when the `}` that closes the expression is found (vs nested braces in objects/blocks). After the closing `}`, it resumes blanking template literal text.

This requires changes to the main while loop: when `stringDelimiter` is backtick and the current character is `$` followed by `{`, push into a code context (stop blanking). Track brace depth. When depth returns to 0 on a `}`, resume the template literal blanking mode. Note that template expressions can themselves contain nested template literals, so a stack-based approach may be needed.

The impact is limited because most tool handler code uses regular function calls outside of template expressions, but this is a correctness gap that can cause false negatives in the security scanning rules.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 90a3469. The stripper is now interpolation-aware: inside a backtick literal it blanks only the literal text and treats ${…} as code, balancing brace depth (so a } from a nested object/block doesn't end the expression early) and descending into nested strings/templates within the interpolation. So execute: async ({ cmd }) => `Result: ${exec(cmd)}` now keeps exec(cmd) in the code-only view and the capability rules fire. Covered by a new util test (interpolation kept, nested prose string blanked) and an agent-tool-capability-risk regression test for a handler that calls a capability inside ${…}.

}

if (character === '"' || character === "'" || character === "`") {
stringDelimiter = character;
isModuleSpecifier = isModuleSpecifierQuote(content, index);
index += 1;
continue;
}

index += 1;
}

return characters.join("");
};
Loading
Loading