From 4b53ef734584dfb3fd0bf62b3bbbf1038330d5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Thu, 5 Mar 2026 02:11:22 +0100 Subject: [PATCH] feat: more security rules and DX - Add hardcoded secret detection (Stripe, generic API keys) - Add framework-specific security profiles - Use runtime-generated test fixtures to avoid secret scanner false positives Made-with: Cursor --- README.md | 47 +++ package.json | 2 +- packages/angular-doctor/CHANGELOG.md | 6 + packages/angular-doctor/package.json | 2 +- packages/angular-doctor/src/scan.ts | 2 +- .../src/utils/run-security-scan.ts | 16 +- packages/cli/CHANGELOG.md | 11 + packages/cli/package.json | 2 +- packages/cli/src/cli.ts | 328 +++++++++++------- packages/core/package.json | 6 +- packages/core/src/index.ts | 9 +- packages/core/src/security/constants.ts | 30 ++ .../core/src/security/framework-profiles.ts | 128 +++++++ .../core/src/security/get-files-to-scan.ts | 17 +- .../src/security/hardcoded-secret-rules.ts | 49 +++ packages/core/src/security/index.ts | 4 + packages/core/src/security/rule.ts | 1 + .../src/security/run-project-security-scan.ts | 312 +++++++++++++++++ .../core/src/security/run-security-scan.ts | 26 +- .../tests/run-project-security-scan.test.ts | 147 ++++++++ packages/core/tests/run-security-scan.test.ts | 44 +++ packages/core/vitest.config.ts | 7 + packages/react-doctor/CHANGELOG.md | 6 + packages/react-doctor/package.json | 2 +- packages/react-doctor/src/index.ts | 2 +- packages/react-doctor/src/scan.ts | 2 +- .../src/utils/run-security-scan.ts | 22 +- packages/svelte-doctor/CHANGELOG.md | 6 + packages/svelte-doctor/package.json | 2 +- packages/svelte-doctor/src/scan.ts | 6 +- .../src/utils/run-security-scan.ts | 21 +- packages/vue-doctor/CHANGELOG.md | 6 + packages/vue-doctor/package.json | 2 +- packages/vue-doctor/src/scan.ts | 2 +- .../vue-doctor/src/utils/run-security-scan.ts | 17 +- pnpm-lock.yaml | 3 + 36 files changed, 1142 insertions(+), 153 deletions(-) create mode 100644 packages/core/src/security/constants.ts create mode 100644 packages/core/src/security/framework-profiles.ts create mode 100644 packages/core/src/security/hardcoded-secret-rules.ts create mode 100644 packages/core/src/security/run-project-security-scan.ts create mode 100644 packages/core/tests/run-project-security-scan.test.ts create mode 100644 packages/core/tests/run-security-scan.test.ts create mode 100644 packages/core/vitest.config.ts diff --git a/README.md b/README.md index 1642e57..2719414 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,52 @@ Framework Doctor auto-detects your framework and runs the right health check. Supports **Svelte**, **React**, **Vue**, and **Angular**. +## Why this project exists + +Framework teams usually rely on separate tools for linting, dead-code cleanup, dependency audits, and framework-specific checks. That creates fragmented output and slow feedback loops, especially in monorepos. + +Framework Doctor was built to unify that workflow into one command: + +- detect the framework automatically +- run the most relevant checks for that framework +- normalize findings into one diagnostic model +- compute a simple, comparable 0-100 score + +The goal is pragmatic: give teams one health snapshot they can run locally, in PRs, or in CI without wiring several tools manually. + +## Architecture overview + +This repository is a TypeScript monorepo organized into framework-specific doctors plus shared core logic. + +- `packages/core` + - shared scoring engine, shared diagnostic types, UI output helpers, security scan primitives, and telemetry helpers +- `packages/cli` + - unified entrypoint (`framework-doctor`) that detects or accepts a forced framework and dispatches to the matching doctor +- `packages/react-doctor`, `packages/vue-doctor`, `packages/svelte-doctor`, `packages/angular-doctor` + - framework adapters that run framework-relevant checks, map results to normalized diagnostics, and render output consistently +- `examples/` + - intentionally flawed sample apps for each framework +- `supabase/` + - optional telemetry backend schema and function + +Each framework doctor follows the same high-level pipeline: + +1. Discover project(s) and load config. +2. Run selected checks (lint/type/framework/security/dead-code/audit). +3. Normalize findings into common diagnostic fields. +4. Compute score with penalties by severity and volume. +5. Print text or JSON output and optionally emit telemetry. + +## Feature highlights + +- unified CLI with auto-detection and watch mode +- forced framework selection via `--framework ` +- monorepo project targeting with `--project` +- diff-only scanning via `--diff [base]` +- CI-friendly machine output with `--format json` and `--score` +- framework-aware checks plus common security and dependency audit checks +- optional, anonymous telemetry routed to your own Supabase endpoint + ## Quick start Run in a project root (auto-detects Svelte, React, Vue, Angular from `package.json`): @@ -42,6 +88,7 @@ See [examples/README.md](examples/README.md) for more demo projects and commands - `npx -y @framework-doctor/cli .` - auto-detect framework and run the right doctor - `npx -y @framework-doctor/cli ./path/to/project` - scan a specific project directory - `npx -y @framework-doctor/cli . --watch` - re-scan on file changes +- `npx -y @framework-doctor/cli . --framework react` - force a framework when detection is ambiguous or unavailable **React (direct):** diff --git a/package.json b/package.json index 9a9434f..f805cd2 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "typecheck": "turbo run typecheck --filter=./packages/*", "test": "turbo run test --filter=./packages/*", "test:doctor": "turbo run test --filter=@framework-doctor/svelte --concurrency=1", - "quality:check": "turbo run format:check lint typecheck test --filter=!./examples/*", + "quality:check": "turbo run format:check lint typecheck test --filter=./packages/*", "changeset": "changeset", "version": "changeset version", "release": "pnpm build && changeset publish", diff --git a/packages/angular-doctor/CHANGELOG.md b/packages/angular-doctor/CHANGELOG.md index 4a5e98c..25d1a84 100644 --- a/packages/angular-doctor/CHANGELOG.md +++ b/packages/angular-doctor/CHANGELOG.md @@ -1,5 +1,11 @@ # @framework-doctor/angular +## 1.1.1 + +### Patch Changes + +- included more security rules + ## 1.1.0 ### Minor Changes diff --git a/packages/angular-doctor/package.json b/packages/angular-doctor/package.json index 204368d..3cec575 100644 --- a/packages/angular-doctor/package.json +++ b/packages/angular-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/angular", - "version": "1.1.0", + "version": "1.1.1", "description": "Diagnose Angular codebase health", "author": { "name": "Pitis Radu", diff --git a/packages/angular-doctor/src/scan.ts b/packages/angular-doctor/src/scan.ts index 0601899..91f8e18 100644 --- a/packages/angular-doctor/src/scan.ts +++ b/packages/angular-doctor/src/scan.ts @@ -315,7 +315,7 @@ export const scan = async ( : Promise.resolve([]); const securityPromise = options.lint - ? runSecurityScan(directory, includePaths) + ? runSecurityScan(directory, includePaths, 'angular') : Promise.resolve([]); const [lintDiagnostics, deadCodeDiagnostics, securityDiagnostics] = await Promise.all([ diff --git a/packages/angular-doctor/src/utils/run-security-scan.ts b/packages/angular-doctor/src/utils/run-security-scan.ts index 74339ef..a27256e 100644 --- a/packages/angular-doctor/src/utils/run-security-scan.ts +++ b/packages/angular-doctor/src/utils/run-security-scan.ts @@ -1,6 +1,9 @@ import { + getFrameworkProfile, + HARDCODED_SECRET_RULES, NO_BYPASS_SECURITY_TRUST_RULE, NO_INNER_HTML_BINDING_RULE, + runProjectSecurityScan, runSecurityScan as runSecurityScanCore, SOURCE_FILE_PATTERN_WITH_ANGULAR, UNIVERSAL_SECURITY_RULES, @@ -10,13 +13,22 @@ const ANGULAR_PLUGIN = 'angular-doctor'; const ANGULAR_SECURITY_RULES = [ ...UNIVERSAL_SECURITY_RULES, + ...HARDCODED_SECRET_RULES, NO_INNER_HTML_BINDING_RULE, NO_BYPASS_SECURITY_TRUST_RULE, ]; -export const runSecurityScan = async (rootDirectory: string, includePaths: string[]) => - runSecurityScanCore(rootDirectory, includePaths, { +export const runSecurityScan = async ( + rootDirectory: string, + includePaths: string[], + framework?: string, +) => { + const regexDiagnostics = await runSecurityScanCore(rootDirectory, includePaths, { plugin: ANGULAR_PLUGIN, rules: ANGULAR_SECURITY_RULES, filePattern: SOURCE_FILE_PATTERN_WITH_ANGULAR, }); + const profile = framework ? getFrameworkProfile(ANGULAR_PLUGIN, framework) : null; + const projectDiagnostics = profile ? runProjectSecurityScan(rootDirectory, profile) : []; + return [...regexDiagnostics, ...projectDiagnostics]; +}; diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 137d3e2..2f80b6c 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,16 @@ # @framework-doctor/cli +## 1.1.1 + +### Patch Changes + +- included more security rules +- Updated dependencies + - @framework-doctor/angular@1.1.1 + - @framework-doctor/svelte@1.1.1 + - @framework-doctor/react@1.1.1 + - @framework-doctor/vue@1.1.1 + ## 1.1.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index cc5c551..5b67f6e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/cli", - "version": "1.1.0", + "version": "1.1.1", "description": "Auto-detect framework and run the right doctor", "author": { "name": "Pitis Radu", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 594a62a..5207d28 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,127 +1,192 @@ +import type { FSWatcher } from 'chokidar'; import chokidar from 'chokidar'; import { spawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; import path from 'node:path'; import { WATCH_DEBOUNCE_MS } from './constants.js'; -type Framework = 'svelte' | 'react' | 'vue' | 'angular' | null; +type Framework = 'svelte' | 'react' | 'vue' | 'angular'; -const detectFramework = (directory: string): Framework => { - const packagePath = path.join(directory, 'package.json'); +interface FrameworkDefinition { + doctorPackage: string; + frameworkLabel: string; + dependencyIndicators: string[]; +} + +interface FrameworkDetectionResult { + detectedFramework: Framework | null; + matchedFrameworks: Framework[]; +} + +interface ParsedCliArguments { + directoryPath: string; + doctorArguments: string[]; + shouldWatch: boolean; + forcedFramework: Framework | null; +} + +const FRAMEWORK_DEFINITIONS: Record = { + svelte: { + doctorPackage: '@framework-doctor/svelte', + frameworkLabel: 'Svelte', + dependencyIndicators: ['svelte', '@sveltejs/kit'], + }, + react: { + doctorPackage: '@framework-doctor/react', + frameworkLabel: 'React', + dependencyIndicators: ['react', 'next', 'remix'], + }, + vue: { + doctorPackage: '@framework-doctor/vue', + frameworkLabel: 'Vue', + dependencyIndicators: ['vue', 'nuxt'], + }, + angular: { + doctorPackage: '@framework-doctor/angular', + frameworkLabel: 'Angular', + dependencyIndicators: ['@angular/core'], + }, +}; + +const readPackageDependencies = (directoryPath: string): Record | null => { + const packagePath = path.join(directoryPath, 'package.json'); try { const require = createRequire(import.meta.url); - const pkg = require(packagePath) as Record; - const deps = { - ...(pkg.dependencies as Record), - ...(pkg.devDependencies as Record), - ...(pkg.peerDependencies as Record), - } as Record; - - if (deps.svelte || deps['@sveltejs/kit']) return 'svelte'; - if (deps.react || deps['next'] || deps['remix']) return 'react'; - if (deps.vue || deps['nuxt']) return 'vue'; - if (deps['@angular/core']) return 'angular'; - - return null; + const packageJson = require(packagePath) as Record; + return { + ...(packageJson.dependencies as Record), + ...(packageJson.devDependencies as Record), + ...(packageJson.peerDependencies as Record), + }; } catch { return null; } }; +const detectFramework = (directoryPath: string): FrameworkDetectionResult => { + const dependencyMap = readPackageDependencies(directoryPath); + if (!dependencyMap) { + return { detectedFramework: null, matchedFrameworks: [] }; + } + + const matchedFrameworks = (Object.keys(FRAMEWORK_DEFINITIONS) as Framework[]).filter( + (frameworkName) => + FRAMEWORK_DEFINITIONS[frameworkName].dependencyIndicators.some( + (dependencyName) => dependencyMap[dependencyName] !== undefined, + ), + ); + + if (matchedFrameworks.length !== 1) { + return { detectedFramework: null, matchedFrameworks }; + } + + return { + detectedFramework: matchedFrameworks[0], + matchedFrameworks, + }; +}; + const resolveDoctorCli = (pkg: string): string => { const require = createRequire(import.meta.url); const indexPath = require.resolve(pkg); return path.join(path.dirname(indexPath), 'cli.js'); }; -const runDoctor = (framework: Framework, args: string[]): number => { +const runDoctor = (framework: Framework, doctorArguments: string[]): number => { const cwd = process.cwd(); - const dirArg = args[0] ?? cwd; - const restArgs = args[0] ? args.slice(1) : args; - - if (framework === 'svelte') { - const cliPath = resolveDoctorCli('@framework-doctor/svelte'); - const fullArgs = [dirArg, ...restArgs]; - const result = spawnSync(process.execPath, [cliPath, ...fullArgs], { - stdio: 'inherit', - cwd, - }); - return result.status ?? 1; - } + const frameworkDefinition = FRAMEWORK_DEFINITIONS[framework]; + const cliPath = resolveDoctorCli(frameworkDefinition.doctorPackage); + const result = spawnSync(process.execPath, [cliPath, ...doctorArguments], { + stdio: 'inherit', + cwd, + }); + return result.status ?? 1; +}; - if (framework === 'react') { - const cliPath = resolveDoctorCli('@framework-doctor/react'); - const fullArgs = [dirArg, ...restArgs]; - const result = spawnSync(process.execPath, [cliPath, ...fullArgs], { - stdio: 'inherit', - cwd, - }); - return result.status ?? 1; - } +const isFramework = (frameworkValue: string): frameworkValue is Framework => + (Object.keys(FRAMEWORK_DEFINITIONS) as Framework[]).includes(frameworkValue as Framework); - if (framework === 'vue') { - const cliPath = resolveDoctorCli('@framework-doctor/vue'); - const fullArgs = [dirArg, ...restArgs]; - const result = spawnSync(process.execPath, [cliPath, ...fullArgs], { - stdio: 'inherit', - cwd, - }); - return result.status ?? 1; +const parseFrameworkArgument = (rawArguments: string[]): string | null => { + for (let argumentIndex = 0; argumentIndex < rawArguments.length; argumentIndex += 1) { + const currentArgument = rawArguments[argumentIndex]; + if (currentArgument === '--framework') { + return rawArguments[argumentIndex + 1] ?? null; + } + if (currentArgument.startsWith('--framework=')) { + return currentArgument.slice('--framework='.length); + } } + return null; +}; - if (framework === 'angular') { - const cliPath = resolveDoctorCli('@framework-doctor/angular'); - const fullArgs = [dirArg, ...restArgs]; - const result = spawnSync(process.execPath, [cliPath, ...fullArgs], { - stdio: 'inherit', - cwd, - }); - return result.status ?? 1; - } +const parseCliArguments = (rawArguments: string[]): ParsedCliArguments => { + const forcedFrameworkValue = parseFrameworkArgument(rawArguments); + const forcedFramework = + forcedFrameworkValue && isFramework(forcedFrameworkValue) ? forcedFrameworkValue : null; - console.error(` - Could not detect a supported framework in ${dirArg}. + const shouldWatch = rawArguments.includes('--watch') || rawArguments.includes('-w'); - Supported: Svelte, React, Vue, Angular + const argumentsWithoutCliFlags: string[] = []; + for (let argumentIndex = 0; argumentIndex < rawArguments.length; argumentIndex += 1) { + const currentArgument = rawArguments[argumentIndex]; + if (currentArgument === '--watch' || currentArgument === '-w') { + continue; + } + if (currentArgument === '--framework') { + argumentIndex += 1; + continue; + } + if (currentArgument.startsWith('--framework=')) { + continue; + } + argumentsWithoutCliFlags.push(currentArgument); + } - Make sure you're in a project root with a package.json that includes: - - Svelte: "svelte" or "@sveltejs/kit" - - React: "react", "next", or "remix" - - Vue: "vue" or "nuxt" - - Angular: "@angular/core" + const directoryIndex = argumentsWithoutCliFlags.findIndex( + (currentArgument) => !currentArgument.startsWith('-'), + ); + const directoryPath = + directoryIndex >= 0 + ? path.resolve(process.cwd(), argumentsWithoutCliFlags[directoryIndex]) + : process.cwd(); + const doctorOptions = + directoryIndex >= 0 + ? [ + ...argumentsWithoutCliFlags.slice(0, directoryIndex), + ...argumentsWithoutCliFlags.slice(directoryIndex + 1), + ] + : argumentsWithoutCliFlags; - Or run a specific doctor directly: - - npx @framework-doctor/svelte . - - npx @framework-doctor/react . - - npx @framework-doctor/vue . -`); - return 1; + return { + directoryPath, + doctorArguments: [directoryPath, ...doctorOptions], + shouldWatch, + forcedFramework, + }; }; -const filterWatchArgs = (args: string[]): { args: string[]; watch: boolean } => { - const watchIndex = args.findIndex((a) => a === '--watch' || a === '-w'); - const watch = watchIndex >= 0; - const argsWithoutWatch = - watchIndex >= 0 ? [...args.slice(0, watchIndex), ...args.slice(watchIndex + 1)] : args; - return { args: argsWithoutWatch, watch }; +const printUnsupportedFrameworkValue = (frameworkValue: string): void => { + const supportedFrameworkNames = (Object.keys(FRAMEWORK_DEFINITIONS) as Framework[]).join(', '); + console.error( + `Unsupported framework "${frameworkValue}". Supported values: ${supportedFrameworkNames}.`, + ); }; -const main = (): number => { - const rawArgs = process.argv.slice(2); - const { args: processedArgs, watch } = filterWatchArgs(rawArgs); - const dirIndex = processedArgs.findIndex((a) => !a.startsWith('-')); - const dirArg = - dirIndex >= 0 ? path.resolve(process.cwd(), processedArgs[dirIndex]) : process.cwd(); - const restArgs = - dirIndex >= 0 - ? [...processedArgs.slice(0, dirIndex), ...processedArgs.slice(dirIndex + 1)] - : processedArgs; - const doctorArgs = restArgs.length > 0 ? [dirArg, ...restArgs] : [dirArg]; - - const framework = detectFramework(dirArg); - if (!framework) { - console.error(` - Could not detect a supported framework in ${dirArg}. +const printFrameworkDetectionError = ( + directoryPath: string, + matchedFrameworks: Framework[], +): void => { + const hasAmbiguousMatch = matchedFrameworks.length > 1; + const matchedFrameworkLabels = matchedFrameworks + .map((frameworkName) => FRAMEWORK_DEFINITIONS[frameworkName].frameworkLabel) + .join(', '); + + const detectionMessage = hasAmbiguousMatch + ? `Detected multiple frameworks in ${directoryPath}: ${matchedFrameworkLabels}.` + : `Could not detect a supported framework in ${directoryPath}.`; + + console.error(` + ${detectionMessage} Supported: Svelte, React, Vue, Angular @@ -131,27 +196,22 @@ const main = (): number => { - Vue: "vue" or "nuxt" - Angular: "@angular/core" + You can also force a framework: + - npx @framework-doctor/cli . --framework svelte + - npx @framework-doctor/cli . --framework react + - npx @framework-doctor/cli . --framework vue + - npx @framework-doctor/cli . --framework angular + Or run a specific doctor directly: - npx @framework-doctor/svelte . - npx @framework-doctor/react . - npx @framework-doctor/vue . + - npx @framework-doctor/angular . `); - return 1; - } - - if (!watch) { - return runDoctor(framework, doctorArgs); - } - - let debounceTimer: ReturnType | null = null; - const runScan = () => { - runDoctor(framework, doctorArgs); - }; - - runScan(); - console.log('\nWatching for changes... (Ctrl+C to stop)\n'); +}; - const watcher = chokidar.watch(dirArg, { +const wireWatchMode = (directoryPath: string, runScan: () => void): FSWatcher => { + const watcher = chokidar.watch(directoryPath, { ignored: [ /(^|[/\\])\../, /node_modules/, @@ -164,30 +224,54 @@ const main = (): number => { ], }); - watcher.on('change', () => { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - debounceTimer = null; - runScan(); - }, WATCH_DEBOUNCE_MS); - }); + let debounceTimer: ReturnType | null = null; + watcher.on('all', (eventName) => { + if (eventName !== 'add' && eventName !== 'change' && eventName !== 'unlink') { + return; + } - watcher.on('add', () => { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - debounceTimer = null; - runScan(); - }, WATCH_DEBOUNCE_MS); - }); + if (debounceTimer) { + clearTimeout(debounceTimer); + } - watcher.on('unlink', () => { - if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { debounceTimer = null; runScan(); }, WATCH_DEBOUNCE_MS); }); + return watcher; +}; + +const main = (): number => { + const rawArgs = process.argv.slice(2); + const forcedFrameworkValue = parseFrameworkArgument(rawArgs); + if (forcedFrameworkValue && !isFramework(forcedFrameworkValue)) { + printUnsupportedFrameworkValue(forcedFrameworkValue); + return 1; + } + + const { directoryPath, doctorArguments, forcedFramework, shouldWatch } = + parseCliArguments(rawArgs); + const detectionResult = detectFramework(directoryPath); + const frameworkToRun = forcedFramework ?? detectionResult.detectedFramework; + + if (!frameworkToRun) { + printFrameworkDetectionError(directoryPath, detectionResult.matchedFrameworks); + return 1; + } + + if (!shouldWatch) { + return runDoctor(frameworkToRun, doctorArguments); + } + + const runScan = () => { + runDoctor(frameworkToRun, doctorArguments); + }; + + runScan(); + console.log('\nWatching for changes... (Ctrl+C to stop)\n'); + wireWatchMode(directoryPath, runScan); return 0; }; diff --git a/packages/core/package.json b/packages/core/package.json index 0a0c936..004a283 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,8 @@ "build": "rimraf dist && cross-env NODE_ENV=production tsdown", "lint": "oxlint src", "lint:fix": "oxlint --fix src", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "pnpm build && vitest run" }, "devDependencies": { "@types/node": "catalog:", @@ -33,7 +34,8 @@ "oxlint": "catalog:", "rimraf": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" }, "packageManager": "pnpm@10.30.0", "dependencies": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f0fbf4b..e5bf064 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,6 +39,7 @@ export { loadConfig, loadConfigWithUnified, loadUnifiedConfig } from './load-con export { runAudit, type AuditResult } from './run-audit.js'; export { DANGEROUSLY_SET_INNER_HTML_RULE, + HARDCODED_SECRET_RULES, NO_AT_HTML_RULE, NO_BYPASS_SECURITY_TRUST_RULE, NO_INNER_HTML_BINDING_RULE, @@ -47,9 +48,15 @@ export { SOURCE_FILE_PATTERN_WITH_VUE, UNIVERSAL_SECURITY_RULES, getFilesToScan, + getFrameworkProfile, + runProjectSecurityScan, runSecurityScan, } from './security/index.js'; -export type { RunSecurityScanOptions, SecurityRule } from './security/index.js'; +export type { + FrameworkSecurityProfile, + RunSecurityScanOptions, + SecurityRule, +} from './security/index.js'; export { isAutomatedEnvironment, maybePromptAnalyticsConsent, diff --git a/packages/core/src/security/constants.ts b/packages/core/src/security/constants.ts new file mode 100644 index 0000000..dedbd2d --- /dev/null +++ b/packages/core/src/security/constants.ts @@ -0,0 +1,30 @@ +export const EXCLUDED_FILE_PATTERNS: RegExp[] = [ + /\.test\.(ts|js|tsx|jsx)$/, + /\.spec\.(ts|js|tsx|jsx)$/, + /__tests__\//, + /\/tests?\//, + /\/fixtures?\//, + /\/test-utils?\//, + /\.stories\.(ts|js|tsx|jsx)$/, + /\.mock\.(ts|js|tsx|jsx)$/, +]; + +export const ENV_LEAK_PATTERN = /.*(?:SECRET|KEY|TOKEN|PASSWORD|PRIVATE|CREDENTIAL)/i; + +export const COMMENT_ONLY_LINE_PATTERN = /^\s*(?:\/\/|\*|\/\*|\*\/)/; + +export const SECURITY_HEADER_PATTERNS: RegExp[] = [ + /Content-Security-Policy/, + /X-Frame-Options/, + /X-Content-Type-Options/, + /Strict-Transport-Security/, + /Referrer-Policy/, +]; + +export const ENV_FILES = ['.env', '.env.local', '.env.development', '.env.production']; + +export const API_ROUTE_MUTATING_HANDLER_PATTERN = + /(?:export\s+(?:async\s+)?function\s+(?:POST|PUT|PATCH|DELETE)\b|export\s+const\s+(?:POST|PUT|PATCH|DELETE)\b|\breq\.method\s*===?\s*['"`](?:POST|PUT|PATCH|DELETE)['"`]|\b(?:router|app|server)\.(?:post|put|patch|delete)\s*\(|@\s*(?:Post|Put|Patch|Delete)\s*\()/; + +export const API_ROUTE_AUTH_SIGNAL_PATTERN = + /\b(?:auth|authenticate|authorization|getServerSession|requireAuth|verify(?:Token|Jwt|Signature)|clerk|supabase\.auth|getUser|withAuth|isAuthenticated|validateSession)\b/i; diff --git a/packages/core/src/security/framework-profiles.ts b/packages/core/src/security/framework-profiles.ts new file mode 100644 index 0000000..f3e4e38 --- /dev/null +++ b/packages/core/src/security/framework-profiles.ts @@ -0,0 +1,128 @@ +export interface FrameworkSecurityProfile { + plugin: string; + envLeakPrefixes: string[]; + configFilenames: string[]; + configPathsForHeaders: string[]; + middlewarePaths: string[]; + apiRoutePathPatterns: RegExp[]; + publicConfigPathPatterns: RegExp[]; +} + +const NEXTJS_PROFILE: FrameworkSecurityProfile = { + plugin: 'react-doctor', + envLeakPrefixes: ['NEXT_PUBLIC_'], + configFilenames: ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs'], + configPathsForHeaders: ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs'], + middlewarePaths: ['middleware.ts', 'src/middleware.ts'], + apiRoutePathPatterns: [ + /^app\/.*\/route\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^src\/app\/.*\/route\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^pages\/api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^src\/pages\/api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + ], + publicConfigPathPatterns: [], +}; + +const SVELTEKIT_PROFILE: FrameworkSecurityProfile = { + plugin: 'svelte-doctor', + envLeakPrefixes: ['PUBLIC_'], + configFilenames: ['svelte.config.js', 'svelte.config.ts'], + configPathsForHeaders: ['svelte.config.js', 'svelte.config.ts', 'src/hooks.server.ts'], + middlewarePaths: ['hooks.server.ts', 'src/hooks.server.ts'], + apiRoutePathPatterns: [/^src\/routes\/.*\/\+server\.(?:ts|js|mts|cts|mjs|cjs)$/], + publicConfigPathPatterns: [], +}; + +const VITE_PROFILE: FrameworkSecurityProfile = { + plugin: 'react-doctor', + envLeakPrefixes: ['VITE_', 'REACT_APP_'], + configFilenames: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs'], + configPathsForHeaders: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs'], + middlewarePaths: [], + apiRoutePathPatterns: [ + /^api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^server\/api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^src\/server\/api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^functions\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + ], + publicConfigPathPatterns: [], +}; + +const VUE_VITE_PROFILE: FrameworkSecurityProfile = { + plugin: 'vue-doctor', + envLeakPrefixes: ['VITE_'], + configFilenames: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs'], + configPathsForHeaders: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs'], + middlewarePaths: [], + apiRoutePathPatterns: [ + /^api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^server\/api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^src\/server\/api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^functions\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + ], + publicConfigPathPatterns: [], +}; + +const NUXT_PROFILE: FrameworkSecurityProfile = { + plugin: 'vue-doctor', + envLeakPrefixes: ['NUXT_PUBLIC_'], + configFilenames: ['nuxt.config.ts', 'nuxt.config.js'], + configPathsForHeaders: ['nuxt.config.ts', 'nuxt.config.js'], + middlewarePaths: ['middleware', 'server/middleware'], + apiRoutePathPatterns: [/^server\/api\/.*\.(?:ts|js|mts|cts|mjs|cjs)$/], + publicConfigPathPatterns: [], +}; + +const SVELTE_PLAIN_PROFILE: FrameworkSecurityProfile = { + plugin: 'svelte-doctor', + envLeakPrefixes: ['PUBLIC_', 'VITE_'], + configFilenames: ['svelte.config.js', 'svelte.config.ts', 'vite.config.js', 'vite.config.ts'], + configPathsForHeaders: [ + 'svelte.config.js', + 'svelte.config.ts', + 'vite.config.js', + 'vite.config.ts', + ], + middlewarePaths: [], + apiRoutePathPatterns: [ + /^api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^server\/api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^src\/server\/api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^functions\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + ], + publicConfigPathPatterns: [], +}; + +const ANGULAR_PROFILE: FrameworkSecurityProfile = { + plugin: 'angular-doctor', + envLeakPrefixes: ['NG_APP_'], + configFilenames: ['angular.json'], + configPathsForHeaders: ['angular.json', 'src/index.html'], + middlewarePaths: [], + apiRoutePathPatterns: [ + /^api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^server\/api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^src\/server\/api\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + /^functions\/.*\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/, + ], + publicConfigPathPatterns: [/^src\/environments\/environment(?:\.[\w-]+)?\.ts$/], +}; + +const PROFILE_MAP: Record = { + nextjs: NEXTJS_PROFILE, + sveltekit: SVELTEKIT_PROFILE, + svelte: SVELTE_PLAIN_PROFILE, + vite: VITE_PROFILE, + vue: VUE_VITE_PROFILE, + nuxt: NUXT_PROFILE, + angular: ANGULAR_PROFILE, +}; + +export const getFrameworkProfile = ( + plugin: string, + framework: string, +): FrameworkSecurityProfile | null => { + const profile = PROFILE_MAP[framework]; + if (!profile || profile.plugin !== plugin) return null; + return profile; +}; diff --git a/packages/core/src/security/get-files-to-scan.ts b/packages/core/src/security/get-files-to-scan.ts index 8e029fe..5aa498e 100644 --- a/packages/core/src/security/get-files-to-scan.ts +++ b/packages/core/src/security/get-files-to-scan.ts @@ -2,12 +2,17 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { GIT_LS_FILES_MAX_BUFFER_BYTES } from '../constants.js'; +import { EXCLUDED_FILE_PATTERNS } from './constants.js'; + export const SOURCE_FILE_PATTERN_FULL = /\.(svelte|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; export const SOURCE_FILE_PATTERN_WITH_VUE = /\.(vue|svelte|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; export const SOURCE_FILE_PATTERN_WITH_ANGULAR = /\.(html|ts|mts|cts|mjs|cjs)$/; +const isExcludedFile = (filePath: string): boolean => + EXCLUDED_FILE_PATTERNS.some((pattern) => pattern.test(filePath)); + export const getFilesToScan = ( rootDirectory: string, includePaths: string[], @@ -18,7 +23,9 @@ export const getFilesToScan = ( .map((filePath) => path.resolve(rootDirectory, filePath)) .filter((resolvedPath) => { const relative = path.relative(rootDirectory, resolvedPath); - return !relative.startsWith('..') && pattern.test(resolvedPath); + return ( + !relative.startsWith('..') && pattern.test(resolvedPath) && !isExcludedFile(relative) + ); }); } @@ -32,7 +39,7 @@ export const getFilesToScan = ( return gitResult.stdout .split('\n') .map((line) => line.trim()) - .filter((line) => line.length > 0 && pattern.test(line)) + .filter((line) => line.length > 0 && pattern.test(line) && !isExcludedFile(line)) .map((line) => path.resolve(rootDirectory, line)); } @@ -46,7 +53,11 @@ export const getFilesToScan = ( if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== '.git') { walk(fullPath); } - } else if (entry.isFile() && pattern.test(entry.name)) { + } else if ( + entry.isFile() && + pattern.test(entry.name) && + !isExcludedFile(path.relative(rootDirectory, fullPath)) + ) { collectedFiles.push(fullPath); } } diff --git a/packages/core/src/security/hardcoded-secret-rules.ts b/packages/core/src/security/hardcoded-secret-rules.ts new file mode 100644 index 0000000..f82aa0f --- /dev/null +++ b/packages/core/src/security/hardcoded-secret-rules.ts @@ -0,0 +1,49 @@ +import type { SecurityRule } from './rule.js'; + +const JS_TS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']; + +const createSecretRule = (id: string, pattern: RegExp, description: string): SecurityRule => ({ + id, + pattern, + message: `${description} found in source code`, + help: 'Remove hardcoded secrets. Use environment variables instead.', + severity: 'error', + fileExtensions: JS_TS_EXTENSIONS, + skipCommentOnlyLines: true, +}); + +export const HARDCODED_SECRET_RULES: SecurityRule[] = [ + createSecretRule( + 'hardcoded-secret-stripe-live', + /sk_live_[a-zA-Z0-9]{20,}/, + 'Stripe live secret key', + ), + createSecretRule( + 'hardcoded-secret-stripe-test', + /sk_test_[a-zA-Z0-9]{20,}/, + 'Stripe test secret key', + ), + createSecretRule( + 'hardcoded-secret-github', + /ghp_[a-zA-Z0-9]{36,}/, + 'GitHub personal access token', + ), + createSecretRule('hardcoded-secret-aws', /AKIA[A-Z0-9]{16}/, 'AWS access key'), + createSecretRule('hardcoded-secret-slack', /xox[bpoas]-[a-zA-Z0-9-]{10,}/, 'Slack token'), + createSecretRule('hardcoded-secret-jwt', /eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\./, 'JWT token'), + createSecretRule( + 'hardcoded-secret-private-key', + /-----BEGIN (RSA |EC )?PRIVATE KEY-----/, + 'Private key', + ), + createSecretRule( + 'hardcoded-secret-mongodb', + /mongodb\+srv:\/\/[^\s'"]+/, + 'MongoDB connection string', + ), + createSecretRule( + 'hardcoded-secret-postgres', + /postgres(ql)?:\/\/[^\s'"]+@/, + 'PostgreSQL connection string', + ), +]; diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 05a0b47..3100c54 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -1,11 +1,15 @@ export { NO_BYPASS_SECURITY_TRUST_RULE, NO_INNER_HTML_BINDING_RULE } from './angular-rules.js'; +export { getFrameworkProfile } from './framework-profiles.js'; +export type { FrameworkSecurityProfile } from './framework-profiles.js'; export { SOURCE_FILE_PATTERN_WITH_ANGULAR, SOURCE_FILE_PATTERN_WITH_VUE, getFilesToScan, } from './get-files-to-scan.js'; +export { HARDCODED_SECRET_RULES } from './hardcoded-secret-rules.js'; export { DANGEROUSLY_SET_INNER_HTML_RULE } from './react-rules.js'; export type { SecurityRule } from './rule.js'; +export { runProjectSecurityScan } from './run-project-security-scan.js'; export { runSecurityScan } from './run-security-scan.js'; export type { RunSecurityScanOptions } from './run-security-scan.js'; export { NO_AT_HTML_RULE } from './svelte-rules.js'; diff --git a/packages/core/src/security/rule.ts b/packages/core/src/security/rule.ts index dc4d187..35f70e4 100644 --- a/packages/core/src/security/rule.ts +++ b/packages/core/src/security/rule.ts @@ -5,4 +5,5 @@ export interface SecurityRule { help: string; severity: 'error' | 'warning'; fileExtensions: string[]; + skipCommentOnlyLines?: boolean; } diff --git a/packages/core/src/security/run-project-security-scan.ts b/packages/core/src/security/run-project-security-scan.ts new file mode 100644 index 0000000..73b5e82 --- /dev/null +++ b/packages/core/src/security/run-project-security-scan.ts @@ -0,0 +1,312 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Diagnostic } from '../types.js'; +import { + API_ROUTE_AUTH_SIGNAL_PATTERN, + API_ROUTE_MUTATING_HANDLER_PATTERN, + ENV_FILES, + ENV_LEAK_PATTERN, + SECURITY_HEADER_PATTERNS, +} from './constants.js'; +import type { FrameworkSecurityProfile } from './framework-profiles.js'; +import { SOURCE_FILE_PATTERN_FULL, getFilesToScan } from './get-files-to-scan.js'; + +const createDiagnostic = ( + rootDir: string, + plugin: string, + rule: string, + severity: 'error' | 'warning', + message: string, + help: string, + relativePath: string, + line = 1, + column = 0, +): Diagnostic => ({ + filePath: path.join(rootDir, relativePath), + plugin, + rule, + severity, + message, + help, + line, + column, + category: 'security', +}); + +const readFileContent = (filePath: string): string | null => { + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return null; + } +}; + +const checkEnvLeaks = ( + rootDir: string, + profile: FrameworkSecurityProfile, + diagnostics: Diagnostic[], +): void => { + if (profile.envLeakPrefixes.length === 0) return; + + const envLeakRegexes = profile.envLeakPrefixes.map( + (prefix) => + new RegExp( + `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${ENV_LEAK_PATTERN.source}`, + ENV_LEAK_PATTERN.flags, + ), + ); + + for (const envFile of ENV_FILES) { + const fullPath = path.join(rootDir, envFile); + const content = readFileContent(fullPath); + if (!content) continue; + + const lines = content.split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line || line.startsWith('#')) continue; + + const keyMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/); + if (keyMatch && envLeakRegexes.some((envLeakRegex) => envLeakRegex.test(keyMatch[1]))) { + diagnostics.push( + createDiagnostic( + rootDir, + profile.plugin, + 'env-leak', + 'error', + `${keyMatch[1]} is public but contains secret-like name`, + 'Use server-only env vars for secrets. Public vars are exposed to the client.', + envFile, + index + 1, + 0, + ), + ); + } + } + } +}; + +const checkPublicConfigLeaks = ( + rootDir: string, + profile: FrameworkSecurityProfile, + diagnostics: Diagnostic[], +): void => { + if (profile.publicConfigPathPatterns.length === 0) return; + + const sourceFiles = getFilesToScan(rootDir, [], SOURCE_FILE_PATTERN_FULL); + for (const sourceFile of sourceFiles) { + const relativePath = normalizeRelativePath(rootDir, sourceFile); + const isPublicConfig = profile.publicConfigPathPatterns.some((pattern) => + pattern.test(relativePath), + ); + if (!isPublicConfig) continue; + + const content = readFileContent(sourceFile); + if (!content) continue; + + const lines = content.split('\n'); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]; + const keyRegex = /(?:^|[,{])\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?\s*:/g; + let keyMatch: RegExpExecArray | null; + while ((keyMatch = keyRegex.exec(line)) !== null) { + if (!ENV_LEAK_PATTERN.test(keyMatch[1])) continue; + + diagnostics.push( + createDiagnostic( + rootDir, + profile.plugin, + 'public-config-leak', + 'error', + `${keyMatch[1]} is defined in public client configuration`, + 'Move secrets to server-side configuration and keep only non-sensitive client values in public config files.', + relativePath, + lineIndex + 1, + 0, + ), + ); + } + } + } +}; + +const checkGitignore = (rootDir: string, plugin: string, diagnostics: Diagnostic[]): void => { + const gitignorePath = path.join(rootDir, '.gitignore'); + const content = readFileContent(gitignorePath); + + if (!content) { + diagnostics.push( + createDiagnostic( + rootDir, + plugin, + 'no-gitignore', + 'warning', + 'No .gitignore file found', + 'Add a .gitignore file and exclude .env to prevent committing secrets.', + '.gitignore', + ), + ); + return; + } + + if (!content.includes('.env')) { + diagnostics.push( + createDiagnostic( + rootDir, + plugin, + 'gitignore-missing-env', + 'error', + '.env files not in .gitignore', + 'Add .env and .env.* to .gitignore to prevent committing secrets.', + '.gitignore', + ), + ); + } +}; + +const checkSecurityHeaders = ( + rootDir: string, + profile: FrameworkSecurityProfile, + diagnostics: Diagnostic[], +): void => { + let didReadConfig = false; + for (const configFile of profile.configPathsForHeaders) { + const fullPath = path.join(rootDir, configFile); + const content = readFileContent(fullPath); + if (!content) continue; + + didReadConfig = true; + const hasHeader = SECURITY_HEADER_PATTERNS.some((pattern) => pattern.test(content)); + if (hasHeader) return; + } + + if (!didReadConfig) return; + + const firstConfig = profile.configPathsForHeaders[0] ?? 'config'; + diagnostics.push( + createDiagnostic( + rootDir, + profile.plugin, + 'no-security-headers', + 'warning', + 'No security headers configured in framework config', + `Configure Content-Security-Policy, X-Frame-Options, X-Content-Type-Options in ${firstConfig}.`, + firstConfig, + ), + ); +}; + +const hasMiddleware = (rootDir: string, profile: FrameworkSecurityProfile): boolean => { + for (const middlewarePath of profile.middlewarePaths) { + const fullPath = path.join(rootDir, middlewarePath); + if (!fs.existsSync(fullPath)) continue; + + const stat = fs.statSync(fullPath); + if (stat.isFile() && /\.(ts|js|mjs|cjs)$/.test(middlewarePath)) { + return true; + } + if (stat.isDirectory()) { + const entries = fs.readdirSync(fullPath, { withFileTypes: true }); + if (entries.some((entry) => entry.isFile() && /\.(ts|js|mjs|cjs)$/.test(entry.name))) { + return true; + } + } + } + return false; +}; + +const checkMiddleware = ( + rootDir: string, + profile: FrameworkSecurityProfile, + diagnostics: Diagnostic[], +): void => { + if (profile.middlewarePaths.length === 0) return; + if (hasMiddleware(rootDir, profile)) return; + + const paths = profile.middlewarePaths.join(', '); + const firstPath = profile.middlewarePaths[0] ?? ''; + diagnostics.push( + createDiagnostic( + rootDir, + profile.plugin, + 'no-middleware', + 'warning', + `No middleware found (checked: ${paths})`, + 'Add middleware for route protection and auth checks.', + firstPath, + ), + ); +}; + +const normalizeRelativePath = (rootDir: string, absolutePath: string): string => + path.relative(rootDir, absolutePath).split(path.sep).join('/'); + +const findLineNumberForPattern = (content: string, pattern: RegExp): number => { + const lines = content.split('\n'); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + if (pattern.test(lines[lineIndex])) { + return lineIndex + 1; + } + } + return 1; +}; + +const checkUnprotectedApiRoutes = ( + rootDir: string, + profile: FrameworkSecurityProfile, + diagnostics: Diagnostic[], +): void => { + if (profile.apiRoutePathPatterns.length === 0) return; + + const sourceFiles = getFilesToScan(rootDir, [], SOURCE_FILE_PATTERN_FULL); + for (const sourceFile of sourceFiles) { + const relativePath = normalizeRelativePath(rootDir, sourceFile); + const isApiRoute = profile.apiRoutePathPatterns.some((pattern) => pattern.test(relativePath)); + if (!isApiRoute) { + continue; + } + + const content = readFileContent(sourceFile); + if (!content) { + continue; + } + + if (!API_ROUTE_MUTATING_HANDLER_PATTERN.test(content)) { + continue; + } + + if (API_ROUTE_AUTH_SIGNAL_PATTERN.test(content)) { + continue; + } + + diagnostics.push( + createDiagnostic( + rootDir, + profile.plugin, + 'unprotected-api-route', + 'error', + `API route ${relativePath} appears to handle mutating requests without auth guard`, + 'Add authentication/authorization checks for mutating API handlers (POST, PUT, PATCH, DELETE).', + relativePath, + findLineNumberForPattern(content, API_ROUTE_MUTATING_HANDLER_PATTERN), + 0, + ), + ); + } +}; + +export const runProjectSecurityScan = ( + rootDirectory: string, + profile: FrameworkSecurityProfile, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + + checkEnvLeaks(rootDirectory, profile, diagnostics); + checkPublicConfigLeaks(rootDirectory, profile, diagnostics); + checkGitignore(rootDirectory, profile.plugin, diagnostics); + checkSecurityHeaders(rootDirectory, profile, diagnostics); + checkMiddleware(rootDirectory, profile, diagnostics); + checkUnprotectedApiRoutes(rootDirectory, profile, diagnostics); + + return diagnostics; +}; diff --git a/packages/core/src/security/run-security-scan.ts b/packages/core/src/security/run-security-scan.ts index c758145..7a5fd02 100644 --- a/packages/core/src/security/run-security-scan.ts +++ b/packages/core/src/security/run-security-scan.ts @@ -1,18 +1,38 @@ import fs from 'node:fs'; import path from 'node:path'; import type { Diagnostic } from '../types.js'; +import { COMMENT_ONLY_LINE_PATTERN } from './constants.js'; import { getFilesToScan, SOURCE_FILE_PATTERN_FULL } from './get-files-to-scan.js'; import type { SecurityRule } from './rule.js'; -const findMatches = (content: string, regex: RegExp): Array<{ line: number; column: number }> => { - const results: Array<{ line: number; column: number }> = []; +interface MatchLocation { + line: number; + column: number; +} + +const findMatches = ( + content: string, + regex: RegExp, + skipCommentOnlyLines = false, +): MatchLocation[] => { + const results: MatchLocation[] = []; const lines = content.split('\n'); for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { const line = lines[lineIndex]; + if (skipCommentOnlyLines && COMMENT_ONLY_LINE_PATTERN.test(line)) { + continue; + } const regexCopy = new RegExp(regex.source, regex.flags); + const isGlobalRegex = regexCopy.global || regexCopy.sticky; let match: RegExpExecArray | null; while ((match = regexCopy.exec(line)) !== null) { results.push({ line: lineIndex + 1, column: match.index }); + if (!isGlobalRegex) { + break; + } + if (match[0].length === 0) { + regexCopy.lastIndex += 1; + } } } return results; @@ -43,7 +63,7 @@ export const runSecurityScan = async ( const content = fs.readFileSync(filePath, 'utf-8'); for (const rule of options.rules) { if (!ruleAppliesToFile(rule, filePath)) continue; - const matches = findMatches(content, rule.pattern); + const matches = findMatches(content, rule.pattern, rule.skipCommentOnlyLines === true); for (const { line, column } of matches) { diagnostics.push({ filePath, diff --git a/packages/core/tests/run-project-security-scan.test.ts b/packages/core/tests/run-project-security-scan.test.ts new file mode 100644 index 0000000..1851d75 --- /dev/null +++ b/packages/core/tests/run-project-security-scan.test.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { getFrameworkProfile, runProjectSecurityScan } from '../src/security/index.js'; + +describe('runProjectSecurityScan', () => { + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'framework-doctor-security-test-')); + const nextjsProfile = getFrameworkProfile('react-doctor', 'nextjs'); + const angularProfile = getFrameworkProfile('angular-doctor', 'angular'); + const reactViteProfile = getFrameworkProfile('react-doctor', 'vite'); + + afterAll(() => { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + }); + + beforeEach(() => { + for (const entry of fs.readdirSync(tempDirectory, { withFileTypes: true })) { + const fullPath = path.join(tempDirectory, entry.name); + fs.rmSync(fullPath, { recursive: true, force: true }); + } + }); + + it('returns env-leak diagnostic when NEXT_PUBLIC_ var has secret-like name', () => { + if (!nextjsProfile) throw new Error('Next.js profile not found'); + fs.writeFileSync(path.join(tempDirectory, '.env'), 'NEXT_PUBLIC_SECRET_KEY=foo\n'); + const diagnostics = runProjectSecurityScan(tempDirectory, nextjsProfile); + const envLeaks = diagnostics.filter((diagnostic) => diagnostic.rule === 'env-leak'); + expect(envLeaks.length).toBeGreaterThan(0); + expect(envLeaks[0].message).toContain('NEXT_PUBLIC_SECRET_KEY'); + }); + + it('returns gitignore-missing-env when .gitignore exists but does not contain .env', () => { + if (!nextjsProfile) throw new Error('Next.js profile not found'); + fs.writeFileSync(path.join(tempDirectory, '.gitignore'), 'node_modules\n'); + const diagnostics = runProjectSecurityScan(tempDirectory, nextjsProfile); + const gitignoreDiagnostics = diagnostics.filter( + (diagnostic) => diagnostic.rule === 'gitignore-missing-env', + ); + expect(gitignoreDiagnostics.length).toBe(1); + }); + + it('returns no-gitignore when .gitignore does not exist', () => { + if (!nextjsProfile) throw new Error('Next.js profile not found'); + const diagnostics = runProjectSecurityScan(tempDirectory, nextjsProfile); + const noGitignore = diagnostics.filter((diagnostic) => diagnostic.rule === 'no-gitignore'); + expect(noGitignore.length).toBe(1); + }); + + it('does not report env-leak for safe NEXT_PUBLIC_ vars', () => { + if (!nextjsProfile) throw new Error('Next.js profile not found'); + fs.writeFileSync(path.join(tempDirectory, '.env'), 'NEXT_PUBLIC_APP_URL=https://example.com\n'); + const diagnostics = runProjectSecurityScan(tempDirectory, nextjsProfile); + const envLeaks = diagnostics.filter((diagnostic) => diagnostic.rule === 'env-leak'); + expect(envLeaks.length).toBe(0); + }); + + it('returns unprotected-api-route for mutating Next.js route without auth signals', () => { + if (!nextjsProfile) throw new Error('Next.js profile not found'); + fs.mkdirSync(path.join(tempDirectory, 'app', 'api', 'profile'), { recursive: true }); + fs.writeFileSync(path.join(tempDirectory, '.gitignore'), '.env\n'); + fs.writeFileSync( + path.join(tempDirectory, 'next.config.ts'), + 'export default { async headers() { return [{ source: "/:path*", headers: [{ key: "Content-Security-Policy", value: "default-src \'self\'" }] }]; } }', + ); + fs.writeFileSync(path.join(tempDirectory, 'middleware.ts'), 'export const config = {};'); + fs.writeFileSync( + path.join(tempDirectory, 'app', 'api', 'profile', 'route.ts'), + 'export async function POST() { return Response.json({ ok: true }); }', + ); + + const diagnostics = runProjectSecurityScan(tempDirectory, nextjsProfile); + const routeDiagnostics = diagnostics.filter( + (diagnostic) => diagnostic.rule === 'unprotected-api-route', + ); + expect(routeDiagnostics.length).toBe(1); + expect(routeDiagnostics[0].message).toContain('app/api/profile/route.ts'); + }); + + it('does not report unprotected-api-route when auth signals are present', () => { + if (!nextjsProfile) throw new Error('Next.js profile not found'); + fs.mkdirSync(path.join(tempDirectory, 'app', 'api', 'secure-profile'), { recursive: true }); + fs.writeFileSync(path.join(tempDirectory, '.gitignore'), '.env\n'); + fs.writeFileSync( + path.join(tempDirectory, 'next.config.ts'), + 'export default { async headers() { return [{ source: "/:path*", headers: [{ key: "Content-Security-Policy", value: "default-src \'self\'" }] }]; } }', + ); + fs.writeFileSync(path.join(tempDirectory, 'middleware.ts'), 'export const config = {};'); + fs.writeFileSync( + path.join(tempDirectory, 'app', 'api', 'secure-profile', 'route.ts'), + 'export async function DELETE() { const session = await getServerSession(); return Response.json({ session }); }', + ); + + const diagnostics = runProjectSecurityScan(tempDirectory, nextjsProfile); + const routeDiagnostics = diagnostics.filter( + (diagnostic) => diagnostic.rule === 'unprotected-api-route', + ); + expect(routeDiagnostics.length).toBe(0); + }); + + it('returns public-config-leak for Angular environment secrets', () => { + if (!angularProfile) throw new Error('Angular profile not found'); + fs.mkdirSync(path.join(tempDirectory, 'src', 'environments'), { recursive: true }); + fs.writeFileSync( + path.join(tempDirectory, 'src', 'environments', 'environment.ts'), + 'export const environment = { production: false, firebaseSecret: "abc123" };', + ); + + const diagnostics = runProjectSecurityScan(tempDirectory, angularProfile); + const publicConfigLeaks = diagnostics.filter( + (diagnostic) => diagnostic.rule === 'public-config-leak', + ); + expect(publicConfigLeaks.length).toBe(1); + expect(publicConfigLeaks[0].message).toContain('firebaseSecret'); + }); + + it('returns unprotected-api-route for mutating server/api handler in Vite profile', () => { + if (!reactViteProfile) throw new Error('React Vite profile not found'); + fs.mkdirSync(path.join(tempDirectory, 'server', 'api'), { recursive: true }); + fs.writeFileSync( + path.join(tempDirectory, 'server', 'api', 'users.ts'), + 'const router = { post(path: string, handler: unknown) {} }; router.post("/users", () => ({ ok: true }));', + ); + + const diagnostics = runProjectSecurityScan(tempDirectory, reactViteProfile); + const routeDiagnostics = diagnostics.filter( + (diagnostic) => diagnostic.rule === 'unprotected-api-route', + ); + expect(routeDiagnostics.length).toBe(1); + expect(routeDiagnostics[0].message).toContain('server/api/users.ts'); + }); + + it('does not report unprotected-api-route for guarded mutating server/api handler', () => { + if (!reactViteProfile) throw new Error('React Vite profile not found'); + fs.mkdirSync(path.join(tempDirectory, 'server', 'api'), { recursive: true }); + fs.writeFileSync( + path.join(tempDirectory, 'server', 'api', 'users.ts'), + 'const router = { post(path: string, handler: unknown) {} }; const auth = true; router.post("/users", () => auth);', + ); + + const diagnostics = runProjectSecurityScan(tempDirectory, reactViteProfile); + const routeDiagnostics = diagnostics.filter( + (diagnostic) => diagnostic.rule === 'unprotected-api-route', + ); + expect(routeDiagnostics.length).toBe(0); + }); +}); diff --git a/packages/core/tests/run-security-scan.test.ts b/packages/core/tests/run-security-scan.test.ts new file mode 100644 index 0000000..8e9a2c8 --- /dev/null +++ b/packages/core/tests/run-security-scan.test.ts @@ -0,0 +1,44 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { HARDCODED_SECRET_RULES, runSecurityScan } from '../src/security/index.js'; + +describe('runSecurityScan', () => { + const tempDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'framework-doctor-security-regex-test-'), + ); + + afterAll(() => { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + }); + + beforeEach(() => { + for (const entry of fs.readdirSync(tempDirectory, { withFileTypes: true })) { + fs.rmSync(path.join(tempDirectory, entry.name), { recursive: true, force: true }); + } + }); + + it('skips hardcoded-secret matches in comment-only lines', async () => { + const fakeKey1 = `sk_live_${'x'.repeat(24)}`; + const fakeKey2 = `sk_live_${'y'.repeat(24)}`; + const fakeKey3 = `sk_live_${'z'.repeat(24)}`; + + fs.writeFileSync( + path.join(tempDirectory, 'security-comment.ts'), + [`// ${fakeKey1}`, `* ${fakeKey2}`, `const apiKey = "${fakeKey3}";`].join('\n'), + ); + + const diagnostics = await runSecurityScan(tempDirectory, [], { + plugin: 'react-doctor', + rules: HARDCODED_SECRET_RULES, + }); + + const stripeDiagnostics = diagnostics.filter( + (diagnostic) => diagnostic.rule === 'hardcoded-secret-stripe-live', + ); + + expect(stripeDiagnostics.length).toBe(1); + expect(stripeDiagnostics[0].line).toBe(3); + }); +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..154643f --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 5_000, + }, +}); diff --git a/packages/react-doctor/CHANGELOG.md b/packages/react-doctor/CHANGELOG.md index e8102c8..1a9b51c 100644 --- a/packages/react-doctor/CHANGELOG.md +++ b/packages/react-doctor/CHANGELOG.md @@ -1,5 +1,11 @@ # react-doctor +## 1.1.1 + +### Patch Changes + +- included more security rules + ## 1.1.0 ### Minor Changes diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index a0d8139..44733d6 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/react", - "version": "1.1.0", + "version": "1.1.1", "description": "Diagnose and fix performance issues in your React app", "author": { "name": "Pitis Radu", diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index 363a387..2ca7e83 100644 --- a/packages/react-doctor/src/index.ts +++ b/packages/react-doctor/src/index.ts @@ -75,7 +75,7 @@ export const diagnose = async ( : Promise.resolve(emptyDiagnostics); const securityPromise = effectiveLint - ? runSecurityScan(resolvedDirectory, includePaths) + ? runSecurityScan(resolvedDirectory, includePaths, projectInfo.framework) : Promise.resolve(emptyDiagnostics); const [lintDiagnostics, deadCodeDiagnostics, securityDiagnostics] = await Promise.all([ diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index d98161c..77cde96 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -467,7 +467,7 @@ export const scan = async ( : Promise.resolve([]); const securityPromise = options.lint - ? runSecurityScan(directory, includePaths) + ? runSecurityScan(directory, includePaths, projectInfo.framework) : Promise.resolve([]); const [lintDiagnostics, deadCodeDiagnostics, securityDiagnostics] = await Promise.all([ diff --git a/packages/react-doctor/src/utils/run-security-scan.ts b/packages/react-doctor/src/utils/run-security-scan.ts index 24a349f..4c8874a 100644 --- a/packages/react-doctor/src/utils/run-security-scan.ts +++ b/packages/react-doctor/src/utils/run-security-scan.ts @@ -1,14 +1,30 @@ import { DANGEROUSLY_SET_INNER_HTML_RULE, + getFrameworkProfile, + HARDCODED_SECRET_RULES, + runProjectSecurityScan, runSecurityScan as runSecurityScanCore, + UNIVERSAL_SECURITY_RULES, } from '@framework-doctor/core'; const REACT_PLUGIN = 'react-doctor'; -const REACT_SECURITY_RULES = [DANGEROUSLY_SET_INNER_HTML_RULE]; +const REACT_SECURITY_RULES = [ + ...UNIVERSAL_SECURITY_RULES, + ...HARDCODED_SECRET_RULES, + DANGEROUSLY_SET_INNER_HTML_RULE, +]; -export const runSecurityScan = async (rootDirectory: string, includePaths: string[]) => - runSecurityScanCore(rootDirectory, includePaths, { +export const runSecurityScan = async ( + rootDirectory: string, + includePaths: string[], + framework?: string, +) => { + const regexDiagnostics = await runSecurityScanCore(rootDirectory, includePaths, { plugin: REACT_PLUGIN, rules: REACT_SECURITY_RULES, }); + const profile = framework ? getFrameworkProfile(REACT_PLUGIN, framework) : null; + const projectDiagnostics = profile ? runProjectSecurityScan(rootDirectory, profile) : []; + return [...regexDiagnostics, ...projectDiagnostics]; +}; diff --git a/packages/svelte-doctor/CHANGELOG.md b/packages/svelte-doctor/CHANGELOG.md index e8a2ad7..882ecc3 100644 --- a/packages/svelte-doctor/CHANGELOG.md +++ b/packages/svelte-doctor/CHANGELOG.md @@ -1,5 +1,11 @@ # svelte-doctor +## 1.1.1 + +### Patch Changes + +- included more security rules + ## 1.1.0 ### Minor Changes diff --git a/packages/svelte-doctor/package.json b/packages/svelte-doctor/package.json index 81f2850..f3dadc2 100644 --- a/packages/svelte-doctor/package.json +++ b/packages/svelte-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/svelte", - "version": "1.1.0", + "version": "1.1.1", "description": "Diagnose and improve Svelte codebase health", "author": { "name": "Pitis Radu", diff --git a/packages/svelte-doctor/src/scan.ts b/packages/svelte-doctor/src/scan.ts index cf0a25b..f7a56fd 100644 --- a/packages/svelte-doctor/src/scan.ts +++ b/packages/svelte-doctor/src/scan.ts @@ -80,7 +80,11 @@ export const scan = async (directory: string, options: ScanOptions = {}): Promis let securityDiagnostics: Diagnostic[] = []; if (resolved.lint) { try { - securityDiagnostics = await runSecurityScan(directory, resolved.includePaths); + securityDiagnostics = await runSecurityScan( + directory, + resolved.includePaths, + projectInfo.framework, + ); } catch { skippedChecks.push('security'); } diff --git a/packages/svelte-doctor/src/utils/run-security-scan.ts b/packages/svelte-doctor/src/utils/run-security-scan.ts index 9a84e16..975383c 100644 --- a/packages/svelte-doctor/src/utils/run-security-scan.ts +++ b/packages/svelte-doctor/src/utils/run-security-scan.ts @@ -1,15 +1,30 @@ import { + getFrameworkProfile, + HARDCODED_SECRET_RULES, NO_AT_HTML_RULE, + runProjectSecurityScan, runSecurityScan as runSecurityScanCore, UNIVERSAL_SECURITY_RULES, } from '@framework-doctor/core'; const SVELTE_PLUGIN = 'svelte-doctor'; -const SVELTE_SECURITY_RULES = [...UNIVERSAL_SECURITY_RULES, NO_AT_HTML_RULE]; +const SVELTE_SECURITY_RULES = [ + ...UNIVERSAL_SECURITY_RULES, + ...HARDCODED_SECRET_RULES, + NO_AT_HTML_RULE, +]; -export const runSecurityScan = async (rootDirectory: string, includePaths: string[]) => - runSecurityScanCore(rootDirectory, includePaths, { +export const runSecurityScan = async ( + rootDirectory: string, + includePaths: string[], + framework?: string, +) => { + const regexDiagnostics = await runSecurityScanCore(rootDirectory, includePaths, { plugin: SVELTE_PLUGIN, rules: SVELTE_SECURITY_RULES, }); + const profile = framework ? getFrameworkProfile(SVELTE_PLUGIN, framework) : null; + const projectDiagnostics = profile ? runProjectSecurityScan(rootDirectory, profile) : []; + return [...regexDiagnostics, ...projectDiagnostics]; +}; diff --git a/packages/vue-doctor/CHANGELOG.md b/packages/vue-doctor/CHANGELOG.md index 24d9ef7..eb9d05a 100644 --- a/packages/vue-doctor/CHANGELOG.md +++ b/packages/vue-doctor/CHANGELOG.md @@ -1,5 +1,11 @@ # vue-doctor +## 1.1.1 + +### Patch Changes + +- included more security rules + ## 1.1.0 ### Minor Changes diff --git a/packages/vue-doctor/package.json b/packages/vue-doctor/package.json index 3801ca8..37f308b 100644 --- a/packages/vue-doctor/package.json +++ b/packages/vue-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/vue", - "version": "1.1.0", + "version": "1.1.1", "description": "Diagnose Vue and Nuxt codebase health", "author": { "name": "Pitis Radu", diff --git a/packages/vue-doctor/src/scan.ts b/packages/vue-doctor/src/scan.ts index b21350f..01d6a4f 100644 --- a/packages/vue-doctor/src/scan.ts +++ b/packages/vue-doctor/src/scan.ts @@ -339,7 +339,7 @@ export const scan = async ( : Promise.resolve([]); const securityPromise = options.lint - ? runSecurityScan(directory, includePaths) + ? runSecurityScan(directory, includePaths, projectInfo.framework) : Promise.resolve([]); const [vueTscDiagnostics, lintDiagnostics, deadCodeDiagnostics, securityDiagnostics] = diff --git a/packages/vue-doctor/src/utils/run-security-scan.ts b/packages/vue-doctor/src/utils/run-security-scan.ts index 909a874..260f5e4 100644 --- a/packages/vue-doctor/src/utils/run-security-scan.ts +++ b/packages/vue-doctor/src/utils/run-security-scan.ts @@ -1,5 +1,8 @@ import { + getFrameworkProfile, + HARDCODED_SECRET_RULES, NO_V_HTML_RULE, + runProjectSecurityScan, runSecurityScan as runSecurityScanCore, SOURCE_FILE_PATTERN_WITH_VUE, UNIVERSAL_SECURITY_RULES, @@ -7,11 +10,19 @@ import { const VUE_PLUGIN = 'vue-doctor'; -const VUE_SECURITY_RULES = [...UNIVERSAL_SECURITY_RULES, NO_V_HTML_RULE]; +const VUE_SECURITY_RULES = [...UNIVERSAL_SECURITY_RULES, ...HARDCODED_SECRET_RULES, NO_V_HTML_RULE]; -export const runSecurityScan = async (rootDirectory: string, includePaths: string[]) => - runSecurityScanCore(rootDirectory, includePaths, { +export const runSecurityScan = async ( + rootDirectory: string, + includePaths: string[], + framework?: string, +) => { + const regexDiagnostics = await runSecurityScanCore(rootDirectory, includePaths, { plugin: VUE_PLUGIN, rules: VUE_SECURITY_RULES, filePattern: SOURCE_FILE_PATTERN_WITH_VUE, }); + const profile = framework ? getFrameworkProfile(VUE_PLUGIN, framework) : null; + const projectDiagnostics = profile ? runProjectSecurityScan(rootDirectory, profile) : []; + return [...regexDiagnostics, ...projectDiagnostics]; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f463e5..8e862dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -352,6 +352,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2) packages/react-doctor: dependencies: