diff --git a/fixtures/vulnerable-app/src/api/python/fastapi_routes.py b/fixtures/vulnerable-app/src/api/python/fastapi_routes.py new file mode 100644 index 0000000..f9ed771 --- /dev/null +++ b/fixtures/vulnerable-app/src/api/python/fastapi_routes.py @@ -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 [] diff --git a/fixtures/vulnerable-app/src/api/python/flask_routes.py b/fixtures/vulnerable-app/src/api/python/flask_routes.py new file mode 100644 index 0000000..862a71c --- /dev/null +++ b/fixtures/vulnerable-app/src/api/python/flask_routes.py @@ -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 diff --git a/packages/scanner/src/__tests__/matchers.test.ts b/packages/scanner/src/__tests__/matchers.test.ts index 696b52a..55dd643 100644 --- a/packages/scanner/src/__tests__/matchers.test.ts +++ b/packages/scanner/src/__tests__/matchers.test.ts @@ -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"; @@ -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); + }); +}); diff --git a/packages/scanner/src/matchers/fastapi-route.ts b/packages/scanner/src/matchers/fastapi-route.ts new file mode 100644 index 0000000..13e0b7d --- /dev/null +++ b/packages/scanner/src/matchers/fastapi-route.ts @@ -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; + }, +}; diff --git a/packages/scanner/src/matchers/flask-route.ts b/packages/scanner/src/matchers/flask-route.ts new file mode 100644 index 0000000..161e667 --- /dev/null +++ b/packages/scanner/src/matchers/flask-route.ts @@ -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; + }, +}; diff --git a/packages/scanner/src/matchers/index.ts b/packages/scanner/src/matchers/index.ts index e86ba78..c6ab4c6 100644 --- a/packages/scanner/src/matchers/index.ts +++ b/packages/scanner/src/matchers/index.ts @@ -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"; @@ -326,6 +334,10 @@ export function createDefaultRegistry(): MatcherRegistry { registry.register(goEmbedAssetMatcher); registry.register(githubWorkflowSecurityMatcher); + // Python web + registry.register(fastapiRouteMatcher); + registry.register(flaskRouteMatcher); + // Terraform / IaC registry.register(tfIamWildcardMatcher); registry.register(tfPublicIngressMatcher); diff --git a/packages/scanner/src/matchers/python-utils.ts b/packages/scanner/src/matchers/python-utils.ts new file mode 100644 index 0000000..78b3c1e --- /dev/null +++ b/packages/scanner/src/matchers/python-utils.ts @@ -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); +}