From a9406855dcaa52449a5406c74eb6298498320d08 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 23:46:01 -0700 Subject: [PATCH 01/25] feat(core): add security posture scanner --- packages/core/package.json | 1 + packages/core/src/check-security-posture.ts | 1358 +++++++++++++++++ packages/core/src/constants.ts | 8 + packages/core/src/index.ts | 1 + packages/core/src/run-inspect.ts | 3 +- .../core/tests/check-security-posture.test.ts | 556 +++++++ .../dist/assets/integrations.js | 26 + .../broad-provider-token-bundle/package.json | 12 + .../app/api/session/route.ts | 9 + .../docs-cookie-cors-trust/package.json | 7 + .../.next/static/chunks/portfolio.js | 19 + .../eva-a16z-env-bundle/package.json | 11 + .../build/static/js/main.js | 21 + .../eva-arc-chattr-firebase/firestore.rules | 7 + .../eva-arc-chattr-firebase/package.json | 7 + .../eva-arc-chattr-firebase/src/boosts.ts | 15 + .../src/chattr-admin.ts | 29 + .../build/static/js/main.js | 15 + .../eva-gamersafer-public-env/package.json | 11 + .../src/admin-client.tsx | 17 + .../eva-minified-widget-bundle/package.json | 6 + .../packages/widget/widget.global.js | 14 + .../static/[subdomain]/[...path]/route.ts | 19 + .../app/docs/[slug]/page.tsx | 12 + .../eva-mintlify-docs-platform/next.config.js | 5 + .../eva-mintlify-docs-platform/package.json | 8 + .../public/uploads/xss.svg | 1 + .../.github/workflows/release.yml | 15 + .../package.json | 12 + .../app/signin/route.ts | 6 + .../package.json | 7 + .../src/slide-embed.tsx | 10 + .../lyra-svg-filter-clickjacking/package.json | 6 + .../src/svg-filter-frame.tsx | 16 + .../mrbruh-asus-local-rpc/package.json | 6 + .../src/driverhub-bridge.ts | 6 + .../mrbruh-fooocus-metadata-eval/package.json | 6 + .../src/image-metadata-import.ts | 5 + .../package.json | 8 + .../src/agents/tools/format-tool.ts | 8 + .../src/database.ts | 3 + .../ported-agent-mcp-tool-risks/package.json | 9 + .../src/agents/tools/run-command-tool.ts | 10 + .../src/mcp/server.ts | 10 + .../package.json | 9 + .../public/plugin.php | 10 + .../scripts/report.py | 5 + .../src/nosql.ts | 7 + .../src/raw-sql.ts | 12 + .../app/api/preview/route.ts | 6 + .../package.json | 8 + .../src/iframe-message-panel.tsx | 5 + .../src/render-remote-html.tsx | 3 + .../app/api/preview/route.ts | 6 + .../package.json | 8 + .../src/iframe-message-panel.tsx | 6 + .../src/static-html.tsx | 3 + .../app/api/files/route.ts | 6 + .../app/api/stripe/webhook/route.ts | 4 + .../ported-web-security-risks/package.json | 8 + .../src/github-import.ts | 3 + .../src/session-token.ts | 6 + .../app/api/files/route.ts | 12 + .../app/api/stripe/webhook/route.ts | 14 + .../package.json | 8 + .../src/github-import.ts | 11 + .../dist/assets/app.js.map | 7 + .../public-debug-sourcemap-leak/package.json | 6 + .../public/debug.log | 2 + .../real-public-env-keys/package.json | 7 + .../real-public-env-keys/src/public-config.ts | 8 + .../package.json | 9 + .../pages/api/vector-search.ts | 26 + .../dist/assets/chat.js | 12 + .../package.json | 12 + .../package.json | 7 + .../supabase/migrations/001_private_items.sql | 18 + .../.github/workflows/release.yml | 11 + .../release-key-material-leak/keys/deploy.pem | 3 + .../release-key-material-leak/package.json | 6 + .../repository-secret-examples/.env.example | 3 + .../repository-secret-examples/package.json | 6 + .../repository-secret-files/.env.production | 6 + .../repository-secret-files/.npmrc | 1 + .../firebase-admin-credentials.json | 10 + .../repository-secret-files/package.json | 6 + .../.github/workflows/test.yml | 14 + .../.next/static/chunks/app.js | 5 + .../safe-hardened-app/firestore.rules | 7 + .../safe-hardened-app/package.json | 10 + .../supabase/migrations/001_profiles.sql | 6 + .../package.json | 7 + .../src/documents.ts | 8 + .../migrations/001_open_documents.sql | 7 + .../dist/assets/app.js | 4 + .../package.json | 7 + .../src/supabase-client.tsx | 6 + pnpm-lock.yaml | 80 + 98 files changed, 2827 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/check-security-posture.ts create mode 100644 packages/core/tests/check-security-posture.test.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/broad-provider-token-bundle/dist/assets/integrations.js create mode 100644 packages/core/tests/fixtures/check-security-posture/broad-provider-token-bundle/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/docs-cookie-cors-trust/app/api/session/route.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/docs-cookie-cors-trust/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-a16z-env-bundle/.next/static/chunks/portfolio.js create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-a16z-env-bundle/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/build/static/js/main.js create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/firestore.rules create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/src/boosts.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/src/chattr-admin.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/build/static/js/main.js create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/src/admin-client.tsx create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-minified-widget-bundle/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-minified-widget-bundle/packages/widget/widget.global.js create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/app/_mintlify/static/[subdomain]/[...path]/route.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/app/docs/[slug]/page.tsx create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/next.config.js create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/public/uploads/xss.svg create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-todesktop-release-pipeline/.github/workflows/release.yml create mode 100644 packages/core/tests/fixtures/check-security-posture/eva-todesktop-release-pipeline/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/app/signin/route.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/src/slide-embed.tsx create mode 100644 packages/core/tests/fixtures/check-security-posture/lyra-svg-filter-clickjacking/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/lyra-svg-filter-clickjacking/src/svg-filter-frame.tsx create mode 100644 packages/core/tests/fixtures/check-security-posture/mrbruh-asus-local-rpc/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/mrbruh-asus-local-rpc/src/driverhub-bridge.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/mrbruh-fooocus-metadata-eval/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/mrbruh-fooocus-metadata-eval/src/image-metadata-import.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-agent-database-safe-patterns/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-agent-database-safe-patterns/src/agents/tools/format-tool.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-agent-database-safe-patterns/src/database.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-agent-mcp-tool-risks/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-agent-mcp-tool-risks/src/agents/tools/run-command-tool.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-agent-mcp-tool-risks/src/mcp/server.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-database-and-command-risks/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-database-and-command-risks/public/plugin.php create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-database-and-command-risks/scripts/report.py create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-database-and-command-risks/src/nosql.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-database-and-command-risks/src/raw-sql.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-static-matcher-patterns/app/api/preview/route.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-static-matcher-patterns/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-static-matcher-patterns/src/iframe-message-panel.tsx create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-static-matcher-patterns/src/render-remote-html.tsx create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-static-matcher-safe-patterns/app/api/preview/route.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-static-matcher-safe-patterns/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-static-matcher-safe-patterns/src/iframe-message-panel.tsx create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-static-matcher-safe-patterns/src/static-html.tsx create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-web-security-risks/app/api/files/route.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-web-security-risks/app/api/stripe/webhook/route.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-web-security-risks/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-web-security-risks/src/github-import.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-web-security-risks/src/session-token.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-web-security-safe-patterns/app/api/files/route.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-web-security-safe-patterns/app/api/stripe/webhook/route.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-web-security-safe-patterns/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/ported-web-security-safe-patterns/src/github-import.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/public-debug-sourcemap-leak/dist/assets/app.js.map create mode 100644 packages/core/tests/fixtures/check-security-posture/public-debug-sourcemap-leak/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/public-debug-sourcemap-leak/public/debug.log create mode 100644 packages/core/tests/fixtures/check-security-posture/real-public-env-keys/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/real-public-env-keys/src/public-config.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/real-server-service-role-route/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/real-server-service-role-route/pages/api/vector-search.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/real-supabase-chat-browser-bundle/dist/assets/chat.js create mode 100644 packages/core/tests/fixtures/check-security-posture/real-supabase-chat-browser-bundle/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/real-supabase-public-read-private-write/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/real-supabase-public-read-private-write/supabase/migrations/001_private_items.sql create mode 100644 packages/core/tests/fixtures/check-security-posture/release-key-material-leak/.github/workflows/release.yml create mode 100644 packages/core/tests/fixtures/check-security-posture/release-key-material-leak/keys/deploy.pem create mode 100644 packages/core/tests/fixtures/check-security-posture/release-key-material-leak/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/repository-secret-examples/.env.example create mode 100644 packages/core/tests/fixtures/check-security-posture/repository-secret-examples/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/repository-secret-files/.env.production create mode 100644 packages/core/tests/fixtures/check-security-posture/repository-secret-files/.npmrc create mode 100644 packages/core/tests/fixtures/check-security-posture/repository-secret-files/firebase-admin-credentials.json create mode 100644 packages/core/tests/fixtures/check-security-posture/repository-secret-files/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/safe-hardened-app/.github/workflows/test.yml create mode 100644 packages/core/tests/fixtures/check-security-posture/safe-hardened-app/.next/static/chunks/app.js create mode 100644 packages/core/tests/fixtures/check-security-posture/safe-hardened-app/firestore.rules create mode 100644 packages/core/tests/fixtures/check-security-posture/safe-hardened-app/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/safe-hardened-app/supabase/migrations/001_profiles.sql create mode 100644 packages/core/tests/fixtures/check-security-posture/supabase-rls-client-owned-authz/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/supabase-rls-client-owned-authz/src/documents.ts create mode 100644 packages/core/tests/fixtures/check-security-posture/supabase-rls-client-owned-authz/supabase/migrations/001_open_documents.sql create mode 100644 packages/core/tests/fixtures/check-security-posture/supabase-service-role-public-client/dist/assets/app.js create mode 100644 packages/core/tests/fixtures/check-security-posture/supabase-service-role-public-client/package.json create mode 100644 packages/core/tests/fixtures/check-security-posture/supabase-service-role-public-client/src/supabase-client.tsx diff --git a/packages/core/package.json b/packages/core/package.json index 7f08463e7..95e797cf8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,6 +28,7 @@ "effect": "4.0.0-beta.70", "eslint-plugin-react-hooks": "^7.1.1", "jiti": "^2.7.0", + "oxc-parser": "^0.132.0", "oxlint": "^1.66.0", "oxlint-plugin-react-doctor": "workspace:*", "picomatch": "^4.0.4", diff --git a/packages/core/src/check-security-posture.ts b/packages/core/src/check-security-posture.ts new file mode 100644 index 000000000..1fa41d0ff --- /dev/null +++ b/packages/core/src/check-security-posture.ts @@ -0,0 +1,1358 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { parseSync, visitorKeys } from "oxc-parser"; +import { + GENERATED_BUNDLE_FILE_PATTERN, + SECURITY_SCAN_MAX_BUNDLE_FILE_SIZE_BYTES, + SECURITY_SCAN_MAX_DIRECTORY_DEPTH, + SECURITY_SCAN_MAX_FILES, + SECURITY_SCAN_MAX_FILE_SIZE_BYTES, +} from "./constants.js"; +import { readDirectoryEntries } from "./project-info/index.js"; +import type { Diagnostic } from "./types/index.js"; +import { isLargeMinifiedFile } from "./utils/is-large-minified-file.js"; + +interface ScannedFile { + readonly absolutePath: string; + readonly relativePath: string; + readonly content: string; + readonly isGeneratedBundle: boolean; +} + +interface SecurityDiagnosticInput { + readonly filePath: string; + readonly rule: string; + readonly title: string; + readonly severity: Diagnostic["severity"]; + readonly message: string; + readonly help: string; + readonly content: string; + readonly pattern?: RegExp; + readonly line?: number; + readonly column?: number; +} + +interface SecurityScanner { + readonly rule: string; + readonly title: string; + readonly severity: Diagnostic["severity"]; + readonly shouldScan: (file: ScannedFile) => boolean; + readonly pattern: RegExp; + readonly message: string; + readonly help: string; +} + +interface DirectoryStackEntry { + readonly absolutePath: string; + readonly depth: number; +} + +interface AstNode { + readonly type?: string; + readonly start?: number; + readonly end?: number; + readonly [propertyName: string]: unknown; +} + +type ScanBucket = "priority" | "artifact" | "other"; + +const SKIPPED_DIRECTORY_NAMES = new Set([ + ".git", + ".turbo", + ".vercel", + "coverage", + "node_modules", + "tmp", +]); + +const TEXT_FILE_PATTERN = + /\.(?:[cm]?[jt]sx?|json|jsonc|map|html?|mdx?|ya?ml|toml|sql|rules|env|txt|log|svg|xml|pem|key|crt|cert|pub|py|php)$/i; + +const DOTENV_FILE_PATTERN = /(?:^|\/)\.env(?:\.|$)/; + +const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?|mdx?)$/i; + +const SERVER_CONTEXT_PATTERN = + /(?:^|\/)(?:api|backend|server|servers|middleware|route|routes|functions|lambdas|workers)(?:\/|$)|(?:^|\/)[^/]+\.server\.[cm]?[jt]sx?$/i; + +const TEST_CONTEXT_PATTERN = + /(?:^|\/)(?:__fixtures__|__mocks__|__tests__|fixtures|mocks|test|tests)(?:\/|$)|\.(?:test|spec|fixture|fixtures|stories|story)\.[cm]?[jt]sx?$/i; + +const BROWSER_ARTIFACT_PATH_PATTERNS = [ + /(?:^|\/)\.next\/static\//, + /(?:^|\/)\.output\/public\//, + /(?:^|\/)build\/static\//, + /(?:^|\/)dist\/assets\//, + /(?:^|\/)public\//, + /(?:^|\/)out\//, + /(?:^|\/)storybook-static\//, +]; + +const SECRET_VALUE_PATTERNS = [ + /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, + /\bAWS_SECRET_ACCESS_KEY\s*[:=]\s*["']?[A-Za-z0-9/+=]{35,}["']?/, + /\bgithub_pat_[A-Za-z0-9_]{30,}\b/, + /\bgh[pousr]_[A-Za-z0-9]{30,}\b/, + /\bglpat-[A-Za-z0-9_-]{20,}\b/, + /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/, + /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/, + /\brk_(?:live|test)_[A-Za-z0-9]{16,}\b/, + /\bsk-[A-Za-z0-9_-]{32,}\b/, + /\bsk-ant-api\d{2}-[A-Za-z0-9_-]{20,}\b/, + /\blin_(?:api|oauth)_[A-Za-z0-9]{20,}\b/, + /\bvercel_[A-Za-z0-9]{20,}\b/, + /\bsntrys_[A-Za-z0-9_-]{20,}\b/, + /\bkey-[a-f0-9]{32}\b/i, + /\bnpm_[A-Za-z0-9]{30,}\b/, + /\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/, + /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/, + /https:\/\/discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/, + /\bsb_secret_[A-Za-z0-9_]{20,}\b/, + /\bservice_role\b/i, + /"private_key"\s*:\s*"-----BEGIN PRIVATE KEY-----/, + /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/, + /\b(?:postgres|mysql|mongodb(?:\+srv)?|redis):\/\/[^:\s/@]+:[^@\s/]+@/i, +]; + +const PUBLIC_ENV_SECRET_NAME_PATTERN = + /\b(?:NEXT_PUBLIC|VITE|REACT_APP|EXPO_PUBLIC)_[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PRIVATE|DATABASE_URL|SERVICE_ROLE|AWS_ACCESS_KEY|AWS_SECRET)[A-Z0-9_]*\b/i; + +const SENSITIVE_AUTH_FIELD_PATTERN = + /\b(?:ownerId|ownerID|creatorId|creatorID|userId|userID|uid|providerId|providerID|orgId|orgID|tenantId|tenantID|teamId|teamID|workspaceId|workspaceID|ghostOrg|role|roles|isAdmin|admin)\b/; + +const SUPABASE_CLIENT_AUTHZ_WRITE_PATTERN = + /\b(?:supabase\b|\.from\s*\(\s*["'][^"']+["']\s*\))[\s\S]{0,700}\b(?:insert|upsert|update)\s*\(\s*(?:\{|\[?\s*\{)[\s\S]{0,700}\b(?:ownerId|creatorId|userId|orgId|tenantId|role|isAdmin)\b/i; + +const PRIVILEGED_QUERY_PARAM_PATTERN = + /\b(?:searchParams|URLSearchParams|request\.nextUrl\.searchParams|location\.search)\b[\s\S]{0,700}\b(?:email|user|userstoinvite|role|permission|sharingaction|invite|admin|next|continue|returnTo|redirect_uri)\b/i; + +const TRUSTED_PUBLIC_SECRET_NAME_PATTERN = + /(?:SENTRY_DSN|PUBLIC_KEY|PUBLISHABLE|ANON_KEY|POSTHOG_PROJECT_TOKEN|POSTHOG_KEY|TLDRAW_LICENSE_KEY|CLERK_PUBLISHABLE_KEY|ALGOLIA_SEARCH_KEY|GC_API_KEY|GOOGLE_MAPS_API_KEY|MAPBOX_TOKEN)/i; + +const BAAS_CLIENT_CONFIG_PATTERN = + /\b(?:initializeApp|firebase|firestore|getFirestore|createClient)\b[\s\S]{0,700}\b(?:apiKey|authDomain|projectId|databaseURL|storageBucket|supabase|SUPABASE_URL)\b|\b(?:apiKey|authDomain|projectId|databaseURL|storageBucket)\b[\s\S]{0,700}\b(?:firebase|firestore|getFirestore|initializeApp)\b/i; + +const BAAS_AUTHORITY_SURFACE_PATTERN = + /\b(?:collection\s*\(\s*["'](?:boosts|sessions|sessions_admin|users|orgs|candidateJobs|conversations|documents|profiles)|from\s*\(\s*["'](?:users|profiles|documents|organizations|memberships)|creatorID|creatorId|providerId|ghostOrg|ownerId|orgId|tenantId|workspaceId|role|roles|isAdmin|SuperAdmin)\b/i; + +const POSTMESSAGE_HANDLER_PATTERN = /addEventListener\s*\(\s*["']message["']|\.onmessage\s*=/; + +const POSTMESSAGE_ORIGIN_CHECK_PATTERN = + /(?:event|e)\.origin|\.origin\s*[!=]==?|origin.*(?:check|valid|allow|trust)|(?:check|valid|allow|trust).*origin/i; + +const OUTBOUND_FETCH_CALL_PATTERN = + /\b(?:fetch|axios\.\s*(?:get|post|put|delete|head)|got|got\.\s*(?:get|post))\s*\(\s*([^,)]+)/; + +const CALLER_STYLE_URL_NAME_PATTERN = + /\b(?:url|targetUrl|callbackUrl|redirectUrl|webhookUrl|companyUrl|websiteUrl|domainUrl|imageUrl|fetchUrl|next|return_to|returnTo|destination|location)\b/i; + +const SAFE_REDIRECT_MODE_PATTERN = /\bredirect\s*:\s*["'](?:manual|error)["']/; + +const DANGEROUS_HTML_PATTERN = /dangerouslySetInnerHTML|\.innerHTML\s*=/; + +const DANGEROUS_HTML_TAINT_PATTERN = + /searchParams|query|params|request|req\.|response\.|result\.|data\.|await|fetch|props\.|children|content|html|body|text|message/i; + +const AGENT_TOOL_DEFINITION_PATTERN = + /\b(?:tool\s*\(\s*\{|createTool\s*\(|defineTool\s*\(|new\s+(?:DynamicTool|StructuredTool)\s*\()/; + +const AGENT_TOOL_DANGEROUS_CAPABILITY_PATTERN = + /\b(?:exec|execSync|spawn|child_process|eval|new Function|vm\.run|readFile|writeFile|fs\.read|fs\.write|fetch|axios|http\.request|sandbox|runCode|executeCode)\b/; + +const MCP_IMPORT_PATTERN = + /\bfrom\s+["']@modelcontextprotocol\/sdk[^"']*["']|\bMcpServer\b|\bMcpAgent\b/; + +const MCP_TOOL_SURFACE_PATTERN = + /\b(?:server\.\s*(?:tool|resource|prompt)\s*\(|register(?:Tool|Resource|Prompt)\s*\(|setRequestHandler\s*\(\s*(?:CallToolRequestSchema|ListToolsRequestSchema)|new\s+(?:McpServer|McpAgent)\s*\()/; + +const RAW_SQL_RISK_PATTERNS = [ + /\$queryRawUnsafe\s*\(/, + /\$executeRawUnsafe\s*\(/, + /\bPrisma\.raw\s*\(/, + /\bsql\.\s*(?:raw|unsafe)\s*\(/, + /\b(?:client|pool|conn)\.query\s*\(\s*['"`]\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^)]{0,400}\$\{/i, + /\.query\s*\(\s*['"`][^'"`]{0,200}['"`]\s*\+/, + /\.whereRaw\s*\(|\.orderByRaw\s*\(|\.havingRaw\s*\(/, + /\bcursor\.execute\s*\(\s*f['"]/, + /\bcursor\.execute\s*\(\s*(?:"[^"]{0,400}"|'[^']{0,400}')\s*(?:%|\.format\s*\(|\+)/, + /\b(?:engine|session)\.execute\s*\(\s*(?:text\s*\(\s*)?f['"]/, + /\$[\w]+->(?:query|exec|prepare|executeQuery|executeStatement|createQuery|createNativeQuery)\s*\(\s*(?:"[^"]{0,400}"|'[^']{0,400}')\s*\.\s*\$/, + /mysqli_query\s*\(\s*[^,]+,\s*(?:"[^"]{0,400}"|'[^']{0,400}')\s*\.\s*\$/, +] as const; + +const NOSQL_INJECTION_RISK_PATTERN = + /\$where\s*['"]?\s*:\s*(?:f?['"`][^'"`]{0,200}\$\{|function|f['"])|\.find\s*\(\s*JSON\.parse\s*\(\s*(?:req|request)\.|\.aggregate\s*\(\s*\[?\s*\{[^}]{0,400}\$where|\bnew\s+RegExp\s*\(\s*(?:req|request)\.|\$regex['"]?\s*:\s*(?:req|request)\./i; + +const COMMAND_EXECUTION_INPUT_RISK_PATTERN = + /\b(?:exec|execSync|spawn|os\.system|subprocess\.(?:run|Popen|call)|shell_exec|exec|system|passthru|proc_open)\s*\([\s\S]{0,220}(?:req\.|request\.|params\.|query\.|body\.|searchParams|\$_(?:GET|POST|REQUEST)|shell\s*=\s*true|f['"`][^'"`]*\{)/i; + +const PATH_TRAVERSAL_RISK_PATTERN = + /\b(?:readFile|readFileSync|writeFile|writeFileSync)\s*\(\s*(?:req\.|request\.|params\.|query\.|body\.|parsed\.|`[^`]*(?:req\.|request\.|params\.|query\.|body\.))|\bpath\.(?:join|resolve)\s*\([^)]*\b(?:req\.|request\.|params\.|query\.|body\.|parsed\.)/; + +const GIT_PROVIDER_URL_INJECTION_PATTERN = + /(?:api\.github\.com|github\.com|gitlab\.com|bitbucket\.org)[^`'"]{0,200}\$\{|`https?:\/\/[^`]{0,80}git[^`]{0,80}\$\{/i; + +const WEBHOOK_HANDLER_PATTERN = + /(?:^|\/)[^/]*webhook[^/]*\/|(?:^|\/)[^/]*webhook[^/]*\.[cm]?[jt]s$|\bwebhook\b/i; + +const WEBHOOK_ENTRYPOINT_PATTERN = + /\b(?:export\s+(?:async\s+)?function\s+POST|export\s+const\s+(?:POST|handler|webhook)|webhookHandler|webhookRoute)\b/i; + +const WEBHOOK_SIGNATURE_VERIFICATION_PATTERN = + /verifySignature|verify.*signature|constructEvent|createHmac|timingSafeEqual|svix|webhookSecret|stripe\.webhooks/i; + +const INSECURE_CRYPTO_PATTERN = + /createHash\s*\(\s*["'](?:md5|sha1)["']|createCipher\s*\(|\b(?:DES|RC4|Blowfish)\b|\bmd5\s*\(|(?:===?|!==?)\s*.{0,40}\b(?:hmac|digest|signature)\b|\b(?:hmac|digest|signature)\b.{0,40}(?:===?|!==?)/i; + +const SECURITY_RANDOM_CONTEXT_PATTERN = + /\b(?:token|secret|key|password|nonce|salt|session|csrf|auth|credential|hash)\b/i; + +const normalizeRelativePath = (filePath: string): string => filePath.replace(/\\/g, "/"); + +const isProbablyTextFile = (relativePath: string): boolean => + TEXT_FILE_PATTERN.test(relativePath) || DOTENV_FILE_PATTERN.test(relativePath); + +const isRepositorySecretFilePath = (relativePath: string): boolean => + DOTENV_FILE_PATTERN.test(relativePath) || + /(?:^|\/)\.npmrc$/.test(relativePath) || + /(?:^|\/)[^/]*(?:credential|credentials|service-account|serviceAccount|firebase-admin|google-service-account|gcp-service-account)[^/]*\.(?:json|env|pem|key)$/i.test( + relativePath, + ); + +const isRepositorySecretExamplePath = (relativePath: string): boolean => + /(?:^|\/)\.env\.example$|(?:^|\/)[^/]*(?:example|sample|template)[^/]*\.(?:env|json|pem|key)$/i.test( + relativePath, + ); + +const isServerOnlyBuildArtifactPath = (relativePath: string): boolean => + /(?:^|\/)(?:\.next\/server|\.output\/server)\//.test(relativePath); + +const isBrowserArtifactPath = (relativePath: string, isGeneratedBundle: boolean): boolean => { + if (isServerOnlyBuildArtifactPath(relativePath)) return false; + if (isGeneratedBundle) return true; + if (relativePath.endsWith(".map")) return true; + return BROWSER_ARTIFACT_PATH_PATTERNS.some((pattern) => pattern.test(relativePath)); +}; + +const isClientSourcePath = (relativePath: string): boolean => { + if (!SOURCE_FILE_PATTERN.test(relativePath)) return false; + if (SERVER_CONTEXT_PATTERN.test(relativePath)) return false; + if (TEST_CONTEXT_PATTERN.test(relativePath)) return false; + return true; +}; + +const isServerRouteSourcePath = (relativePath: string): boolean => { + if (!SOURCE_FILE_PATTERN.test(relativePath)) return false; + if (SERVER_CONTEXT_PATTERN.test(relativePath)) return true; + return /(?:^|\/)(?:middleware|route)\.[cm]?[jt]sx?$/.test(relativePath); +}; + +const isConfigOrCiPath = (relativePath: string): boolean => + /(?:^|\/)(?:package\.json|Dockerfile|docker-compose\.ya?ml|\.github\/workflows\/[^/]+\.ya?ml|vercel\.json|next\.config\.[cm]?[jt]s|netlify\.toml)$/i.test( + relativePath, + ); + +const isSqlPath = (relativePath: string): boolean => + relativePath.endsWith(".sql") || /(?:^|\/)supabase\/(?:migrations|schemas)\//.test(relativePath); + +const isFirebaseRulesPath = (relativePath: string): boolean => + /(?:^|\/)(?:firestore\.rules|storage\.rules|database\.rules\.json)$/.test(relativePath); + +const isPublicDebugArtifactPath = (relativePath: string): boolean => + isBrowserArtifactPath(relativePath, GENERATED_BUNDLE_FILE_PATTERN.test(relativePath)) && + /(?:^|\/)(?:\.env(?:\.[^/]*)?|[^/]*(?:debug|crash|trace|stack|report|dump|phpinfo)[^/]*\.(?:txt|log|json|html?)|[^/]+\.log)$/i.test( + relativePath, + ); + +const getMatchLocation = ( + content: string, + pattern: RegExp | undefined, +): { readonly line: number; readonly column: number } => { + const matchIndex = pattern === undefined ? -1 : content.search(pattern); + if (matchIndex < 0) return { line: 0, column: 0 }; + const prefix = content.slice(0, matchIndex); + const lines = prefix.split(/\r?\n/); + return { + line: lines.length, + column: (lines[lines.length - 1]?.length ?? 0) + 1, + }; +}; + +const buildDiagnostic = (input: SecurityDiagnosticInput): Diagnostic => { + const location = + input.line !== undefined && input.column !== undefined + ? { line: input.line, column: input.column } + : getMatchLocation(input.content, input.pattern); + return { + filePath: input.filePath, + plugin: "react-doctor", + rule: input.rule, + title: input.title, + severity: input.severity, + message: input.message, + help: input.help, + line: location.line, + column: location.column, + category: "Security", + }; +}; + +const addDiagnostic = ( + diagnostics: Diagnostic[], + seen: Set, + diagnostic: Diagnostic, +): void => { + const key = `${diagnostic.rule}:${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column}:${diagnostic.message}`; + if (seen.has(key)) return; + seen.add(key); + diagnostics.push(diagnostic); +}; + +const isAstNode = (value: unknown): value is AstNode => + typeof value === "object" && value !== null && "type" in value; + +const parseSourceAst = (file: ScannedFile): AstNode | null => { + if (!SOURCE_FILE_PATTERN.test(file.relativePath)) return null; + try { + return parseSync(file.relativePath, file.content, { + sourceType: "unambiguous", + range: false, + }).program as unknown as AstNode; + } catch { + return null; + } +}; + +const walkAst = (root: AstNode, visit: (node: AstNode) => void): void => { + const stack: AstNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop(); + if (node === undefined) continue; + visit(node); + const keys = node.type === undefined ? [] : (visitorKeys[node.type] ?? []); + for (let keyIndex = keys.length - 1; keyIndex >= 0; keyIndex -= 1) { + const child = node[keys[keyIndex]]; + if (Array.isArray(child)) { + for (let childIndex = child.length - 1; childIndex >= 0; childIndex -= 1) { + const item = child[childIndex]; + if (isAstNode(item)) stack.push(item); + } + continue; + } + if (isAstNode(child)) stack.push(child); + } + } +}; + +const getNodeText = (file: ScannedFile, node: AstNode | undefined): string => { + if (node?.start === undefined || node.end === undefined) return ""; + return file.content.slice(node.start, node.end); +}; + +const getCalleeText = (file: ScannedFile, node: AstNode): string => { + const callee = node.callee; + return isAstNode(callee) ? getNodeText(file, callee) : ""; +}; + +const getStringLiteralValue = (node: AstNode | undefined): string | null => { + if (!node) return null; + if (node.type === "Literal" && typeof node.value === "string") return node.value; + if (node.type === "StringLiteral" && typeof node.value === "string") return node.value; + return null; +}; + +const readScannedFile = (absolutePath: string, rootDirectory: string): ScannedFile | null => { + let stat: fs.Stats; + try { + stat = fs.statSync(absolutePath); + } catch { + return null; + } + if (!stat.isFile()) return null; + + const relativePath = normalizeRelativePath(path.relative(rootDirectory, absolutePath)); + const isGeneratedBundle = + GENERATED_BUNDLE_FILE_PATTERN.test(relativePath) || isLargeMinifiedFile(absolutePath); + const maxSizeBytes = isGeneratedBundle + ? SECURITY_SCAN_MAX_BUNDLE_FILE_SIZE_BYTES + : SECURITY_SCAN_MAX_FILE_SIZE_BYTES; + if (stat.size > maxSizeBytes) return null; + if ( + !isGeneratedBundle && + !isProbablyTextFile(relativePath) && + !isConfigOrCiPath(relativePath) && + !isRepositorySecretFilePath(relativePath) + ) { + return null; + } + + try { + return { + absolutePath, + relativePath, + content: fs.readFileSync(absolutePath, "utf-8"), + isGeneratedBundle, + }; + } catch { + return null; + } +}; + +const classifyScanBucket = (relativePath: string): ScanBucket | null => { + const isGeneratedByName = GENERATED_BUNDLE_FILE_PATTERN.test(relativePath); + if ( + isRepositorySecretFilePath(relativePath) || + isSqlPath(relativePath) || + isFirebaseRulesPath(relativePath) || + isConfigOrCiPath(relativePath) + ) { + return "priority"; + } + if (isBrowserArtifactPath(relativePath, isGeneratedByName)) return "artifact"; + if (isProbablyTextFile(relativePath)) return "other"; + return null; +}; + +const collectScannedFiles = (rootDirectory: string): ScannedFile[] => { + const priorityFiles: ScannedFile[] = []; + const artifactFiles: ScannedFile[] = []; + const otherFiles: ScannedFile[] = []; + const stack: DirectoryStackEntry[] = [{ absolutePath: rootDirectory, depth: 0 }]; + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) continue; + if (current.depth > SECURITY_SCAN_MAX_DIRECTORY_DEPTH) continue; + + const entries = readDirectoryEntries(current.absolutePath); + for (const entry of entries) { + const absolutePath = path.join(current.absolutePath, entry.name); + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORY_NAMES.has(entry.name)) { + stack.push({ absolutePath, depth: current.depth + 1 }); + } + continue; + } + + const relativePath = normalizeRelativePath(path.relative(rootDirectory, absolutePath)); + const bucket = classifyScanBucket(relativePath); + if (bucket === null) continue; + const bucketFiles = + bucket === "priority" ? priorityFiles : bucket === "artifact" ? artifactFiles : otherFiles; + if (bucketFiles.length >= SECURITY_SCAN_MAX_FILES) continue; + + const scannedFile = readScannedFile(absolutePath, rootDirectory); + if (scannedFile !== null) bucketFiles.push(scannedFile); + } + } + + return [...priorityFiles, ...artifactFiles, ...otherFiles]; +}; + +const hasSecretValue = (content: string): boolean => + SECRET_VALUE_PATTERNS.some((pattern) => pattern.test(content)); + +const hasSuspiciousPublicEnvSecretName = (content: string): boolean => + [...content.matchAll(new RegExp(PUBLIC_ENV_SECRET_NAME_PATTERN.source, "gi"))].some( + (match) => !TRUSTED_PUBLIC_SECRET_NAME_PATTERN.test(match[0] ?? ""), + ); + +const hasFullEnvLeakShape = (content: string): boolean => + /\b(?:process\.env|import\.meta\.env|window\.__[A-Z0-9_]*ENV[A-Z0-9_]*__|__[A-Z0-9_]*ENV[A-Z0-9_]*__)\b/.test( + content, + ) && + /\b(?:DATABASE_URL|AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|MAILGUN_API_KEY|SALESFORCE_CLIENT_SECRET|OKTA_CLIENT_SECRET|SESSION_SECRET|COOKIE_SECRET|PRIVATE_KEY|SERVICE_ROLE)\b/.test( + content, + ); + +const scannerDefinitions: ReadonlyArray = [ + { + rule: "firebase-permissive-rules", + title: "Permissive Firebase security rule", + severity: "error", + shouldScan: (file) => isFirebaseRulesPath(file.relativePath), + pattern: + /allow\s+(?:read|write|create|update|delete|list|get|read,\s*write)\s*:\s*if\s+(?:true|request\.auth\s*!=\s*null)\s*;?/i, + message: + "Firebase rules grant broad access to everyone or to any signed-in user, which is the Chattr/Firewreck failure mode.", + help: "Bind every read/write to `request.auth.uid`, immutable ownership, and tenant membership instead of treating sign-in as authorization.", + }, + { + rule: "firebase-client-owned-authz-field", + title: "Client writes authorization field", + severity: "error", + shouldScan: (file) => isClientSourcePath(file.relativePath), + pattern: + /\b(?:setDoc|updateDoc|addDoc|\.set|\.update|\.add)\s*\([\s\S]{0,700}\b(?:ownerId|ownerID|creatorId|creatorID|providerId|providerID|orgId|orgID|tenantId|tenantID|workspaceId|workspaceID|ghostOrg|role|roles|isAdmin)\b/i, + message: + "Client code writes an ownership, tenant, or role field that should be server-owned and immutable.", + help: "Derive authority fields on the server or enforce them in Firebase/Supabase rules; never trust client-provided owner, org, or role values.", + }, + { + rule: "firebase-query-filter-as-auth", + title: "Firestore query filter used as authorization", + severity: "warning", + shouldScan: (file) => isClientSourcePath(file.relativePath), + pattern: + /\.where\s*\(\s*["'](?:uid|userId|userID|ownerId|ownerID|orgId|orgID|tenantId|tenantID|role)["']\s*,\s*["']==["']/i, + message: + "Firestore query code filters by an auth-shaped field; filtering is not authorization unless rules enforce the same boundary.", + help: "Make sure Firestore rules compare the requested document against `request.auth.uid` and trusted membership data.", + }, + { + rule: "tenant-static-proxy-risk", + title: "Tenant-controlled static asset proxy", + severity: "warning", + shouldScan: (file) => isServerRouteSourcePath(file.relativePath), + pattern: + /\b(?:tenant|subdomain|org|organization|workspace|hostPattern|params)\b[\s\S]{0,700}\b(?:fetch|S3|s3|cdn|bucket|path\.join|join\(["']\/["']\)|decodeURIComponent)\b/i, + message: + "Route code appears to compose tenant or subdomain input into a static/CDN/object-store fetch path.", + help: "Bind tenant identity to the trusted host or authenticated org, canonicalize after decoding, reject traversal, and never let one tenant choose another tenant's asset prefix.", + }, + { + rule: "mdx-ssr-execution-risk", + title: "Server-rendered MDX can execute code", + severity: "warning", + shouldScan: (file) => SOURCE_FILE_PATTERN.test(file.relativePath), + pattern: + /\b(?:@mdx-js\/mdx|next-mdx-remote|MDXRemote|compileMDX|evaluate|compile)\b[\s\S]{0,700}\b(?:mdx|markdown|content|source|body|repo|customer|tenant|cache|process\.env|rehypeRaw|allowDangerousHtml)\b/i, + message: + "MDX/markdown rendering code may evaluate user or repository content during SSR or static generation.", + help: "Use a constrained compiler for untrusted content, disable expressions/raw HTML, sandbox renderers, and avoid caching attacker-controlled output across tenants.", + }, + { + rule: "local-rpc-native-bridge-risk", + title: "Weak localhost native bridge boundary", + severity: "warning", + shouldScan: (file) => SOURCE_FILE_PATTERN.test(file.relativePath), + pattern: + /\b(?:127\.0\.0\.1|localhost|Origin|Access-Control-Allow-Origin|websocket|WebSocket)\b[\s\S]{0,700}\b(?:includes|indexOf|endsWith|UpdateApp|InstallApp|install|update|exec|spawn)\b/i, + message: + "Code appears to bridge browser code to localhost/native capabilities with weak origin or update/install checks.", + help: "Use exact origin allowlists after URL parsing, per-request nonces, narrow methods, and never expose install/update commands to arbitrary web pages.", + }, + { + rule: "url-prefilled-privileged-action", + title: "URL pre-fills a privileged action", + severity: "warning", + shouldScan: (file) => isClientSourcePath(file.relativePath), + pattern: PRIVILEGED_QUERY_PARAM_PATTERN, + message: + "Client code reads sensitive action state from the URL, which can pre-fill invites, roles, redirects, or sharing flows with attacker values.", + help: "Require server-side validation and explicit confirmation for URL-sourced invite, role, permission, redirect, or sharing parameters.", + }, + { + rule: "clickjacking-redirect-risk", + title: "Redirect or frame boundary risk", + severity: "warning", + shouldScan: (file) => + SOURCE_FILE_PATTERN.test(file.relativePath) || isConfigOrCiPath(file.relativePath), + pattern: + /\bredirect\s*\([^)]*(?:searchParams\.get|nextUrl\.searchParams|returnTo|continue|next)\b| SOURCE_FILE_PATTERN.test(file.relativePath), + pattern: + / SOURCE_FILE_PATTERN.test(file.relativePath), + pattern: + /\b(?:eval|new Function|vm\.runIn|script|Lua|python|exec|spawn)\b[\s\S]{0,700}\b(?:exif|metadata|manifest|preset|plugin|upload|drop|archive|zip|import|restore)\b/i, + message: "Imported metadata, uploads, or plugin manifests appear to reach code execution.", + help: "Parse imported metadata as data with strict schemas; do not evaluate EXIF, manifests, presets, dropped files, or archives.", + }, + { + rule: "plugin-update-trust-risk", + title: "Plugin or updater trust boundary risk", + severity: "warning", + shouldScan: (file) => + SOURCE_FILE_PATTERN.test(file.relativePath) || isConfigOrCiPath(file.relativePath), + pattern: + /\b(?:plugin|repoUrl|updateUrl|UpdateApp|InstallApp|auto.?update|download|installer|curl|wget)\b[\s\S]{0,700}\b(?:https?:\/\/|\binstall(?:er)?\b|\bupdate\b|\bbinary\b|\.zip\b|\.exe\b|\.dmg\b|\.appimage\b)/i, + message: + "Code appears to download, install, update, or execute plugin/updater content across a trust boundary.", + help: "Require signed updates/plugins, pin trusted repositories, verify hashes before execution, and keep custom repository installs behind explicit warnings.", + }, + { + rule: "key-lifecycle-risk", + title: "Long-lived key material in repository", + severity: "error", + shouldScan: (file) => !TEST_CONTEXT_PATTERN.test(file.relativePath), + pattern: + /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----|\b(?:SSH_PRIVATE_KEY|GPG_PRIVATE_KEY|DEPLOY_KEY|SIGNING_KEY)\b/i, + message: "Private or long-lived release key material appears in the repository.", + help: "Remove private keys from source, rotate exposed credentials, prefer short-lived deploy credentials, and document revocation/expiry for release keys.", + }, + { + rule: "cors-cookie-trust-risk", + title: "Broad cookie or credentialed CORS trust", + severity: "warning", + shouldScan: (file) => + SOURCE_FILE_PATTERN.test(file.relativePath) || isConfigOrCiPath(file.relativePath), + pattern: + /Access-Control-Allow-Credentials["']?\s*[:,]\s*["']?true[\s\S]{0,700}Access-Control-Allow-Origin["']?\s*[:,]\s*["']?(?:\*|https:\/\/docs\.|https:\/\/.*mintlify)|\b(?:session|auth|token|jwt)[^=\n]{0,80}\bDomain=\./i, + message: + "Credentialed CORS or broad auth-cookie scope can make a docs/custom-domain XSS become account compromise.", + help: "Keep auth cookies host-only and HttpOnly, avoid credentialed CORS for less-trusted docs/vendor origins, and isolate documentation domains from app sessions.", + }, +]; + +const scanArtifactSecrets = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!isBrowserArtifactPath(file.relativePath, file.isGeneratedBundle)) return; + + const secretPattern = SECRET_VALUE_PATTERNS.find((pattern) => pattern.test(file.content)); + if (secretPattern !== undefined) { + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "artifact-secret-leak", + title: "Secret shipped in browser artifact", + severity: "error", + message: "A browser-delivered artifact contains a secret-looking credential value.", + help: "Remove the secret from client bundles/static assets, rotate it, and route privileged service calls through server-only code.", + content: file.content, + pattern: secretPattern, + }), + ); + } + + if (hasSuspiciousPublicEnvSecretName(file.content) || hasFullEnvLeakShape(file.content)) { + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "artifact-env-leak", + title: "Server env leaked to browser artifact", + severity: "error", + message: + "A browser artifact contains server-secret environment names or a full environment dump shape.", + help: "Treat public env prefixes as publication, not secrecy; keep secret env vars server-only and rebuild after rotating leaked keys.", + content: file.content, + pattern: PUBLIC_ENV_SECRET_NAME_PATTERN, + }), + ); + } +}; + +const scanArtifactBaasAuthoritySurface = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!isBrowserArtifactPath(file.relativePath, file.isGeneratedBundle)) return; + if (!BAAS_CLIENT_CONFIG_PATTERN.test(file.content)) return; + if (!BAAS_AUTHORITY_SURFACE_PATTERN.test(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "artifact-baas-authority-surface", + title: "BaaS authority map shipped in browser artifact", + severity: "warning", + message: + "A browser artifact exposes Firebase/Supabase config together with sensitive collections or authorization fields.", + help: "Client BaaS config is often public, but shipped collection names plus owner, role, tenant, or admin fields give attackers a precise authorization map. Verify rules/RLS enforce every boundary server-side.", + content: file.content, + pattern: BAAS_AUTHORITY_SURFACE_PATTERN, + }), + ); +}; + +const scanPublicDebugArtifact = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!isPublicDebugArtifactPath(file.relativePath)) return; + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "public-debug-artifact", + title: "Public debug artifact", + severity: hasSecretValue(file.content) ? "error" : "warning", + message: "A browser-reachable debug, log, dump, report, or env artifact is present.", + help: "Remove debug artifacts from public output; logs and dumps often reveal source paths, internal routes, tokens, or environment snapshots.", + content: file.content, + }), + ); +}; + +const scanActiveStaticAssets = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + const svgActivePattern = /]+(?:data|src)=["'][^"']+\.svg(?:\?[^"']*)?["']/i; + if (dangerousAllowSvgPattern.test(file.content) || executableSvgEmbedPattern.test(file.content)) { + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "active-static-asset", + title: "Executable SVG exposure", + severity: "warning", + message: "The app enables or embeds SVG in an executable browser context.", + help: "Prefer `` for SVG images; if SVG must be served directly, use attachment disposition and a CSP that blocks scripts and objects.", + content: file.content, + pattern: dangerousAllowSvgPattern.test(file.content) + ? dangerousAllowSvgPattern + : executableSvgEmbedPattern, + }), + ); + } +}; + +const scanPackageJsonSecrets = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!file.relativePath.endsWith("package.json")) return; + if (!hasSuspiciousPublicEnvSecretName(file.content) && !hasSecretValue(file.content)) return; + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "package-metadata-secret", + title: "Secret-like package metadata", + severity: "warning", + message: "Package metadata contains secret-like values or public env secret names.", + help: "Keep secrets out of package metadata and generated reports; they are often published to registries, logs, or browser artifacts.", + content: file.content, + pattern: PUBLIC_ENV_SECRET_NAME_PATTERN, + }), + ); +}; + +const scanRepositorySecretFile = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!isRepositorySecretFilePath(file.relativePath)) return; + if (isRepositorySecretExamplePath(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + if (!hasSecretValue(file.content) && !hasSuspiciousPublicEnvSecretName(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "repository-secret-file", + title: "Secret file checked into repository", + severity: "error", + message: "A repository credential/config file contains secret-looking values.", + help: "Remove committed env files, service-account credentials, npm auth tokens, and webhook URLs; rotate exposed values and keep only redacted examples in source.", + content: file.content, + pattern: SECRET_VALUE_PATTERNS.find((pattern) => pattern.test(file.content)), + }), + ); +}; + +const scanPublicEnvSecretName = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!isClientSourcePath(file.relativePath)) return; + if (!hasSuspiciousPublicEnvSecretName(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "public-env-secret-name", + title: "Secret-like public env variable", + severity: "warning", + message: + "Client code references a public env variable whose name looks like a secret or privileged credential.", + help: "Public env prefixes are inlined into browser bundles. Rename public values to non-secret names, and keep tokens, passwords, private keys, and service-role credentials server-only.", + content: file.content, + pattern: PUBLIC_ENV_SECRET_NAME_PATTERN, + }), + ); +}; + +const scanBuildPipelineSecretBoundary = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!isConfigOrCiPath(file.relativePath)) return; + + const ciInstallNearSecretPattern = + /(?:npm|pnpm|yarn|bun)\s+(?:install|ci)\b(?:(?!--ignore-scripts)[\s\S]){0,700}\bsecrets\.[A-Z0-9_]+|\bsecrets\.[A-Z0-9_]+(?:(?!--ignore-scripts)[\s\S]){0,700}(?:npm|pnpm|yarn|bun)\s+(?:install|ci)\b/i; + const pattern = file.relativePath.endsWith("package.json") ? null : ciInstallNearSecretPattern; + + if (pattern === null || !pattern.test(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "build-pipeline-secret-boundary", + title: "Build pipeline runs code near secrets", + severity: "warning", + message: + "The build or install pipeline can execute package lifecycle code while CI secrets may be present.", + help: "Run dependency installs with scripts disabled before exposing secrets, isolate untrusted build code, and move signing/deploy authority into a narrow privileged step.", + content: file.content, + pattern, + }), + ); +}; + +const scanSupabaseRlsPolicyRisk = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!isSqlPath(file.relativePath)) return; + + const disabledRlsPattern = /disable\s+row\s+level\s+security/i; + const serviceRolePolicyPattern = + /create\s+policy[\s\S]{0,700}auth\.role\(\)\s*=\s*["']service_role["']/i; + const openWritePolicyPattern = + /create\s+policy[\s\S]{0,700}\bfor\s+(?:all|insert|update|delete)\b[\s\S]{0,500}\b(?:using|with\s+check)\s*\(\s*true\s*\)/i; + const implicitOpenPolicyPattern = + /create\s+policy(?:(?!\bfor\s+select\b)[\s\S]){0,700}\b(?:using|with\s+check)\s*\(\s*true\s*\)/i; + const pattern = + [ + disabledRlsPattern, + serviceRolePolicyPattern, + openWritePolicyPattern, + implicitOpenPolicyPattern, + ].find((candidate) => candidate.test(file.content)) ?? null; + + if (pattern === null) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "supabase-rls-policy-risk", + title: "Permissive Supabase RLS policy", + severity: "error", + message: + "Supabase policy SQL disables RLS, permits writes broadly, or references a service-role bypass.", + help: "Keep public-read policies explicit, but gate inserts, updates, deletes, and service-role bypasses behind `auth.uid()` plus trusted tenant membership.", + content: file.content, + pattern, + }), + ); +}; + +const scanPostMessageOriginRisk = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!SOURCE_FILE_PATTERN.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + const ast = parseSourceAst(file); + if (ast === null) return; + + walkAst(ast, (node) => { + if (node.type !== "CallExpression" && node.type !== "AssignmentExpression") return; + + const nodeText = getNodeText(file, node); + let isMessageHandler = false; + if (node.type === "CallExpression") { + const calleeText = getCalleeText(file, node); + const args = Array.isArray(node.arguments) ? node.arguments : []; + const firstArgument = isAstNode(args[0]) ? args[0] : undefined; + isMessageHandler = + calleeText.endsWith("addEventListener") && + getStringLiteralValue(firstArgument) === "message"; + } else { + const left = node.left; + isMessageHandler = isAstNode(left) && getNodeText(file, left).endsWith(".onmessage"); + } + + if (!isMessageHandler) return; + if (POSTMESSAGE_ORIGIN_CHECK_PATTERN.test(nodeText)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "postmessage-origin-risk", + title: "postMessage handler without origin check", + severity: "warning", + message: + "A message event handler reads cross-window messages without an obvious origin check.", + help: "Validate `event.origin` against an exact allowlist before using `event.data`, especially when an iframe or parent window can be attacker-controlled.", + content: file.content, + line: getMatchLocation(file.content, undefined).line || undefined, + column: getMatchLocation(file.content, undefined).column || undefined, + pattern: POSTMESSAGE_HANDLER_PATTERN, + }), + ); + }); +}; + +const scanUntrustedRedirectFollowing = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if ( + !isServerRouteSourcePath(file.relativePath) && + !SERVER_CONTEXT_PATTERN.test(file.relativePath) + ) { + return; + } + if (!/\bfetch\s*\(|\baxios\b|\bgot\s*\(/.test(file.content)) return; + + const lines = file.content.split("\n"); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex] ?? ""; + const explicitFollow = /\bredirect\s*:\s*["']follow["']/.test(line); + const fetchMatch = line.match(OUTBOUND_FETCH_CALL_PATTERN); + if ( + !explicitFollow && + (!fetchMatch || !CALLER_STYLE_URL_NAME_PATTERN.test(fetchMatch[1] ?? "")) + ) { + continue; + } + + const fetchWindow = lines.slice(lineIndex, lineIndex + 5).join("\n"); + if (!explicitFollow && SAFE_REDIRECT_MODE_PATTERN.test(fetchWindow)) continue; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "untrusted-redirect-following", + title: "Server fetch follows redirects for caller-shaped URL", + severity: "warning", + message: + "Server-side fetch code appears to follow redirects for a URL shaped like caller-controlled input.", + help: 'Use `redirect: "manual"` or equivalent and re-validate every redirect target before following it to avoid SSRF redirect bypasses.', + content: file.content, + line: lineIndex + 1, + column: line.search(/\S/) + 1, + }), + ); + } +}; + +const scanDangerousHtmlSink = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!SOURCE_FILE_PATTERN.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + if (!DANGEROUS_HTML_PATTERN.test(file.content)) return; + + const lines = file.content.split("\n"); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex] ?? ""; + if (!DANGEROUS_HTML_PATTERN.test(line)) continue; + + const htmlWindow = lines.slice(Math.max(0, lineIndex - 3), lineIndex + 5).join("\n"); + if (/__html\s*:\s*["'`]/.test(htmlWindow)) continue; + if (!DANGEROUS_HTML_TAINT_PATTERN.test(htmlWindow)) continue; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "dangerous-html-sink", + title: "HTML injection sink with dynamic content", + severity: "warning", + message: + "HTML is injected from a dynamic-looking source, which can become XSS if the value is user-controlled or unsanitized.", + help: "Prefer rendering structured React nodes. If HTML is required, sanitize with a well-reviewed sanitizer and keep the trust boundary close to the sink.", + content: file.content, + line: lineIndex + 1, + column: line.search(/\S/) + 1, + }), + ); + } +}; + +const scanAgentToolCapabilityRisk = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!SOURCE_FILE_PATTERN.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + if ( + !/(?:^|\/)(?:agents?|tools?|mcp)(?:\/|$)|(?:agent|tool|mcp)[^/]*\.[cm]?[jt]sx?$/i.test( + file.relativePath, + ) + ) { + return; + } + if (!AGENT_TOOL_DEFINITION_PATTERN.test(file.content)) return; + if (!AGENT_TOOL_DANGEROUS_CAPABILITY_PATTERN.test(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "agent-tool-capability-risk", + title: "Agent tool exposes dangerous capability", + severity: "warning", + message: + "An agent-callable tool appears to expose network, filesystem, shell, or code-execution capability.", + help: "Treat tool inputs as prompt-injection controlled. Validate arguments, scope permissions per call, and avoid exposing shell/file/network primitives directly to agents.", + content: file.content, + pattern: AGENT_TOOL_DEFINITION_PATTERN, + }), + ); +}; + +const scanMcpToolCapabilityRisk = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!SOURCE_FILE_PATTERN.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + if (!MCP_IMPORT_PATTERN.test(file.content)) return; + if (!MCP_TOOL_SURFACE_PATTERN.test(file.content)) return; + if (!AGENT_TOOL_DANGEROUS_CAPABILITY_PATTERN.test(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "mcp-tool-capability-risk", + title: "MCP tool exposes dangerous capability", + severity: "warning", + message: + "An MCP tool/resource/prompt handler appears to expose file, shell, network, or code-execution capability.", + help: "MCP tool calls run with the connecting client's authority. Validate inputs, enforce per-tool authorization, and avoid raw filesystem/shell/network access where possible.", + content: file.content, + pattern: MCP_TOOL_SURFACE_PATTERN, + }), + ); +}; + +const scanRawSqlRisk = (file: ScannedFile, diagnostics: Diagnostic[], seen: Set): void => { + if (!/\.(?:[cm]?[jt]sx?|py|php)$/.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + const pattern = RAW_SQL_RISK_PATTERNS.find((candidate) => candidate.test(file.content)); + if (pattern === undefined) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "raw-sql-injection-risk", + title: "Raw SQL built outside parameter binding", + severity: "warning", + message: + "Code uses a raw SQL escape hatch or string-built query shape that can bypass parameter binding.", + help: "Keep user input in driver parameters or ORM bind variables. Avoid unsafe/raw SQL helpers and string interpolation for queries.", + content: file.content, + pattern, + }), + ); +}; + +const scanNoSqlInjectionRisk = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!/\.(?:[cm]?[jt]sx?|py)$/.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + if (!NOSQL_INJECTION_RISK_PATTERN.test(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "nosql-injection-risk", + title: "NoSQL query accepts operator-shaped input", + severity: "warning", + message: "Code appears to pass raw JSON, regex, or `$where` style input into a NoSQL query.", + help: "Coerce scalar fields before querying, reject operator keys from client input, and avoid `$where` or request-derived regexes.", + content: file.content, + pattern: NOSQL_INJECTION_RISK_PATTERN, + }), + ); +}; + +const scanCommandExecutionInputRisk = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!/\.(?:[cm]?[jt]sx?|py|php)$/.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + if (!COMMAND_EXECUTION_INPUT_RISK_PATTERN.test(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "command-execution-input-risk", + title: "Command execution uses caller-shaped input", + severity: "error", + message: + "Command execution appears to include request, query, body, or shell-interpolated input.", + help: "Avoid shell execution for caller-controlled values. Use fixed commands, argument arrays, strict allowlists, and no shell interpolation.", + content: file.content, + pattern: COMMAND_EXECUTION_INPUT_RISK_PATTERN, + }), + ); +}; + +const scanPathTraversalRisk = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!SOURCE_FILE_PATTERN.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + if (!PATH_TRAVERSAL_RISK_PATTERN.test(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "path-traversal-risk", + title: "Filesystem path uses caller input", + severity: "warning", + message: + "Filesystem access appears to use request, query, params, or body data as part of the path.", + help: "Resolve paths against a fixed base directory, reject traversal after normalization, and map user-visible identifiers to server-owned paths.", + content: file.content, + pattern: PATH_TRAVERSAL_RISK_PATTERN, + }), + ); +}; + +const scanGitProviderUrlInjectionRisk = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!SOURCE_FILE_PATTERN.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + if (!GIT_PROVIDER_URL_INJECTION_PATTERN.test(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "git-provider-url-injection-risk", + title: "Git provider URL built from interpolation", + severity: "warning", + message: + "GitHub/GitLab/Bitbucket URL construction interpolates path components that may be attacker-controlled.", + help: "Validate owner, repo, org, and branch identifiers against strict slugs and build URLs with URL/path encoders instead of raw interpolation.", + content: file.content, + pattern: GIT_PROVIDER_URL_INJECTION_PATTERN, + }), + ); +}; + +const scanWebhookSignatureRisk = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!SOURCE_FILE_PATTERN.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + if ( + !WEBHOOK_HANDLER_PATTERN.test(file.relativePath) && + !WEBHOOK_HANDLER_PATTERN.test(file.content) + ) { + return; + } + if (!WEBHOOK_ENTRYPOINT_PATTERN.test(file.content)) return; + if (WEBHOOK_SIGNATURE_VERIFICATION_PATTERN.test(file.content)) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "webhook-signature-risk", + title: "Webhook handler lacks signature verification", + severity: "warning", + message: "Webhook handler code does not show an obvious signature verification step.", + help: "Verify provider signatures before parsing or acting on webhook bodies. Use provider SDK helpers or HMAC verification with timing-safe comparison.", + content: file.content, + pattern: WEBHOOK_ENTRYPOINT_PATTERN, + }), + ); +}; + +const scanInsecureCryptoRisk = ( + file: ScannedFile, + diagnostics: Diagnostic[], + seen: Set, +): void => { + if (!SOURCE_FILE_PATTERN.test(file.relativePath)) return; + if (TEST_CONTEXT_PATTERN.test(file.relativePath)) return; + const hasInsecurePrimitive = INSECURE_CRYPTO_PATTERN.test(file.content); + const hasSecurityRandom = + SECURITY_RANDOM_CONTEXT_PATTERN.test(file.content) && /Math\.random\s*\(/.test(file.content); + if (!hasInsecurePrimitive && !hasSecurityRandom) return; + + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "insecure-crypto-risk", + title: "Weak cryptography in security context", + severity: "warning", + message: + "Code uses weak hashes, deprecated ciphers, timing-unsafe comparisons, or Math.random in a security-shaped context.", + help: "Use modern primitives, `crypto.randomBytes` / Web Crypto randomness, and timing-safe comparisons for signatures, digests, tokens, and auth material.", + content: file.content, + pattern: hasInsecurePrimitive ? INSECURE_CRYPTO_PATTERN : /Math\.random\s*\(/, + }), + ); +}; + +export const checkSecurityPosture = (rootDirectory: string): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const seen = new Set(); + const files = collectScannedFiles(rootDirectory); + + for (const file of files) { + scanArtifactSecrets(file, diagnostics, seen); + scanArtifactBaasAuthoritySurface(file, diagnostics, seen); + scanPublicDebugArtifact(file, diagnostics, seen); + scanActiveStaticAssets(file, diagnostics, seen); + scanPackageJsonSecrets(file, diagnostics, seen); + scanRepositorySecretFile(file, diagnostics, seen); + scanPublicEnvSecretName(file, diagnostics, seen); + scanBuildPipelineSecretBoundary(file, diagnostics, seen); + scanSupabaseRlsPolicyRisk(file, diagnostics, seen); + scanPostMessageOriginRisk(file, diagnostics, seen); + scanUntrustedRedirectFollowing(file, diagnostics, seen); + scanDangerousHtmlSink(file, diagnostics, seen); + scanAgentToolCapabilityRisk(file, diagnostics, seen); + scanMcpToolCapabilityRisk(file, diagnostics, seen); + scanRawSqlRisk(file, diagnostics, seen); + scanNoSqlInjectionRisk(file, diagnostics, seen); + scanCommandExecutionInputRisk(file, diagnostics, seen); + scanPathTraversalRisk(file, diagnostics, seen); + scanGitProviderUrlInjectionRisk(file, diagnostics, seen); + scanWebhookSignatureRisk(file, diagnostics, seen); + scanInsecureCryptoRisk(file, diagnostics, seen); + + for (const scanner of scannerDefinitions) { + if (!scanner.shouldScan(file)) continue; + if (!scanner.pattern.test(file.content)) continue; + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: scanner.rule, + title: scanner.title, + severity: scanner.severity, + message: scanner.message, + help: scanner.help, + content: file.content, + pattern: scanner.pattern, + }), + ); + } + + if ( + isClientSourcePath(file.relativePath) && + SENSITIVE_AUTH_FIELD_PATTERN.test(file.content) && + SUPABASE_CLIENT_AUTHZ_WRITE_PATTERN.test(file.content) + ) { + addDiagnostic( + diagnostics, + seen, + buildDiagnostic({ + filePath: file.relativePath, + rule: "supabase-client-owned-authz-field", + title: "Client writes Supabase authorization field", + severity: "error", + message: + "Client Supabase code appears to write user, tenant, owner, or role fields that should be enforced by RLS.", + help: "Use RLS policies based on `auth.uid()` and server-owned membership rows; do not trust client-provided owner, org, or role columns.", + content: file.content, + pattern: SENSITIVE_AUTH_FIELD_PATTERN, + }), + ); + } + } + + return diagnostics; +}; diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 714e3e0a6..e86e5b642 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -210,6 +210,14 @@ export const DEAD_CODE_WORKER_TIMEOUT_MS = 120_000; // the child's heap so those projects complete instead of crashing. export const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192; +export const SECURITY_SCAN_MAX_FILES = 2500; + +export const SECURITY_SCAN_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; + +export const SECURITY_SCAN_MAX_BUNDLE_FILE_SIZE_BYTES = 8 * 1024 * 1024; + +export const SECURITY_SCAN_MAX_DIRECTORY_DEPTH = 8; + // HACK: lookahead cap for JSX opener-span scanning; bounds worst-case // work on pathological files. Real openers stay well under this. export const JSX_OPENER_SCAN_MAX_LINES = 32; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aeec4eb9b..1a6faedfb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,7 @@ export * from "./check-expo-project.js"; export * from "./check-pnpm-hardening.js"; export * from "./check-react-native-project.js"; export * from "./check-reduced-motion.js"; +export * from "./check-security-posture.js"; export * from "./collect-ignore-patterns.js"; export * from "./compute-diagnostic-delta.js"; export * from "./constants.js"; diff --git a/packages/core/src/run-inspect.ts b/packages/core/src/run-inspect.ts index 0345c0b40..a7c517c14 100644 --- a/packages/core/src/run-inspect.ts +++ b/packages/core/src/run-inspect.ts @@ -17,6 +17,7 @@ import { checkExpoProject } from "./check-expo-project.js"; import { checkPnpmHardening } from "./check-pnpm-hardening.js"; import { checkReactNativeProject } from "./check-react-native-project.js"; import { checkReducedMotion } from "./check-reduced-motion.js"; +import { checkSecurityPosture } from "./check-security-posture.js"; import { DEFAULT_SHOW_WARNINGS } from "./constants.js"; import { highlighter } from "./highlighter.js"; import { computeJsxIncludePaths } from "./jsx-include-paths.js"; @@ -324,12 +325,12 @@ export const runInspect = ( Stream.tap((diagnostic) => reporterService.emit(diagnostic)), ); - // ── Phase: environment checks ────────────────────────────────── const environmentDiagnostics: ReadonlyArray = isDiffMode ? [] : [ ...checkReducedMotion(scanDirectory), ...checkPnpmHardening(scanDirectory), + ...checkSecurityPosture(scanDirectory), ...checkExpoProject(scanDirectory, project), ...checkReactNativeProject(scanDirectory, project), ]; diff --git a/packages/core/tests/check-security-posture.test.ts b/packages/core/tests/check-security-posture.test.ts new file mode 100644 index 000000000..fbaa65d76 --- /dev/null +++ b/packages/core/tests/check-security-posture.test.ts @@ -0,0 +1,556 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import type { Diagnostic } from "@react-doctor/core"; +import { checkSecurityPosture } from "../src/check-security-posture.js"; + +const FIXTURES_DIRECTORY = path.resolve(import.meta.dirname, "fixtures", "check-security-posture"); + +let temporaryRoot: string; + +const writeFile = (relativePath: string, content: string): void => { + const absolutePath = path.join(temporaryRoot, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, content); +}; + +const rulesOf = (diagnostics: ReadonlyArray): ReadonlySet => + new Set(diagnostics.map((diagnostic) => diagnostic.rule)); + +const fixtureRules = (fixtureName: string): ReadonlySet => + rulesOf(checkSecurityPosture(path.join(FIXTURES_DIRECTORY, fixtureName))); + +beforeEach(() => { + temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-security-posture-")); +}); + +afterEach(() => { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +}); + +describe("checkSecurityPosture", () => { + describe("Eva-grounded fixtures", () => { + it("flags an a16z-style full server env object shipped in a browser chunk", () => { + expect(fixtureRules("eva-a16z-env-bundle")).toEqual( + new Set(["artifact-env-leak", "artifact-secret-leak"]), + ); + }); + + it("flags a GamerSafer-style public React env secret leak in source and build output", () => { + expect(fixtureRules("eva-gamersafer-public-env")).toEqual( + new Set(["artifact-env-leak", "artifact-secret-leak", "public-env-secret-name"]), + ); + }); + + it("flags minified/generated widget bundles outside normal framework output folders", () => { + expect(fixtureRules("eva-minified-widget-bundle")).toEqual(new Set(["artifact-secret-leak"])); + }); + + it("flags Mintlify-style MDX SSR plus cross-tenant static asset exposure", () => { + expect(fixtureRules("eva-mintlify-docs-platform")).toEqual( + new Set(["active-static-asset", "mdx-ssr-execution-risk", "tenant-static-proxy-risk"]), + ); + }); + + it("flags Arc and Chattr style Firebase authorization mistakes", () => { + expect(fixtureRules("eva-arc-chattr-firebase")).toEqual( + new Set([ + "artifact-baas-authority-surface", + "firebase-client-owned-authz-field", + "firebase-permissive-rules", + "firebase-query-filter-as-auth", + ]), + ); + }); + + it("flags a ToDesktop-style release pipeline where install scripts run near release secrets", () => { + expect(fixtureRules("eva-todesktop-release-pipeline")).toEqual( + new Set(["build-pipeline-secret-boundary"]), + ); + }); + + it("flags an ASUS DriverHub-style localhost RPC and updater bridge", () => { + expect(fixtureRules("mrbruh-asus-local-rpc")).toEqual( + new Set(["local-rpc-native-bridge-risk", "plugin-update-trust-risk"]), + ); + }); + + it("flags a Fooocus-style metadata eval import path", () => { + expect(fixtureRules("mrbruh-fooocus-metadata-eval")).toEqual( + new Set(["import-metadata-execution-risk"]), + ); + }); + + it("flags a Lyra-style iframe redirect chain with prefilled privileged URL parameters", () => { + expect(fixtureRules("lyra-clickjacking-redirect-chain")).toEqual( + new Set(["clickjacking-redirect-risk", "url-prefilled-privileged-action"]), + ); + }); + + it("flags a Lyra-style SVG-filtered iframe clickjacking primitive", () => { + expect(fixtureRules("lyra-svg-filter-clickjacking")).toEqual( + new Set(["svg-filter-clickjacking-risk"]), + ); + }); + + it("flags a Supabase service-role key exposed through public client config", () => { + expect(fixtureRules("supabase-service-role-public-client")).toEqual( + new Set(["artifact-env-leak", "artifact-secret-leak", "public-env-secret-name"]), + ); + }); + + it("flags common webhook and provider tokens shipped in browser bundles", () => { + expect(fixtureRules("broad-provider-token-bundle")).toEqual( + new Set(["artifact-secret-leak"]), + ); + }); + + it("flags permissive Supabase RLS plus client-owned tenant and role columns", () => { + expect(fixtureRules("supabase-rls-client-owned-authz")).toEqual( + new Set(["supabase-client-owned-authz-field", "supabase-rls-policy-risk"]), + ); + }); + + it("flags docs-domain credentialed CORS and broad first-party auth cookies", () => { + expect(fixtureRules("docs-cookie-cors-trust")).toEqual(new Set(["cors-cookie-trust-risk"])); + }); + + it("flags public debug logs and source maps that expose server env references", () => { + expect(fixtureRules("public-debug-sourcemap-leak")).toEqual( + new Set(["artifact-env-leak", "artifact-secret-leak", "public-debug-artifact"]), + ); + }); + + it("flags checked-in private release key material and key-shaped CI variables", () => { + expect(fixtureRules("release-key-material-leak")).toEqual(new Set(["key-lifecycle-risk"])); + }); + + it("flags committed env, service-account, and npm auth credential files", () => { + expect(fixtureRules("repository-secret-files")).toEqual( + new Set(["key-lifecycle-risk", "repository-secret-file"]), + ); + }); + + it("flags ported static matcher patterns for postMessage, redirect-following fetches, and dynamic HTML", () => { + expect(fixtureRules("ported-static-matcher-patterns")).toEqual( + new Set(["dangerous-html-sink", "postmessage-origin-risk", "untrusted-redirect-following"]), + ); + }); + + it("keeps safe postMessage, manual redirect, and static HTML patterns quiet", () => { + expect( + checkSecurityPosture(path.join(FIXTURES_DIRECTORY, "ported-static-matcher-safe-patterns")), + ).toEqual([]); + }); + + it("flags ported agent, MCP, SQL, NoSQL, and command execution matcher patterns", () => { + expect(fixtureRules("ported-agent-mcp-tool-risks")).toEqual( + new Set(["agent-tool-capability-risk", "mcp-tool-capability-risk"]), + ); + expect(fixtureRules("ported-database-and-command-risks")).toEqual( + new Set(["command-execution-input-risk", "nosql-injection-risk", "raw-sql-injection-risk"]), + ); + }); + + it("keeps safe agent tools and parameterized database calls quiet", () => { + expect( + checkSecurityPosture(path.join(FIXTURES_DIRECTORY, "ported-agent-database-safe-patterns")), + ).toEqual([]); + }); + + it("flags ported path traversal, git URL injection, webhook signature, and crypto risks", () => { + expect(fixtureRules("ported-web-security-risks")).toEqual( + new Set([ + "git-provider-url-injection-risk", + "insecure-crypto-risk", + "path-traversal-risk", + "webhook-signature-risk", + ]), + ); + }); + + it("keeps safe path, git URL, webhook signature, and crypto patterns quiet", () => { + expect( + checkSecurityPosture(path.join(FIXTURES_DIRECTORY, "ported-web-security-safe-patterns")), + ).toEqual([]); + }); + + it("keeps redacted env examples quiet", () => { + expect( + checkSecurityPosture(path.join(FIXTURES_DIRECTORY, "repository-secret-examples")), + ).toEqual([]); + }); + + it("keeps public Supabase chat browser bundles quiet when they expose no authority fields", () => { + expect( + checkSecurityPosture(path.join(FIXTURES_DIRECTORY, "real-supabase-chat-browser-bundle")), + ).toEqual([]); + }); + + it("keeps known browser-facing analytics, license, map, and search keys quiet", () => { + expect(checkSecurityPosture(path.join(FIXTURES_DIRECTORY, "real-public-env-keys"))).toEqual( + [], + ); + }); + + it("keeps server-only Supabase service-role routes quiet", () => { + expect( + checkSecurityPosture(path.join(FIXTURES_DIRECTORY, "real-server-service-role-route")), + ).toEqual([]); + }); + + it("keeps public-read private-write Supabase RLS policies quiet", () => { + expect( + checkSecurityPosture( + path.join(FIXTURES_DIRECTORY, "real-supabase-public-read-private-write"), + ), + ).toEqual([]); + }); + + it("stays quiet on a hardened app with scoped rules and public-only browser config", () => { + expect(checkSecurityPosture(path.join(FIXTURES_DIRECTORY, "safe-hardened-app"))).toEqual([]); + }); + }); + + it("covers the P0-P2 security posture analyzer families", () => { + writeFile( + ".next/static/chunks/app.js", + `window.__ENV__ = { AWS_SECRET_ACCESS_KEY: "very-secret", AWS_ACCESS_KEY_ID: "AKIAABCDEFGHIJKLMNOP" };`, + ); + writeFile("public/debug.log", "DATABASE_URL=postgres://user:pass@example.com/db\nstack trace"); + writeFile("public/xss.svg", ``); + writeFile( + "firestore.rules", + `service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if request.auth != null; } } }`, + ); + writeFile( + "src/firebase-client.tsx", + `export const save = () => setDoc(ref, { creatorID: profile.id, role: "SuperAdmin" }); db.collection("sessions").where("userId", "==", userId);`, + ); + writeFile( + "supabase/migrations/001.sql", + `alter table profiles disable row level security; create policy "open" on profiles using (true);`, + ); + writeFile( + "src/supabase-client.ts", + `export const save = (supabase, ownerId) => supabase.from("docs").upsert({ ownerId, role: "admin" });`, + ); + writeFile( + "package.json", + JSON.stringify({ scripts: { postinstall: "node scripts/postinstall.js" } }, null, 2), + ); + writeFile( + ".github/workflows/release.yml", + `steps:\n - run: pnpm install\n env:\n RELEASE_TOKEN: \${{ secrets.RELEASE_TOKEN }}\n`, + ); + writeFile( + "app/api/static/[tenant]/route.ts", + `export const GET = async (_, { params }) => fetch(CDN + "/" + params.tenant + "/" + decodeURIComponent(params.path.join("/")));`, + ); + writeFile( + "src/render-mdx.ts", + `import { compileMDX } from "next-mdx-remote/rsc"; export const render = (markdown) => compileMDX({ source: markdown });`, + ); + writeFile( + "src/local-bridge.ts", + `if (origin.includes("driverhub.asus.com")) new WebSocket("ws://127.0.0.1:53000/UpdateApp");`, + ); + writeFile( + "src/share-dialog.tsx", + `const email = new URLSearchParams(location.search).get("userstoinvite"); const role = searchParams.get("role");`, + ); + writeFile( + "src/redirect.ts", + `export const GET = (request) => redirect(request.nextUrl.searchParams.get("next"));`, + ); + writeFile( + "src/import-metadata.ts", + `export const apply = (metadata) => eval(metadata.exifPreset);`, + ); + writeFile( + "src/message-listener.ts", + `window.addEventListener("message", (event) => window.dispatchEvent(new CustomEvent("x", { detail: event.data })));`, + ); + writeFile( + "app/api/preview/route.ts", + `export const POST = async (request) => { const { imageUrl } = await request.json(); return fetch(imageUrl); };`, + ); + writeFile( + "src/remote-html.tsx", + `export const RemoteHtml = ({ html }) =>
;`, + ); + writeFile( + "src/agents/tools/run-command.ts", + `import { tool } from "ai"; import { execFile } from "node:child_process"; export const t = tool({ execute: async ({ command }) => execFile(command, []) });`, + ); + writeFile( + "src/mcp/server.ts", + `import { McpServer } from "@modelcontextprotocol/sdk/server/index.js"; import { readFile } from "node:fs/promises"; const server = new McpServer({ name: "x", version: "1" }); server.tool("read_file", async ({ path }) => readFile(path, "utf-8"));`, + ); + writeFile( + "src/raw-sql.ts", + "export const q = (prisma, id) => prisma.$queryRawUnsafe(`SELECT * FROM users WHERE id = '${id}'`);", + ); + writeFile( + "src/nosql.ts", + "export const q = (collection, request) => collection.find(JSON.parse(request.body.filter));", + ); + writeFile( + "app/api/files/route.ts", + `export const POST = async (request) => readFile(request.body.path, "utf-8");`, + ); + writeFile( + "src/github-import.ts", + "export const build = (owner, repo) => `https://api.github.com/repos/${owner}/${repo}`;", + ); + writeFile( + "app/api/stripe/webhook/route.ts", + `export const POST = async (request) => { const event = await request.json(); return Response.json({ received: event.type }); };`, + ); + writeFile("src/session-token.ts", `export const token = () => Math.random().toString(36);`); + writeFile( + "scripts/report.py", + "import os\ndef run(request):\n os.system(f\"wkhtmltopdf {request.args['url']} /tmp/report.pdf\")\n", + ); + writeFile( + "src/updater.ts", + `import { execFile } from "node:child_process"; export const update = (updateUrl) => execFile("installer", [updateUrl ?? "https://example.com/app.exe"]);`, + ); + writeFile("secrets/signing-key.txt", "-----BEGIN OPENSSH PRIVATE KEY-----\nredacted\n"); + writeFile( + "src/cors.ts", + `headers.set("Access-Control-Allow-Credentials", "true"); headers.set("Access-Control-Allow-Origin", "https://docs.example.com"); res.setHeader("Set-Cookie", "session=abc; Domain=.example.com");`, + ); + writeFile("next.config.js", `export default { images: { dangerouslyAllowSVG: true } };`); + + const rules = rulesOf(checkSecurityPosture(temporaryRoot)); + + expect(rules).toEqual( + new Set([ + "active-static-asset", + "artifact-env-leak", + "artifact-secret-leak", + "build-pipeline-secret-boundary", + "clickjacking-redirect-risk", + "cors-cookie-trust-risk", + "firebase-client-owned-authz-field", + "firebase-permissive-rules", + "firebase-query-filter-as-auth", + "import-metadata-execution-risk", + "key-lifecycle-risk", + "local-rpc-native-bridge-risk", + "mdx-ssr-execution-risk", + "plugin-update-trust-risk", + "public-debug-artifact", + "supabase-client-owned-authz-field", + "supabase-rls-policy-risk", + "tenant-static-proxy-risk", + "postmessage-origin-risk", + "untrusted-redirect-following", + "dangerous-html-sink", + "url-prefilled-privileged-action", + "agent-tool-capability-risk", + "mcp-tool-capability-risk", + "raw-sql-injection-risk", + "nosql-injection-risk", + "command-execution-input-risk", + "path-traversal-risk", + "git-provider-url-injection-risk", + "webhook-signature-risk", + "insecure-crypto-risk", + ]), + ); + }); + + it("stays quiet on public client keys, Firebase config alone, SVG images, and test keys", () => { + writeFile( + "public/app.js", + `window.config = { firebase: { apiKey: "AIzaSyPublicNotASecret", projectId: "demo" }, sentry: "https://abc@o1.ingest.sentry.io/2", stripe: "pk_live_1234567890" };`, + ); + writeFile("src/logo.tsx", `export const Logo = () => ;`); + writeFile( + "tests/fixtures/private-key.txt", + "-----BEGIN OPENSSH PRIVATE KEY-----\nfixture only\n", + ); + + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + }); + + it("adds Security metadata and source locations for direct pattern matches", () => { + writeFile( + "firestore.rules", + `rules_version = "2";\nservice cloud.firestore {\n match /databases/{database}/documents {\n allow read, write: if true;\n }\n}\n`, + ); + + const diagnostics = checkSecurityPosture(temporaryRoot); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + plugin: "react-doctor", + rule: "firebase-permissive-rules", + category: "Security", + severity: "error", + line: 4, + column: 5, + }); + }); + + it("does not treat server-only Next build output as a browser artifact", () => { + writeFile( + ".next/server/app/page.js", + `export const env = { AWS_SECRET_ACCESS_KEY: "server-only", AWS_ACCESS_KEY_ID: "AKIAABCDEFGHIJKLMNOP" };`, + ); + + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + }); + + it("does not treat ordinary package dist output as browser-shipped artifact output", () => { + writeFile( + "packages/api/dist/index.js", + `export const databaseUrl = process.env.DATABASE_URL ?? "postgres://api:password@db.internal.example.com/api";`, + ); + + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + }); + + it("still reports a real secret when a public key appears in the same browser bundle", () => { + writeFile( + "dist/assets/app.js", + `const publishable = "pk_live_1234567890"; const secret = "AKIAABCDEFGHIJKLMNOP";`, + ); + + expect(rulesOf(checkSecurityPosture(temporaryRoot))).toContain("artifact-secret-leak"); + }); + + it("discovers one-line minified bundles even without a generated bundle filename", () => { + const minifiedPrefix = `var bootstrap="${"a".repeat(21_000)}";`; + writeFile( + "packages/widget/client.js", + `${minifiedPrefix}var env={AWS_ACCESS_KEY_ID:"AKIAABCDEFGHIJKLMNOP"};window.Widget=env;`, + ); + + expect(rulesOf(checkSecurityPosture(temporaryRoot))).toContain("artifact-secret-leak"); + }); + + it("reports public env names that look secret-like in client source", () => { + writeFile("src/client.tsx", `export const token = process.env.NEXT_PUBLIC_SECRET_TOKEN;`); + + expect(rulesOf(checkSecurityPosture(temporaryRoot))).toContain("public-env-secret-name"); + }); + + it("does not report server-only public env probes as client secret exposure", () => { + writeFile( + "src/server/env.server.ts", + `export const token = process.env.NEXT_PUBLIC_SECRET_TOKEN;`, + ); + + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + }); + + it("keeps tenant CDN fetches in client code quiet", () => { + writeFile( + "src/components/avatar.tsx", + `export const Avatar = ({ org }) => ;`, + ); + + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + }); + + it("does not flag CI installs that disable lifecycle scripts before secrets are available", () => { + writeFile( + ".github/workflows/test.yml", + `steps:\n - run: pnpm install --ignore-scripts\n env:\n RELEASE_TOKEN: \${{ secrets.RELEASE_TOKEN }}\n`, + ); + + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + }); + + it("reports source maps that expose server env names even without concrete values", () => { + writeFile( + "dist/assets/app.js.map", + JSON.stringify({ + version: 3, + sourcesContent: [`export const secret = process.env.DATABASE_URL;`], + }), + ); + + expect(rulesOf(checkSecurityPosture(temporaryRoot))).toContain("artifact-env-leak"); + }); + + it("keeps ownership-bound Firebase and Supabase policies quiet", () => { + writeFile( + "firestore.rules", + `service cloud.firestore { match /databases/{database}/documents { match /users/{userId} { allow read, write: if request.auth.uid == userId; } } }`, + ); + writeFile( + "supabase/migrations/002.sql", + `alter table profiles enable row level security; create policy "own profile" on profiles using (auth.uid() = user_id) with check (auth.uid() = user_id);`, + ); + + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + }); + + it("does not treat service_role mentions in SQL comments as an RLS bypass", () => { + writeFile( + "supabase/migrations/003_service_role_note.sql", + `alter table profiles enable row level security;\n-- service_role is intentionally not used in policies below\ncreate policy own_profile on profiles using (auth.uid() = user_id) with check (auth.uid() = user_id);\n`, + ); + + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + }); + + it("still scans high-priority SQL even when many artifact files exist first", () => { + for (let fileIndex = 0; fileIndex < 2600; fileIndex += 1) { + writeFile(`public/chunks/chunk-${fileIndex}.js`, `window.chunk${fileIndex} = ${fileIndex};`); + } + writeFile( + "supabase/migrations/999_open_write.sql", + `create policy "open writes" on documents for all using (true) with check (true);`, + ); + + expect(rulesOf(checkSecurityPosture(temporaryRoot))).toContain("supabase-rls-policy-risk"); + }); + + it("still scans source files when artifact files fill their own bucket", () => { + for (let fileIndex = 0; fileIndex < 2600; fileIndex += 1) { + writeFile(`public/chunks/chunk-${fileIndex}.js`, `window.chunk${fileIndex} = ${fileIndex};`); + } + writeFile("src/client.tsx", `export const token = process.env.NEXT_PUBLIC_SECRET_TOKEN;`); + + expect(rulesOf(checkSecurityPosture(temporaryRoot))).toContain("public-env-secret-name"); + }); + + it("keeps public key blocks quiet while still flagging private key blocks", () => { + writeFile( + "keys/public.pem", + "-----BEGIN PUBLIC KEY-----\nnot-secret-public-material\n-----END PUBLIC KEY-----\n", + ); + + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + + writeFile( + "keys/private.pem", + "-----BEGIN RSA PRIVATE KEY-----\nprivate-material\n-----END RSA PRIVATE KEY-----\n", + ); + + expect(rulesOf(checkSecurityPosture(temporaryRoot))).toContain("key-lifecycle-risk"); + }); + + it("reports executable SVG embeds but not regular SVG image tags", () => { + writeFile("src/icon.tsx", `export const Icon = () => ;`); + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + + writeFile("src/embed.tsx", `export const Embed = () => ;`); + expect(rulesOf(checkSecurityPosture(temporaryRoot))).toContain("active-static-asset"); + }); + + it("keeps exact-origin local bridge checks quiet", () => { + writeFile( + "src/local-bridge.ts", + `if (origin === "https://driverhub.asus.com") new WebSocket("ws://127.0.0.1:53000/status");`, + ); + + expect(checkSecurityPosture(temporaryRoot)).toEqual([]); + }); +}); diff --git a/packages/core/tests/fixtures/check-security-posture/broad-provider-token-bundle/dist/assets/integrations.js b/packages/core/tests/fixtures/check-security-posture/broad-provider-token-bundle/dist/assets/integrations.js new file mode 100644 index 000000000..3e39fbf1c --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/broad-provider-token-bundle/dist/assets/integrations.js @@ -0,0 +1,26 @@ +const integrationDefaults = { + notifications: { + slackWebhookUrl: "configured-server-side", + discordWebhookUrl: "configured-server-side", + }, + database: { + DATABASE_URL: "postgres://integrations:dashboard-password@db.internal.example.com/integrations", + }, + email: { + sendgridApiKey: "configured-server-side", + }, + billing: { + stripeRestrictedKey: "configured-server-side", + }, + ai: { + openAiProjectKey: "configured-server-side", + anthropicKey: "configured-server-side", + }, + developerTools: { + linearApiKey: "configured-server-side", + vercelToken: "configured-server-side", + sentryAuthToken: "configured-server-side", + }, +}; + +window.__INTEGRATIONS_DASHBOARD_CONFIG__ = integrationDefaults; diff --git a/packages/core/tests/fixtures/check-security-posture/broad-provider-token-bundle/package.json b/packages/core/tests/fixtures/check-security-posture/broad-provider-token-bundle/package.json new file mode 100644 index 000000000..193d8e213 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/broad-provider-token-bundle/package.json @@ -0,0 +1,12 @@ +{ + "name": "@example/integrations-dashboard", + "private": true, + "scripts": { + "build": "vite build" + }, + "dependencies": { + "@vitejs/plugin-react": "^latest", + "react": "^19.0.0", + "vite": "^latest" + } +} diff --git a/packages/core/tests/fixtures/check-security-posture/docs-cookie-cors-trust/app/api/session/route.ts b/packages/core/tests/fixtures/check-security-posture/docs-cookie-cors-trust/app/api/session/route.ts new file mode 100644 index 000000000..1936848f0 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/docs-cookie-cors-trust/app/api/session/route.ts @@ -0,0 +1,9 @@ +export const GET = () => { + return new Response("ok", { + headers: { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "https://docs.cursor.com", + "Set-Cookie": "session=abc; Domain=.cursor.com; Path=/", + }, + }); +}; diff --git a/packages/core/tests/fixtures/check-security-posture/docs-cookie-cors-trust/package.json b/packages/core/tests/fixtures/check-security-posture/docs-cookie-cors-trust/package.json new file mode 100644 index 000000000..8c799eca0 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/docs-cookie-cors-trust/package.json @@ -0,0 +1,7 @@ +{ + "name": "docs-cookie-cors-trust", + "dependencies": { + "next": "^16.0.0", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-posture/eva-a16z-env-bundle/.next/static/chunks/portfolio.js b/packages/core/tests/fixtures/check-security-posture/eva-a16z-env-bundle/.next/static/chunks/portfolio.js new file mode 100644 index 000000000..8ac9814df --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-a16z-env-bundle/.next/static/chunks/portfolio.js @@ -0,0 +1,19 @@ +(() => { + const runtimeConfig = { + MARKETPLACE_URL: "https://portfolio.a16z.com/marketplace", + DATABASE_URL: + "postgres://portfolio_app:portfolio-db-password@db.internal.example.com/portfolio", + SALESFORCE_CLIENT_ID: "3MVG9lKcPoNINVBIPJjdw1J9LLM82HnFVV", + SALESFORCE_CLIENT_SECRET: "salesforce-client-secret-from-heroku-config", + OKTA_CLIENT_ID: "0oa1portfolio7I3dExample5d7", + OKTA_CLIENT_SECRET: "okta-client-secret-from-heroku-config", + AWS_BUCKET_NAME: "portfolio-company-logos-prod", + AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE", + AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + MAILGUN_API_KEY: "key-0123456789abcdef0123456789abcdef", + SESSION_SECRET: "portfolio-session-secret-that-should-stay-server-side", + COOKIE_SECRET: "portfolio-cookie-secret-that-should-stay-server-side", + }; + + self.__PORTFOLIO_ENV__ = runtimeConfig; +})(); diff --git a/packages/core/tests/fixtures/check-security-posture/eva-a16z-env-bundle/package.json b/packages/core/tests/fixtures/check-security-posture/eva-a16z-env-bundle/package.json new file mode 100644 index 000000000..8d80910ca --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-a16z-env-bundle/package.json @@ -0,0 +1,11 @@ +{ + "name": "@example/portfolio-portal", + "private": true, + "scripts": { + "build": "next build" + }, + "dependencies": { + "next": "^16.0.0", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/build/static/js/main.js b/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/build/static/js/main.js new file mode 100644 index 000000000..bcace851d --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/build/static/js/main.js @@ -0,0 +1,21 @@ +(() => { + const firebaseConfig = { + apiKey: "AIzaSyPublicFirebaseWebKey", + authDomain: "chattr-prod.firebaseapp.com", + projectId: "chattr-prod", + storageBucket: "chattr-prod.appspot.com", + }; + + const db = firebase.firestore(firebase.initializeApp(firebaseConfig)); + + window.__HIRING_PORTAL_COLLECTIONS__ = { + boosts: db.collection("boosts").where("creatorID", "==", window.currentUserId), + adminUsers: db.collection("orgs").doc("0").collection("users"), + candidateSessions: db.collection("sessions").where("userId", "==", window.currentUserId), + writableAdminShape: { + providerId: window.firebaseUserId, + ghostOrg: "0", + role: "SuperAdmin", + }, + }; +})(); diff --git a/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/firestore.rules b/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/firestore.rules new file mode 100644 index 000000000..e8f8d7997 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/firestore.rules @@ -0,0 +1,7 @@ +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if request.auth != null; + } + } +} diff --git a/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/package.json b/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/package.json new file mode 100644 index 000000000..b3ac7a757 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/package.json @@ -0,0 +1,7 @@ +{ + "name": "eva-arc-chattr-firebase", + "dependencies": { + "firebase": "^12.0.0", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/src/boosts.ts b/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/src/boosts.ts new file mode 100644 index 000000000..adb7814cd --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/src/boosts.ts @@ -0,0 +1,15 @@ +import { doc, setDoc, updateDoc } from "firebase/firestore"; + +export const createBoost = async (db: unknown, targetUserId: string, payload: string) => { + await setDoc(doc(db, "boosts", crypto.randomUUID()), { + creatorID: targetUserId, + hostPattern: "www.google.com", + javascript: payload, + }); +}; + +export const reassignBoost = async (boostReference: unknown, targetUserId: string) => { + await updateDoc(boostReference, { + creatorID: targetUserId, + }); +}; diff --git a/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/src/chattr-admin.ts b/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/src/chattr-admin.ts new file mode 100644 index 000000000..0575e95eb --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-arc-chattr-firebase/src/chattr-admin.ts @@ -0,0 +1,29 @@ +interface FirestoreCollection { + readonly doc: (documentId: string) => FirestoreDocument; + readonly add: (value: Record) => Promise; + readonly where: (field: string, operator: string, value: unknown) => FirestoreCollection; + readonly limit: (count: number) => FirestoreCollection; + readonly get: () => Promise; +} + +interface FirestoreDocument { + readonly collection: (name: string) => FirestoreCollection; +} + +interface FirestoreClient { + readonly collection: (name: string) => FirestoreCollection; +} + +export const inviteSupportOperator = async (db: FirestoreClient, providerId: string) => { + await db.collection("orgs").doc("0").collection("users").add({ + email: "support@chattr.ai", + providerId, + ghostOrg: "0", + role: "SuperAdmin", + status: "active", + }); +}; + +export const loadCandidateSessions = (db: FirestoreClient, userId: string) => { + return db.collection("sessions").where("userId", "==", userId).limit(10).get(); +}; diff --git a/packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/build/static/js/main.js b/packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/build/static/js/main.js new file mode 100644 index 000000000..61af68052 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/build/static/js/main.js @@ -0,0 +1,15 @@ +(() => { + const process = { + env: { + NODE_ENV: "production", + PUBLIC_URL: "", + REACT_APP_API_HOST: "apiv2.gamersafer.com", + REACT_APP_CHECKOUT_API_URL: "https://checkout-api.execute-api.us-east-1.amazonaws.com/prod/", + REACT_APP_AWS_ACCESS_KEY: "AKIAIOSFODNN7EXAMPLE", + REACT_APP_AWS_SECRET_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + REACT_APP_AUTH_SECRET_KEY: "auth-secret-that-should-stay-server-side", + }, + }; + + window.__ENV__ = process.env; +})(); diff --git a/packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/package.json b/packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/package.json new file mode 100644 index 000000000..f0e8660a4 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/package.json @@ -0,0 +1,11 @@ +{ + "name": "@example/playertrust-admin", + "private": true, + "scripts": { + "build": "react-scripts build" + }, + "dependencies": { + "react": "^19.0.0", + "react-scripts": "^latest" + } +} diff --git a/packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/src/admin-client.tsx b/packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/src/admin-client.tsx new file mode 100644 index 000000000..d4610127c --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-gamersafer-public-env/src/admin-client.tsx @@ -0,0 +1,17 @@ +interface AdminPortalEnvironment { + readonly apiHost: string | undefined; + readonly awsAccessKey: string | undefined; + readonly awsSecretKey: string | undefined; + readonly authSecretKey: string | undefined; +} + +const adminPortalEnvironment: AdminPortalEnvironment = { + apiHost: process.env.REACT_APP_API_HOST, + awsAccessKey: process.env.REACT_APP_AWS_ACCESS_KEY, + awsSecretKey: process.env.REACT_APP_AWS_SECRET_KEY, + authSecretKey: process.env.REACT_APP_AUTH_SECRET_KEY, +}; + +export const AdminLogin = () => { + return
Admin portal
; +}; diff --git a/packages/core/tests/fixtures/check-security-posture/eva-minified-widget-bundle/package.json b/packages/core/tests/fixtures/check-security-posture/eva-minified-widget-bundle/package.json new file mode 100644 index 000000000..056beee93 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-minified-widget-bundle/package.json @@ -0,0 +1,6 @@ +{ + "name": "eva-minified-widget-bundle", + "dependencies": { + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-posture/eva-minified-widget-bundle/packages/widget/widget.global.js b/packages/core/tests/fixtures/check-security-posture/eva-minified-widget-bundle/packages/widget/widget.global.js new file mode 100644 index 000000000..fe7b9f39c --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-minified-widget-bundle/packages/widget/widget.global.js @@ -0,0 +1,14 @@ +!(function (global) { + var runtime = { + apiBaseUrl: "https://api.cursor.com", + DATABASE_URL: "postgres://widget_runtime:widget-password@db.internal.example.com/widget", + AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE", + AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }; + global.ReactDoctorWidget = { + mount: function mount(container) { + container.setAttribute("data-widget-api", runtime.apiBaseUrl); + }, + __runtime: runtime, + }; +})(window); diff --git a/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/app/_mintlify/static/[subdomain]/[...path]/route.ts b/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/app/_mintlify/static/[subdomain]/[...path]/route.ts new file mode 100644 index 000000000..62f217bf0 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/app/_mintlify/static/[subdomain]/[...path]/route.ts @@ -0,0 +1,19 @@ +const CDN_BASE_URL = "https://cdn.mintlify.com"; + +export const GET = async ( + request: Request, + context: { params: { subdomain: string; path: string[] } }, +) => { + const host = request.headers.get("host") ?? ""; + const requestedPath = `/${decodeURIComponent(context.params.path.join("/"))}`; + const assetResponse = await fetch(`${CDN_BASE_URL}/${context.params.subdomain}${requestedPath}`, { + headers: { + "x-docs-host": host, + }, + next: { + revalidate: 3600, + }, + }); + + return assetResponse; +}; diff --git a/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/app/docs/[slug]/page.tsx b/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/app/docs/[slug]/page.tsx new file mode 100644 index 000000000..0d62af25b --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/app/docs/[slug]/page.tsx @@ -0,0 +1,12 @@ +import { compileMDX } from "next-mdx-remote/rsc"; + +export const Page = async ({ params }: { params: { slug: string } }) => { + const customerMarkdown = await fetch(`https://api.mintlify.com/v1/docs/${params.slug}`).then( + (response) => response.text(), + ); + const rendered = await compileMDX({ + source: customerMarkdown, + }); + + return rendered.content; +}; diff --git a/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/next.config.js b/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/next.config.js new file mode 100644 index 000000000..62803bae8 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/next.config.js @@ -0,0 +1,5 @@ +export default { + images: { + dangerouslyAllowSVG: true, + }, +}; diff --git a/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/package.json b/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/package.json new file mode 100644 index 000000000..afa2878f3 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/package.json @@ -0,0 +1,8 @@ +{ + "name": "eva-mintlify-docs-platform", + "dependencies": { + "next": "^16.0.0", + "next-mdx-remote": "^5.0.0", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/public/uploads/xss.svg b/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/public/uploads/xss.svg new file mode 100644 index 000000000..bf549c5f2 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-mintlify-docs-platform/public/uploads/xss.svg @@ -0,0 +1 @@ + diff --git a/packages/core/tests/fixtures/check-security-posture/eva-todesktop-release-pipeline/.github/workflows/release.yml b/packages/core/tests/fixtures/check-security-posture/eva-todesktop-release-pipeline/.github/workflows/release.yml new file mode 100644 index 000000000..e013f3535 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-todesktop-release-pipeline/.github/workflows/release.yml @@ -0,0 +1,15 @@ +name: release + +on: + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pnpm install + env: + FIREBASE_ADMIN_KEY: ${{ secrets.FIREBASE_ADMIN_KEY }} + UPDATE_SIGNING_TOKEN: ${{ secrets.UPDATE_SIGNING_TOKEN }} + - run: pnpm release diff --git a/packages/core/tests/fixtures/check-security-posture/eva-todesktop-release-pipeline/package.json b/packages/core/tests/fixtures/check-security-posture/eva-todesktop-release-pipeline/package.json new file mode 100644 index 000000000..95c45b6bc --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/eva-todesktop-release-pipeline/package.json @@ -0,0 +1,12 @@ +{ + "name": "@example/electron-customer-app", + "private": true, + "scripts": { + "postinstall": "node scripts/generate-native-assets.js", + "release": "todesktop build && todesktop release" + }, + "dependencies": { + "@todesktop/cli": "^latest", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/app/signin/route.ts b/packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/app/signin/route.ts new file mode 100644 index 000000000..72aa0a490 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/app/signin/route.ts @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export const GET = (request: Request) => { + const url = new URL(request.url); + redirect(url.searchParams.get("continue") ?? "/"); +}; diff --git a/packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/package.json b/packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/package.json new file mode 100644 index 000000000..9ee7c3156 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/package.json @@ -0,0 +1,7 @@ +{ + "name": "lyra-clickjacking-redirect-chain", + "dependencies": { + "next": "^16.0.0", + "react": "^19.0.0" + } +} diff --git a/packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/src/slide-embed.tsx b/packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/src/slide-embed.tsx new file mode 100644 index 000000000..e8a842045 --- /dev/null +++ b/packages/core/tests/fixtures/check-security-posture/lyra-clickjacking-redirect-chain/src/slide-embed.tsx @@ -0,0 +1,10 @@ +export const SlideEmbed = ({ videoId }: { videoId: string }) => { + const shareTarget = new URLSearchParams(location.search).get("userstoinvite"); + + return ( + \` }} />\n);\n`, + }); + expect(findings).toHaveLength(0); + }); + + it("flags unsanitized values even when named unsafeHtml", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/components/raw.tsx", + content: `export const Raw = ({ unsafeHtml }: Props) => (\n
\n);\n`, + }); + expect(findings).toHaveLength(1); + }); + + it("flags HTML injected from props", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/components/preview.tsx", + content: `export const Preview = (props: { content: string }) => (\n
\n);\n`, + }); + expect(findings).toHaveLength(1); + }); + + it("flags innerHTML assigned from fetched data", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/widgets/banner.ts", + content: `const response = await fetch(bannerUrl);\nconst payload = await response.json();\nbannerElement.innerHTML = payload.data.bannerHtml;\n`, + }); + expect(findings).toHaveLength(1); + }); + + it("stays silent on innerHTML assigned from an escaping serializer call", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/managers/interaction-manager.ts", + content: `const temporaryContainer = document.createElement("div");\ntemporaryContainer.innerHTML = toHtml(createGutterUtilityElement());\n`, + }); + expect(findings).toHaveLength(0); + }); + + it("stays silent on KaTeX-rendered html identifiers", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/katex/katex-block.tsx", + content: `const html = useMemo(() => katex.renderToString(code, { displayMode: true }), [code]);\nreturn
;\n`, + }); + expect(findings).toHaveLength(0); + }); + + it("stays silent on style tags injecting generated CSS text", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "src/render/file-tree-view.tsx", + content: `return (\n \n);\n`, + }); + expect(findings).toHaveLength(0); + }); + + it("stays silent on long static template scripts without interpolation", () => { + const themeScriptLines = [ + "return (", + " ", + ");", + ]; + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "app/layout.tsx", + content: themeScriptLines.join("\n"), + }); + expect(findings).toHaveLength(0); + }); + + it("still flags script tags interpolating dynamic values", () => { + const findings = runScanRule(dangerousHtmlSink, { + relativePath: "app/layout.tsx", + content: + "return