Skip to content
Open
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
91 changes: 91 additions & 0 deletions packages/scanner/src/__tests__/matchers.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
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";
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");
Expand Down Expand Up @@ -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);
});
});
5 changes: 3 additions & 2 deletions packages/scanner/src/matchers/all-server-actions.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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];
Expand All @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions packages/scanner/src/matchers/server-action-no-auth.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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*\(/,
Expand All @@ -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;

Expand Down
5 changes: 3 additions & 2 deletions packages/scanner/src/matchers/server-action.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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];
Expand All @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions packages/scanner/src/matchers/utils.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading