Skip to content
Closed
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
19 changes: 19 additions & 0 deletions fixtures/vulnerable-app/src/api/python/fastapi_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from fastapi import APIRouter, FastAPI

app = FastAPI()
router = APIRouter()


@app.get("/status")
async def status():
return {"ok": True}


@router.post("/users")
def create_user(payload: dict):
return payload


@router.api_route("/reports", methods=["GET", "POST"])
async def reports():
return []
14 changes: 14 additions & 0 deletions fixtures/vulnerable-app/src/api/python/flask_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from flask import Blueprint, Flask, request

app = Flask(__name__)
admin = Blueprint("admin", __name__)


@app.route("/healthz")
def healthz():
return "ok"


@admin.post("/users")
def create_user():
return request.json
57 changes: 57 additions & 0 deletions packages/scanner/src/__tests__/matchers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { authBypassMatcher } from "../matchers/auth-bypass.js";
import { fastapiRouteMatcher } from "../matchers/fastapi-route.js";
import { flaskRouteMatcher } from "../matchers/flask-route.js";
import { insecureCryptoMatcher } from "../matchers/insecure-crypto.js";
import { missingAuthMatcher } from "../matchers/missing-auth.js";
import { openRedirectMatcher } from "../matchers/open-redirect.js";
Expand Down Expand Up @@ -121,3 +123,58 @@ describe("open-redirect matcher", () => {
expect(matches.length).toBeGreaterThan(0);
});
});

describe("fastapi-route matcher", () => {
it("detects FastAPI decorator routes as Python web entry points", () => {
const content = readFixture("api/python/fastapi_routes.py");
const matches = fastapiRouteMatcher.match(content, "src/api/python/fastapi_routes.py");
expect(matches).toHaveLength(3);
expect(matches.every((m) => m.vulnSlug === "fastapi-route")).toBe(true);
expect(matches.map((m) => m.lineNumbers[0])).toEqual([7, 12, 17]);
expect(matches.map((m) => m.matchedPattern)).toEqual([
"FastAPI GET route decorator — Python HTTP entry point (weak candidate)",
"FastAPI POST route decorator — Python HTTP entry point (weak candidate)",
"FastAPI API route decorator — Python HTTP entry point (weak candidate)",
]);
});

it("skips Python test files", () => {
const content = readFixture("api/python/fastapi_routes.py");
const matches = fastapiRouteMatcher.match(content, "tests/test_fastapi_routes.py");
expect(matches).toHaveLength(0);
});

it("does not match uppercase decorator method names", () => {
const content =
"from fastapi import FastAPI\n\napp = FastAPI()\n\n@app.GET('/status')\ndef status():\n pass\n";
const matches = fastapiRouteMatcher.match(content, "src/api/python/uppercase_fastapi.py");
expect(matches).toHaveLength(0);
});
});

describe("flask-route matcher", () => {
it("detects Flask decorator routes as Python web entry points", () => {
const content = readFixture("api/python/flask_routes.py");
const matches = flaskRouteMatcher.match(content, "src/api/python/flask_routes.py");
expect(matches).toHaveLength(2);
expect(matches.every((m) => m.vulnSlug === "flask-route")).toBe(true);
expect(matches.map((m) => m.lineNumbers[0])).toEqual([7, 12]);
expect(matches.map((m) => m.matchedPattern)).toEqual([
"Flask ROUTE decorator — Python HTTP entry point (weak candidate)",
"Flask POST route decorator — Python HTTP entry point (weak candidate)",
]);
});

it("does not flag non-Flask Python route-like decorators", () => {
const content = "class Job:\n @worker.route('/nightly')\n def run(self):\n pass\n";
const matches = flaskRouteMatcher.match(content, "src/jobs/nightly.py");
expect(matches).toHaveLength(0);
});

it("does not match uppercase decorator method names", () => {
const content =
"from flask import Flask\n\napp = Flask(__name__)\n\n@app.GET('/status')\ndef status():\n pass\n";
const matches = flaskRouteMatcher.match(content, "src/api/python/uppercase_flask.py");
expect(matches).toHaveLength(0);
});
});
44 changes: 44 additions & 0 deletions packages/scanner/src/matchers/fastapi-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { CandidateMatch } from "@deepsec/core";
import type { MatcherPlugin } from "../types.js";
import { isPythonTestFile } from "./python-utils.js";

const FASTAPI_CONTEXT_RE =
/(?:^|\n)\s*(?:from\s+fastapi\s+import|import\s+fastapi)\b|\b(?:FastAPI|APIRouter)\s*\(/;

const FASTAPI_ROUTE_DECORATOR_RE =
/^\s*@[\w.]+\.(get|post|put|patch|delete|options|head|trace|websocket|api_route)\s*\(/;

export const fastapiRouteMatcher: MatcherPlugin = {
noiseTier: "noisy" as const,
slug: "fastapi-route",
description:
"FastAPI route decorators — Python web entry point coverage for AI review (weak candidate)",
filePatterns: ["**/*.py"],
match(content, filePath) {
if (isPythonTestFile(filePath)) return [];
if (!FASTAPI_CONTEXT_RE.test(content)) return [];

const matches: CandidateMatch[] = [];
const lines = content.split("\n");

for (let i = 0; i < lines.length; i++) {
const methodMatch = lines[i].match(FASTAPI_ROUTE_DECORATOR_RE);
if (!methodMatch) continue;

const method = methodMatch[1].toUpperCase();
const start = Math.max(0, i - 2);
const end = Math.min(lines.length, i + 3);
matches.push({
vulnSlug: "fastapi-route",
lineNumbers: [i + 1],
snippet: lines.slice(start, end).join("\n"),
matchedPattern:
method === "API_ROUTE"
? "FastAPI API route decorator — Python HTTP entry point (weak candidate)"
: `FastAPI ${method} route decorator — Python HTTP entry point (weak candidate)`,
});
}

return matches;
},
};
43 changes: 43 additions & 0 deletions packages/scanner/src/matchers/flask-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { CandidateMatch } from "@deepsec/core";
import type { MatcherPlugin } from "../types.js";
import { isPythonTestFile } from "./python-utils.js";

const FLASK_CONTEXT_RE =
/(?:^|\n)\s*(?:from\s+flask\s+import|import\s+flask)\b|\b(?:Flask|Blueprint)\s*\(/;

const FLASK_ROUTE_DECORATOR_RE = /^\s*@[\w.]+\.(route|get|post|put|patch|delete|options|head)\s*\(/;

export const flaskRouteMatcher: MatcherPlugin = {
noiseTier: "noisy" as const,
slug: "flask-route",
description:
"Flask route decorators — Python web entry point coverage for AI review (weak candidate)",
filePatterns: ["**/*.py"],
match(content, filePath) {
if (isPythonTestFile(filePath)) return [];
if (!FLASK_CONTEXT_RE.test(content)) return [];

const matches: CandidateMatch[] = [];
const lines = content.split("\n");

for (let i = 0; i < lines.length; i++) {
const methodMatch = lines[i].match(FLASK_ROUTE_DECORATOR_RE);
if (!methodMatch) continue;

const method = methodMatch[1].toUpperCase();
const start = Math.max(0, i - 2);
const end = Math.min(lines.length, i + 3);
matches.push({
vulnSlug: "flask-route",
lineNumbers: [i + 1],
snippet: lines.slice(start, end).join("\n"),
matchedPattern:
method === "ROUTE"
? "Flask ROUTE decorator — Python HTTP entry point (weak candidate)"
: `Flask ${method} route decorator — Python HTTP entry point (weak candidate)`,
});
}

return matches;
},
};
12 changes: 12 additions & 0 deletions packages/scanner/src/matchers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ import { errorMessageLeakMatcher } from "./error-message-leak.js";
import { eventHandlerMismatchMatcher } from "./event-handler-mismatch.js";
import { exPhoenixControllerMatcher } from "./ex-phoenix-controller.js";
import { expensiveApiAbuseMatcher } from "./expensive-api-abuse.js";
import { fastapiRouteMatcher } from "./fastapi-route.js";
import { flaskRouteMatcher } from "./flask-route.js";
import { frameworkEdgeSandboxMatcher } from "./framework-edge-sandbox.js";
import { frameworkImageOptimizerMatcher } from "./framework-image-optimizer.js";
import { frameworkInternalHeaderMatcher } from "./framework-internal-header.js";
import { frameworkServerActionMatcher } from "./framework-server-action.js";
// --- Framework (Next.js) matchers ---
import { frameworkUntrustedFetchMatcher } from "./framework-untrusted-fetch.js";
import { fsWriteSymlinkBoundaryMatcher } from "./fs-write-symlink-boundary.js";
import { gcpCloudFunctionMatcher } from "./gcp-cloud-function.js";
import { gitProviderUrlInjectionMatcher } from "./git-provider-url-injection.js";
Expand Down Expand Up @@ -326,6 +334,10 @@ export function createDefaultRegistry(): MatcherRegistry {
registry.register(goEmbedAssetMatcher);
registry.register(githubWorkflowSecurityMatcher);

// Python web
registry.register(fastapiRouteMatcher);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicate FastAPI and Flask matchers registered in the default registry produce redundant candidate matches on every Python file using those frameworks.

Fix on Vercel

registry.register(flaskRouteMatcher);

// Terraform / IaC
registry.register(tfIamWildcardMatcher);
registry.register(tfPublicIngressMatcher);
Expand Down
6 changes: 6 additions & 0 deletions packages/scanner/src/matchers/python-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const PYTHON_TEST_FILE_RE =
/(?:^|\/)(?:tests?|__tests__)\/|(?:^|\/)(?:test_.*|.*_(?:test|spec))\.py$/i;

export function isPythonTestFile(filePath: string): boolean {
return PYTHON_TEST_FILE_RE.test(filePath);
}