diff --git a/packages/scanner/src/__tests__/matchers.test.ts b/packages/scanner/src/__tests__/matchers.test.ts index 696b52a..1d4dfd8 100644 --- a/packages/scanner/src/__tests__/matchers.test.ts +++ b/packages/scanner/src/__tests__/matchers.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { allServerActionsMatcher } from "../matchers/all-server-actions.js"; import { authBypassMatcher } from "../matchers/auth-bypass.js"; import { insecureCryptoMatcher } from "../matchers/insecure-crypto.js"; import { missingAuthMatcher } from "../matchers/missing-auth.js"; @@ -8,8 +9,11 @@ import { openRedirectMatcher } from "../matchers/open-redirect.js"; import { pathTraversalMatcher } from "../matchers/path-traversal.js"; import { rceMatcher } from "../matchers/rce.js"; import { secretsExposureMatcher } from "../matchers/secrets-exposure.js"; +import { serverActionMatcher } from "../matchers/server-action.js"; +import { serverActionNoAuthMatcher } from "../matchers/server-action-no-auth.js"; import { sqlInjectionMatcher } from "../matchers/sql-injection.js"; import { ssrfMatcher } from "../matchers/ssrf.js"; +import { hasFileDirective } from "../matchers/utils.js"; import { xssMatcher } from "../matchers/xss.js"; const FIXTURES_ROOT = path.resolve(import.meta.dirname, "../../../../fixtures/vulnerable-app/src"); @@ -121,3 +125,90 @@ describe("open-redirect matcher", () => { expect(matches.length).toBeGreaterThan(0); }); }); + +describe("hasFileDirective", () => { + it("matches a bare directive at the start of the file", () => { + expect(hasFileDirective(`"use server";\nexport async function f() {}`, "use server")).toBe( + true, + ); + }); + + it("matches after a block-comment banner", () => { + expect(hasFileDirective(`/* Copyright 2024 */\n"use server";`, "use server")).toBe(true); + }); + + it("matches after a JSDoc banner", () => { + expect(hasFileDirective(`/**\n * @license MIT\n */\n"use server";`, "use server")).toBe(true); + }); + + it("matches after a line-comment header", () => { + expect(hasFileDirective(`// internal\n"use server";`, "use server")).toBe(true); + }); + + it("matches after a shebang", () => { + expect(hasFileDirective(`#!/usr/bin/env node\n"use server";`, "use server")).toBe(true); + }); + + it("matches single-quoted directives", () => { + expect(hasFileDirective(`/* x */\n'use server';`, "use server")).toBe(true); + }); + + it("does not match when the directive only appears inside a comment", () => { + expect( + hasFileDirective(`// "use server" is set elsewhere\nexport function f() {}`, "use server"), + ).toBe(false); + }); + + it("does not match when there is no directive at all", () => { + expect(hasFileDirective(`export function f() {}`, "use server")).toBe(false); + }); + + it("respects the requested directive name", () => { + expect(hasFileDirective(`/* x */\n"use client";`, "use client")).toBe(true); + expect(hasFileDirective(`/* x */\n"use client";`, "use server")).toBe(false); + }); +}); + +describe("server-action matcher", () => { + it("detects exports after a leading copyright banner", () => { + const content = `/* Copyright 2024 Acme Corp. Apache 2.0 license. */ +"use server"; + +export async function deleteAccount(userId: string) { + return userId; +}`; + const matches = serverActionMatcher.match(content, "src/app/actions.ts"); + expect(matches.length).toBe(1); + expect(matches[0].vulnSlug).toBe("server-action"); + expect(matches[0].matchedPattern).toContain("file-level"); + }); +}); + +describe("server-action-no-auth matcher", () => { + it("flags banner-prefixed files with no auth call", () => { + const content = `// internal helpers, see SECURITY.md +"use server"; + +export async function transferFunds(amount: number) { + return amount; +}`; + const matches = serverActionNoAuthMatcher.match(content, "src/app/actions.ts"); + expect(matches.length).toBe(1); + expect(matches[0].matchedPattern).toContain("WITHOUT any auth call"); + }); +}); + +describe("all-server-actions matcher", () => { + it("flags every export in a file with a banner-prefixed file-level directive", () => { + const content = `/** + * @license MIT + */ +"use server"; + +export async function a() {} +export async function b() {} +export const c = async () => {};`; + const matches = allServerActionsMatcher.match(content, "src/app/actions.ts"); + expect(matches.length).toBe(3); + }); +}); diff --git a/packages/scanner/src/matchers/all-server-actions.ts b/packages/scanner/src/matchers/all-server-actions.ts index c443c8e..3909149 100644 --- a/packages/scanner/src/matchers/all-server-actions.ts +++ b/packages/scanner/src/matchers/all-server-actions.ts @@ -1,5 +1,6 @@ import type { CandidateMatch } from "@deepsec/core"; import type { MatcherPlugin } from "../types.js"; +import { hasFileDirective } from "./utils.js"; /** * Comprehensive "use server" coverage. Any file with "use server" directive @@ -21,7 +22,7 @@ export const allServerActionsMatcher: MatcherPlugin = { const matches: CandidateMatch[] = []; const lines = content.split("\n"); - const hasFileDirective = /^['"]use server['"]/.test(content.trim()); + const hasFileLevelDirective = hasFileDirective(content, "use server"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; @@ -39,7 +40,7 @@ export const allServerActionsMatcher: MatcherPlugin = { !hasInlineDirective && lines.slice(i + 1, Math.min(lines.length, i + 6)).some((l) => /['"]use server['"]/.test(l)); - if (!hasFileDirective && !hasInlineDirective && !hasBodyDirective) continue; + if (!hasFileLevelDirective && !hasInlineDirective && !hasBodyDirective) continue; const start = Math.max(0, i - 1); const end = Math.min(lines.length, i + 4); diff --git a/packages/scanner/src/matchers/server-action-no-auth.ts b/packages/scanner/src/matchers/server-action-no-auth.ts index 02ce871..ca820b6 100644 --- a/packages/scanner/src/matchers/server-action-no-auth.ts +++ b/packages/scanner/src/matchers/server-action-no-auth.ts @@ -1,5 +1,6 @@ import type { CandidateMatch } from "@deepsec/core"; import type { MatcherPlugin } from "../types.js"; +import { hasFileDirective } from "./utils.js"; export const serverActionNoAuthMatcher: MatcherPlugin = { noiseTier: "precise" as const, @@ -15,7 +16,7 @@ export const serverActionNoAuthMatcher: MatcherPlugin = { const lines = content.split("\n"); // File-level "use server" - const hasFileDirective = /^['"]use server['"]/.test(content.trim()); + const hasFileLevelDirective = hasFileDirective(content, "use server"); const authCalls = [ /getSession\s*\(/, @@ -40,7 +41,7 @@ export const serverActionNoAuthMatcher: MatcherPlugin = { /['"]use server['"]/.test(lines[i + 1]) && /export\s+(async\s+)?function/.test(line); - const isFileExport = hasFileDirective && /export\s+(async\s+)?function\s+\w+/.test(line); + const isFileExport = hasFileLevelDirective && /export\s+(async\s+)?function\s+\w+/.test(line); if (!isInlineServerAction && !isFileExport) continue; diff --git a/packages/scanner/src/matchers/server-action.ts b/packages/scanner/src/matchers/server-action.ts index 1febda1..aca6235 100644 --- a/packages/scanner/src/matchers/server-action.ts +++ b/packages/scanner/src/matchers/server-action.ts @@ -1,5 +1,6 @@ import type { CandidateMatch } from "@deepsec/core"; import type { MatcherPlugin } from "../types.js"; +import { hasFileDirective } from "./utils.js"; /** * 100% server action coverage. Every exported function from a 'use server' @@ -26,7 +27,7 @@ export const serverActionMatcher: MatcherPlugin = { const lines = content.split("\n"); // File-level directive - const hasFileDirective = /^['"]use server['"]/.test(content.trim()); + const hasFileLevelDirective = hasFileDirective(content, "use server"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; @@ -37,7 +38,7 @@ export const serverActionMatcher: MatcherPlugin = { // Check for inline "use server" on the next line const hasInlineDirective = i + 1 < lines.length && /['"]use server['"]/.test(lines[i + 1]); - if (!hasFileDirective && !hasInlineDirective) continue; + if (!hasFileLevelDirective && !hasInlineDirective) continue; const start = Math.max(0, i - 1); const end = Math.min(lines.length, i + 4); diff --git a/packages/scanner/src/matchers/utils.ts b/packages/scanner/src/matchers/utils.ts index 1897586..66e94f3 100644 --- a/packages/scanner/src/matchers/utils.ts +++ b/packages/scanner/src/matchers/utils.ts @@ -1,5 +1,21 @@ import type { CandidateMatch } from "@deepsec/core"; +const PROLOGUE_RE = /^(?:\s|\/\/[^\n]*\n?|\/\*[\s\S]*?\*\/)*/; + +/** + * True if `content` opens with the given module-level directive, allowing a + * leading shebang and any line/block comments above it. + */ +export function hasFileDirective(content: string, directive: string): boolean { + let body = content; + if (body.startsWith("#!")) { + const eol = body.indexOf("\n"); + body = eol === -1 ? "" : body.slice(eol + 1); + } + body = body.replace(PROLOGUE_RE, ""); + return body.startsWith(`"${directive}"`) || body.startsWith(`'${directive}'`); +} + /** * Helper to build a regex-based matcher. * For each pattern, scans every line and collects matches with surrounding context.