getdoorman 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/bin/doorman.js +444 -0
  4. package/package.json +74 -0
  5. package/src/ai-fixer.js +559 -0
  6. package/src/ast-scanner.js +434 -0
  7. package/src/auth.js +149 -0
  8. package/src/baseline.js +48 -0
  9. package/src/compliance.js +539 -0
  10. package/src/config.js +466 -0
  11. package/src/custom-rules.js +32 -0
  12. package/src/dashboard.js +202 -0
  13. package/src/detector.js +142 -0
  14. package/src/fix-engine.js +48 -0
  15. package/src/fix-registry-extra.js +95 -0
  16. package/src/fix-registry-go-rust.js +77 -0
  17. package/src/fix-registry-java-csharp.js +77 -0
  18. package/src/fix-registry-js.js +99 -0
  19. package/src/fix-registry-mcp-ai.js +57 -0
  20. package/src/fix-registry-python.js +87 -0
  21. package/src/fixer-ruby-php.js +608 -0
  22. package/src/fixer.js +2113 -0
  23. package/src/hooks.js +115 -0
  24. package/src/ignore.js +176 -0
  25. package/src/index.js +384 -0
  26. package/src/metrics.js +126 -0
  27. package/src/monorepo.js +65 -0
  28. package/src/presets.js +54 -0
  29. package/src/reporter.js +975 -0
  30. package/src/rule-worker.js +36 -0
  31. package/src/rules/ast-rules.js +756 -0
  32. package/src/rules/bugs/accessibility.js +235 -0
  33. package/src/rules/bugs/ai-codegen-fixable.js +172 -0
  34. package/src/rules/bugs/ai-codegen.js +365 -0
  35. package/src/rules/bugs/code-smell-bugs.js +247 -0
  36. package/src/rules/bugs/crypto-bugs.js +195 -0
  37. package/src/rules/bugs/docker-bugs.js +158 -0
  38. package/src/rules/bugs/general.js +361 -0
  39. package/src/rules/bugs/go-bugs.js +279 -0
  40. package/src/rules/bugs/index.js +73 -0
  41. package/src/rules/bugs/js-api.js +257 -0
  42. package/src/rules/bugs/js-array-object.js +210 -0
  43. package/src/rules/bugs/js-async-fixable.js +223 -0
  44. package/src/rules/bugs/js-async.js +211 -0
  45. package/src/rules/bugs/js-closure-scope.js +182 -0
  46. package/src/rules/bugs/js-database.js +203 -0
  47. package/src/rules/bugs/js-error-handling.js +148 -0
  48. package/src/rules/bugs/js-logic.js +261 -0
  49. package/src/rules/bugs/js-memory.js +214 -0
  50. package/src/rules/bugs/js-node.js +361 -0
  51. package/src/rules/bugs/js-react.js +373 -0
  52. package/src/rules/bugs/js-regex.js +200 -0
  53. package/src/rules/bugs/js-state.js +272 -0
  54. package/src/rules/bugs/js-type-coercion.js +318 -0
  55. package/src/rules/bugs/nextjs-bugs.js +242 -0
  56. package/src/rules/bugs/nextjs-fixable.js +120 -0
  57. package/src/rules/bugs/node-fixable.js +178 -0
  58. package/src/rules/bugs/python-advanced.js +245 -0
  59. package/src/rules/bugs/python-fixable.js +98 -0
  60. package/src/rules/bugs/python.js +284 -0
  61. package/src/rules/bugs/react-fixable.js +207 -0
  62. package/src/rules/bugs/ruby-bugs.js +182 -0
  63. package/src/rules/bugs/shell-bugs.js +181 -0
  64. package/src/rules/bugs/silent-failures.js +261 -0
  65. package/src/rules/bugs/ts-bugs.js +235 -0
  66. package/src/rules/bugs/unused-vars.js +65 -0
  67. package/src/rules/compliance/accessibility-ext.js +468 -0
  68. package/src/rules/compliance/education.js +322 -0
  69. package/src/rules/compliance/financial.js +421 -0
  70. package/src/rules/compliance/frameworks.js +507 -0
  71. package/src/rules/compliance/healthcare.js +520 -0
  72. package/src/rules/compliance/index.js +2714 -0
  73. package/src/rules/compliance/regional-eu.js +480 -0
  74. package/src/rules/compliance/regional-international.js +903 -0
  75. package/src/rules/cost/index.js +1993 -0
  76. package/src/rules/data/index.js +2503 -0
  77. package/src/rules/dependencies/index.js +1684 -0
  78. package/src/rules/deployment/index.js +2050 -0
  79. package/src/rules/index.js +71 -0
  80. package/src/rules/infrastructure/index.js +3048 -0
  81. package/src/rules/performance/index.js +3455 -0
  82. package/src/rules/quality/index.js +3175 -0
  83. package/src/rules/reliability/index.js +3040 -0
  84. package/src/rules/scope-rules.js +815 -0
  85. package/src/rules/security/ai-api.js +1177 -0
  86. package/src/rules/security/auth.js +1328 -0
  87. package/src/rules/security/cors.js +127 -0
  88. package/src/rules/security/crypto.js +527 -0
  89. package/src/rules/security/csharp.js +862 -0
  90. package/src/rules/security/csrf.js +193 -0
  91. package/src/rules/security/dart.js +835 -0
  92. package/src/rules/security/deserialization.js +291 -0
  93. package/src/rules/security/file-upload.js +187 -0
  94. package/src/rules/security/go.js +850 -0
  95. package/src/rules/security/headers.js +235 -0
  96. package/src/rules/security/index.js +65 -0
  97. package/src/rules/security/injection.js +1639 -0
  98. package/src/rules/security/mcp-server.js +71 -0
  99. package/src/rules/security/misconfiguration.js +660 -0
  100. package/src/rules/security/oauth-jwt.js +329 -0
  101. package/src/rules/security/path-traversal.js +295 -0
  102. package/src/rules/security/php.js +1054 -0
  103. package/src/rules/security/prototype-pollution.js +283 -0
  104. package/src/rules/security/rate-limiting.js +208 -0
  105. package/src/rules/security/ruby.js +1061 -0
  106. package/src/rules/security/rust.js +693 -0
  107. package/src/rules/security/secrets.js +747 -0
  108. package/src/rules/security/shell.js +647 -0
  109. package/src/rules/security/ssrf.js +298 -0
  110. package/src/rules/security/supply-chain-advanced.js +393 -0
  111. package/src/rules/security/supply-chain.js +734 -0
  112. package/src/rules/security/swift.js +835 -0
  113. package/src/rules/security/taint.js +27 -0
  114. package/src/rules/security/xss.js +520 -0
  115. package/src/scan-cache.js +71 -0
  116. package/src/scanner.js +710 -0
  117. package/src/scope-analyzer.js +685 -0
  118. package/src/share.js +88 -0
  119. package/src/taint.js +300 -0
  120. package/src/telemetry.js +183 -0
  121. package/src/tracer.js +190 -0
  122. package/src/upload.js +35 -0
  123. package/src/worker.js +31 -0
package/src/hooks.js ADDED
@@ -0,0 +1,115 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Check if this is the first scan for the given project.
6
+ */
7
+ export function isFirstScan(targetPath) {
8
+ const metricsPath = join(targetPath, '.doorman', 'metrics.json');
9
+ if (!existsSync(metricsPath)) return true;
10
+ try {
11
+ const data = JSON.parse(readFileSync(metricsPath, 'utf-8'));
12
+ return !data.scans || data.scans.length <= 1;
13
+ } catch {
14
+ return true;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Check if Claude Code hooks are already set up for Doorman.
20
+ */
21
+ export function hasClaudeHook(targetPath) {
22
+ const settingsPath = join(targetPath, '.claude', 'settings.json');
23
+ if (!existsSync(settingsPath)) return false;
24
+ try {
25
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
26
+ return JSON.stringify(settings).includes('doorman');
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Install Claude Code hook that runs Doorman after file edits.
34
+ */
35
+ export function installClaudeHook(targetPath) {
36
+ const claudeDir = join(targetPath, '.claude');
37
+ const settingsPath = join(claudeDir, 'settings.json');
38
+
39
+ if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
40
+
41
+ let settings = {};
42
+ if (existsSync(settingsPath)) {
43
+ try {
44
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
45
+ } catch {
46
+ settings = {};
47
+ }
48
+ }
49
+
50
+ if (!settings.hooks) settings.hooks = {};
51
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
52
+
53
+ // Don't add duplicate
54
+ const already = settings.hooks.PostToolUse.some(h =>
55
+ h.command && h.command.includes('doorman')
56
+ );
57
+ if (already) return false;
58
+
59
+ settings.hooks.PostToolUse.push({
60
+ matcher: 'Write|Edit',
61
+ command: 'npx getdoorman check --quiet'
62
+ });
63
+
64
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
65
+ return true;
66
+ }
67
+
68
+ /**
69
+ * Install pre-commit git hook.
70
+ */
71
+ export function installGitHook(targetPath) {
72
+ const hookPath = join(targetPath, '.git', 'hooks', 'pre-commit');
73
+ if (!existsSync(join(targetPath, '.git'))) return false;
74
+
75
+ const hooksDir = join(targetPath, '.git', 'hooks');
76
+ if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
77
+
78
+ if (existsSync(hookPath)) return false;
79
+
80
+ const hookScript = `#!/bin/sh
81
+ # Doorman pre-commit hook — blocks commits with critical issues
82
+ npx getdoorman check --quiet --ci
83
+ `;
84
+ writeFileSync(hookPath, hookScript, { mode: 0o755 });
85
+ return true;
86
+ }
87
+
88
+ /**
89
+ * Add Doorman instructions to CLAUDE.md so Claude knows to use it.
90
+ */
91
+ export function installClaudeMd(targetPath) {
92
+ const claudeMdPath = join(targetPath, 'CLAUDE.md');
93
+ const doormanBlock = `
94
+ ## Doorman — Code Safety
95
+
96
+ Before shipping or when asked to check code safety, run:
97
+ \`\`\`bash
98
+ npx getdoorman check
99
+ \`\`\`
100
+
101
+ To fix issues automatically:
102
+ \`\`\`bash
103
+ npx getdoorman fix
104
+ \`\`\`
105
+ `;
106
+
107
+ if (existsSync(claudeMdPath)) {
108
+ const content = readFileSync(claudeMdPath, 'utf-8');
109
+ if (content.includes('doorman')) return false; // Already there
110
+ writeFileSync(claudeMdPath, content + '\n' + doormanBlock);
111
+ } else {
112
+ writeFileSync(claudeMdPath, doormanBlock.trimStart());
113
+ }
114
+ return true;
115
+ }
package/src/ignore.js ADDED
@@ -0,0 +1,176 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { minimatch } from 'minimatch';
4
+
5
+ /**
6
+ * Default directories and patterns to always ignore.
7
+ */
8
+ const DEFAULT_IGNORES = [
9
+ // Package managers & build output
10
+ 'node_modules/**',
11
+ '.git/**',
12
+ 'dist/**',
13
+ 'build/**',
14
+ 'vendor/**',
15
+ '__pycache__/**',
16
+ '.next/**',
17
+ '.vercel/**',
18
+ 'out/**',
19
+ '.nuxt/**',
20
+ '.svelte-kit/**',
21
+ '.astro/**',
22
+ 'coverage/**',
23
+ '.cache/**',
24
+ '.turbo/**',
25
+ '.parcel-cache/**',
26
+ '.expo/**',
27
+ // Tests & non-production code
28
+ 'test/**',
29
+ 'tests/**',
30
+ '__tests__/**',
31
+ '__mocks__/**',
32
+ 'fixtures/**',
33
+ 'examples/**',
34
+ 'example/**',
35
+ 'sample/**',
36
+ 'samples/**',
37
+ 'demo/**',
38
+ 'demos/**',
39
+ 'docs/**',
40
+ 'doc/**',
41
+ 'benchmarks/**',
42
+ 'benchmark/**',
43
+ 'storybook/**',
44
+ '.storybook/**',
45
+ 'stories/**',
46
+ 'cypress/**',
47
+ 'playwright/**',
48
+ 'e2e/**',
49
+ '**/*.test.js',
50
+ '**/*.test.ts',
51
+ '**/*.spec.js',
52
+ '**/*.spec.ts',
53
+ '**/*.test.jsx',
54
+ '**/*.test.tsx',
55
+ '**/*.spec.jsx',
56
+ '**/*.spec.tsx',
57
+ // Generated & binary files
58
+ '*.min.js',
59
+ '*.min.css',
60
+ '*.map',
61
+ '*.d.ts',
62
+ 'package-lock.json',
63
+ 'yarn.lock',
64
+ 'pnpm-lock.yaml',
65
+ 'bun.lockb',
66
+ // Media & assets
67
+ '*.ico',
68
+ '*.png',
69
+ '*.jpg',
70
+ '*.jpeg',
71
+ '*.gif',
72
+ '*.svg',
73
+ '*.woff',
74
+ '*.woff2',
75
+ '*.ttf',
76
+ '*.eot',
77
+ '*.mp4',
78
+ '*.webm',
79
+ '*.mp3',
80
+ '*.pdf',
81
+ // IDE & config
82
+ '.vscode/**',
83
+ '.idea/**',
84
+ '.doorman/**',
85
+ ];
86
+
87
+ /**
88
+ * Parse a .doormanignore file.
89
+ * Supports:
90
+ * - Glob patterns (same as .gitignore)
91
+ * - Comments starting with #
92
+ * - Negation patterns starting with !
93
+ * - Blank lines are ignored
94
+ *
95
+ * Returns { patterns: string[], negations: string[] }
96
+ */
97
+ export function parseIgnoreFile(content) {
98
+ const patterns = [];
99
+ const negations = [];
100
+
101
+ const lines = content.split('\n');
102
+ for (const raw of lines) {
103
+ const line = raw.trim();
104
+
105
+ // Skip blank lines and comments
106
+ if (!line || line.startsWith('#')) continue;
107
+
108
+ if (line.startsWith('!')) {
109
+ // Negation — strip the ! prefix
110
+ negations.push(line.slice(1));
111
+ } else {
112
+ patterns.push(line);
113
+ }
114
+ }
115
+
116
+ return { patterns, negations };
117
+ }
118
+
119
+ /**
120
+ * Load ignore patterns from .doormanignore in the given directory.
121
+ * Merges with the built-in default ignores.
122
+ *
123
+ * Returns the full list of ignore glob strings (for use with glob's `ignore`),
124
+ * plus a `shouldIgnore(filePath)` predicate that respects negation patterns.
125
+ */
126
+ export function loadIgnorePatterns(targetPath) {
127
+ const ignoreFilePath = join(targetPath, '.doormanignore');
128
+
129
+ let userPatterns = [];
130
+ let negations = [];
131
+
132
+ if (existsSync(ignoreFilePath)) {
133
+ try {
134
+ const content = readFileSync(ignoreFilePath, 'utf-8');
135
+ const parsed = parseIgnoreFile(content);
136
+ userPatterns = parsed.patterns;
137
+ negations = parsed.negations;
138
+ } catch {
139
+ // If the file can't be read, just use defaults
140
+ }
141
+ }
142
+
143
+ // Merge default ignores with user patterns
144
+ const allPatterns = [...DEFAULT_IGNORES, ...userPatterns];
145
+
146
+ /**
147
+ * Check whether a relative file path should be ignored.
148
+ * First checks if it matches any ignore pattern, then checks
149
+ * if a negation pattern re-includes it.
150
+ */
151
+ function shouldIgnore(filePath) {
152
+ const matchOpts = { dot: true, matchBase: true };
153
+
154
+ // Check negations first — if a negation matches, the file is NOT ignored
155
+ for (const neg of negations) {
156
+ if (minimatch(filePath, neg, matchOpts)) {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ // Check ignore patterns
162
+ for (const pattern of allPatterns) {
163
+ if (minimatch(filePath, pattern, matchOpts)) {
164
+ return true;
165
+ }
166
+ }
167
+
168
+ return false;
169
+ }
170
+
171
+ return {
172
+ patterns: allPatterns,
173
+ negations,
174
+ shouldIgnore,
175
+ };
176
+ }
package/src/index.js ADDED
@@ -0,0 +1,384 @@
1
+ import { resolve, dirname } from 'path';
2
+ import { existsSync, statSync } from 'fs';
3
+ import ora from 'ora';
4
+ import chalk from 'chalk';
5
+ import { detectStack } from './detector.js';
6
+ import { collectFiles, collectFilesIncremental, runRules, runRulesParallel } from './scanner.js';
7
+ import { loadRules } from './rules/index.js';
8
+ import { calculateScore, printReport, generateJSON, writeSARIF, writeHTML, printSummaryLine } from './reporter.js';
9
+ import { traceAttackPaths } from './tracer.js';
10
+ import { loadConfig, applyConfig, getFileIgnorePatterns } from './config.js';
11
+ import { recordScan } from './metrics.js';
12
+ import { sendTelemetry } from './telemetry.js';
13
+ import { isASTAvailable } from './ast-scanner.js';
14
+ import { loadBaseline, diffFindings, saveBaseline } from './baseline.js';
15
+ import { loadCustomRules } from './custom-rules.js';
16
+ import { detectWorkspaces } from './monorepo.js';
17
+ import { saveScanCache, mergeWithCache } from './scan-cache.js';
18
+
19
+ /**
20
+ * Rules that don't apply to libraries (npm packages, frameworks).
21
+ * These expect an application context (deployed service, user-facing app).
22
+ */
23
+ const LIBRARY_SKIP_PATTERNS = [
24
+ // App-level security headers/middleware — the app's responsibility, not the library's
25
+ /https.*enforcement|hsts|csp.*header|content.security.policy|x-frame|x-content-type|referrer.policy|permissions.policy|csrf|helmet/i,
26
+ // App deployment/monitoring
27
+ /no.*uptime.*monitoring|no.*health.*check|no.*graceful.*shutdown|no.*request.*timeout|no.*rate.*limit|no.*ci.*pipeline|no.*sast|no.*test.*file|no.*lint/i,
28
+ // App-level process handling
29
+ /uncaughtException|unhandledRejection|no.*error.*tracking|no.*lockfile/i,
30
+ // App infrastructure
31
+ /no.*privacy.*policy|no.*cookie.*consent|no.*data.*retention|no.*backup|no.*incident.*response|no.*breach.*notification/i,
32
+ // App-level compression/monitoring
33
+ /without.*compression|no.*monitoring|no.*logging.*service/i,
34
+ ];
35
+
36
+ /**
37
+ * Rule IDs that are app-level concerns, not library concerns.
38
+ */
39
+ const LIBRARY_SKIP_RULES = new Set([
40
+ 'SCOPE-AUTH-001', // "Route handler without auth" — auth is the app's job
41
+ 'SCOPE-AUTH-002', // "Route handler without rate limiting"
42
+ ]);
43
+
44
+ /**
45
+ * Rules that should be downgraded to 'info' severity for libraries
46
+ * (still shown with --verbose, but don't tank the score).
47
+ */
48
+ const LIBRARY_DOWNGRADE_PATTERNS = [
49
+ /\bvar\b.*declaration/i, // var→const/let is a style choice for older Node support
50
+ /recursive.*without.*depth/i, // Library internals manage their own call depth
51
+ /without.*(?:timeout|shutdown|compression|rate.limit)/i, // App-level concerns
52
+ /unbounded.*concurrent/i, // App-level concern
53
+ /server\.listen|hardcoded.*port/i, // Example patterns, not library code
54
+ ];
55
+
56
+ /**
57
+ * Categories that are largely irrelevant for libraries.
58
+ * Compliance, deployment, and cost rules assume a deployed app context.
59
+ */
60
+ const LIBRARY_SKIP_CATEGORIES = new Set(['compliance', 'deployment', 'cost']);
61
+
62
+ /**
63
+ * Filter out findings that don't apply to library/framework codebases.
64
+ * Libraries provide building blocks — app-level rules (CSRF, HSTS, etc.) are the consumer's job.
65
+ */
66
+ function filterLibraryFindings(findings) {
67
+ return findings.filter(f => {
68
+ const title = f.title || '';
69
+ const desc = f.description || '';
70
+ const text = title + ' ' + desc;
71
+
72
+ // Skip entire categories that don't apply to libraries
73
+ if (LIBRARY_SKIP_CATEGORIES.has(f.category)) return false;
74
+
75
+ // Skip specific rule IDs
76
+ if (f.ruleId && LIBRARY_SKIP_RULES.has(f.ruleId)) return false;
77
+
78
+ // Skip project-level (no file) findings that match app patterns
79
+ if (!f.file) {
80
+ return !LIBRARY_SKIP_PATTERNS.some(p => p.test(text));
81
+ }
82
+
83
+ // Downgrade noisy rules to 'suggestion' confidence for libraries
84
+ // (still shown with --verbose, doesn't affect score)
85
+ if (LIBRARY_DOWNGRADE_PATTERNS.some(p => p.test(title))) {
86
+ f.confidence = 'suggestion';
87
+ f.severity = 'info';
88
+ }
89
+
90
+ return true;
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Determine whether to use incremental scanning.
96
+ * Defaults to incremental when inside a git repo, unless --full is set.
97
+ */
98
+ function shouldUseIncremental(resolvedPath, options) {
99
+ if (options.full) return false;
100
+ if (options.incremental) return true;
101
+ // Default: incremental when a .git directory exists
102
+ return existsSync(resolve(resolvedPath, '.git'));
103
+ }
104
+
105
+ /**
106
+ * Main check function — orchestrates the entire scan.
107
+ */
108
+ export async function check(targetPath = '.', options = {}) {
109
+ let resolvedPath = resolve(targetPath);
110
+ // If target is a file, use its parent directory for scanning
111
+ if (existsSync(resolvedPath) && statSync(resolvedPath).isFile()) {
112
+ resolvedPath = dirname(resolvedPath);
113
+ }
114
+ const silent = options.silent || options.json || options.sarif || options.html || options.ci || options.quiet || false;
115
+ const timeoutMs = (options.timeout ? parseInt(options.timeout, 10) : 60) * 1000;
116
+ const startTime = performance.now();
117
+ let timedOut = false;
118
+
119
+ // Load user config (ignore rules, severity overrides, thresholds)
120
+ const config = await loadConfig(resolvedPath, options.config);
121
+
122
+ if (!silent && config._loadedFrom) {
123
+ console.log(chalk.gray(` Using config: ${config._loadedFrom}`));
124
+ }
125
+
126
+ // Wrap the scan in a timeout race
127
+ const scanPromise = (async () => {
128
+ // Step 1: Detect stack
129
+ const spinner = silent ? null : ora('Detecting your tech stack...').start();
130
+ const stack = await detectStack(resolvedPath);
131
+ if (spinner) {
132
+ const stackLabel = [stack.framework || stack.language || 'Unknown', stack.orm, stack.database]
133
+ .filter(Boolean).join(' + ');
134
+ spinner.succeed(`Detected: ${chalk.bold(stackLabel)}`);
135
+ }
136
+
137
+ // Step 2: Collect files (incremental or full)
138
+ const incremental = shouldUseIncremental(resolvedPath, options);
139
+ if (spinner) spinner.start(incremental ? 'Collecting changed files (incremental)...' : 'Collecting files...');
140
+
141
+ const extraIgnores = getFileIgnorePatterns(config);
142
+ const collectOpts = { silent, noCache: options.noCache || false, extraIgnores };
143
+ const files = incremental
144
+ ? await collectFilesIncremental(resolvedPath, collectOpts)
145
+ : await collectFiles(resolvedPath, collectOpts);
146
+
147
+ if (spinner) {
148
+ const modeLabel = incremental ? ' (incremental)' : '';
149
+ spinner.succeed(`Found ${chalk.bold(files.size)} files to scan${modeLabel}`);
150
+ }
151
+
152
+ // Detect which languages are present to skip irrelevant rules
153
+ const _detectedLangs = new Set();
154
+ for (const fp of files.keys()) {
155
+ if (/\.(js|jsx|ts|tsx|mjs|cjs)$/.test(fp)) _detectedLangs.add('js');
156
+ else if (/\.py$/.test(fp)) _detectedLangs.add('py');
157
+ else if (/\.go$/.test(fp)) _detectedLangs.add('go');
158
+ else if (/\.rb$/.test(fp)) _detectedLangs.add('rb');
159
+ else if (/\.php$/.test(fp)) _detectedLangs.add('php');
160
+ else if (/\.java$/.test(fp)) _detectedLangs.add('java');
161
+ else if (/\.(cs|csx)$/.test(fp)) _detectedLangs.add('cs');
162
+ else if (/\.rs$/.test(fp)) _detectedLangs.add('rust');
163
+ else if (/\.swift$/.test(fp)) _detectedLangs.add('swift');
164
+ else if (/\.dart$/.test(fp)) _detectedLangs.add('dart');
165
+ else if (/\.(sh|bash)$/.test(fp)) _detectedLangs.add('shell');
166
+ }
167
+
168
+ // Step 3: Load rules — filtered by detected languages AND user plan
169
+ // Only load rules for categories the user's plan allows (huge speed win)
170
+ let planCategories = null;
171
+ if (!options.allCategories && !options.strict) {
172
+ try {
173
+ const { loadAuth, getUserPlan } = await import('./auth.js');
174
+ const auth = loadAuth();
175
+ const plan = auth?.email ? getUserPlan(auth.email) : { categories: ['security', 'bugs'] };
176
+ if (plan.categories !== 'all') {
177
+ planCategories = plan.categories;
178
+ }
179
+ } catch { /* no auth — default to free plan categories */ }
180
+ }
181
+ const categoryFilter = planCategories ? planCategories.join(',') : (options.category || null);
182
+ const rules = loadRules({ ...options, _detectedLangs, category: categoryFilter });
183
+ const customRules = await loadCustomRules(resolvedPath);
184
+ if (customRules.length > 0) rules.push(...customRules);
185
+
186
+ if (!silent) {
187
+ const astStatus = isASTAvailable()
188
+ ? chalk.green('AST engine active')
189
+ : chalk.gray('AST engine unavailable — install tree-sitter for deeper analysis');
190
+ const customLabel = customRules.length > 0 ? ` + ${customRules.length} custom` : '';
191
+ console.log(chalk.gray(` Running ${rules.length} checks${customLabel} across ${new Set(rules.map(r => r.category)).size} categories (${astStatus}${chalk.gray(')')}...`));
192
+ console.log('');
193
+ }
194
+
195
+ // Detect monorepo
196
+ const mono = detectWorkspaces(resolvedPath);
197
+ if (!silent && mono.isMonorepo) {
198
+ console.log(chalk.gray(` Monorepo detected: ${mono.workspaces.length} workspaces`));
199
+ }
200
+
201
+ // Step 4: Run rules — parallel on multi-core machines
202
+ const context = { files, stack, profile: options.profile || false, _detectedLangs, _categoryFilter: categoryFilter, silent };
203
+ const useParallel = !options.profile && files.size >= 10;
204
+ const findings = useParallel
205
+ ? await runRulesParallel(rules, context)
206
+ : await runRules(rules, context);
207
+
208
+ // Step 5: Filter findings for library context
209
+ // Libraries (npm packages) shouldn't get app-level rules like "no CSRF", "no HSTS", etc.
210
+ const contextFiltered = stack.isLibrary ? filterLibraryFindings(findings) : findings;
211
+
212
+ // Step 6: Trace attack paths
213
+ const enrichedFindings = traceAttackPaths(contextFiltered, files, stack);
214
+
215
+ // Step 7: Apply config (filter ignored findings, override severities)
216
+ let filteredFindings = applyConfig(enrichedFindings, config);
217
+
218
+ // Step 7a: Deduplicate findings
219
+ // Many rules fire on every file for the same issue. Cap each rule to
220
+ // max 3 occurrences — enough to show the pattern without inflating counts.
221
+ const MAX_PER_RULE = 3;
222
+ const ruleCount = {};
223
+ filteredFindings = filteredFindings.filter(f => {
224
+ const id = f.ruleId || f.title;
225
+ ruleCount[id] = (ruleCount[id] || 0) + 1;
226
+ return ruleCount[id] <= MAX_PER_RULE;
227
+ });
228
+
229
+ // Step 7b: Baseline diff — only show new findings
230
+ if (options.baseline) {
231
+ const baselinePath = typeof options.baseline === 'string' ? options.baseline : '.doorman-baseline.json';
232
+ const baseline = loadBaseline(baselinePath);
233
+ if (baseline) {
234
+ const before = filteredFindings.length;
235
+ filteredFindings = diffFindings(filteredFindings, baseline);
236
+ if (!silent) {
237
+ console.log(chalk.gray(` Baseline: ${before - filteredFindings.length} known issues filtered, ${filteredFindings.length} new`));
238
+ }
239
+ }
240
+ }
241
+
242
+ // Step 7c: Save baseline if requested
243
+ if (options.saveBaseline) {
244
+ const baselinePath = typeof options.saveBaseline === 'string' ? options.saveBaseline : '.doorman-baseline.json';
245
+ saveBaseline(filteredFindings, baselinePath);
246
+ if (!silent) {
247
+ console.log(chalk.green(` Baseline saved to ${baselinePath} (${filteredFindings.length} findings)`));
248
+ }
249
+ }
250
+
251
+ // Step 8: Merge with cache for incremental scans (complete picture)
252
+ const isIncremental = shouldUseIncremental(resolvedPath, options);
253
+ if (isIncremental) {
254
+ const scannedFiles = [...files.keys()];
255
+ filteredFindings = mergeWithCache(resolvedPath, filteredFindings, scannedFiles);
256
+
257
+ // Re-apply cap after merge (cached findings may exceed cap)
258
+ const mergedRuleCount = {};
259
+ filteredFindings = filteredFindings.filter(f => {
260
+ const id = f.ruleId || f.title;
261
+ mergedRuleCount[id] = (mergedRuleCount[id] || 0) + 1;
262
+ return mergedRuleCount[id] <= MAX_PER_RULE;
263
+ });
264
+ }
265
+
266
+ // Step 9: Calculate score
267
+ const score = calculateScore(filteredFindings);
268
+
269
+ // Save to cache for future incremental merges and instant fix
270
+ saveScanCache(resolvedPath, filteredFindings, stack, score);
271
+
272
+ // Attach profile data
273
+ const profileData = findings._profile || null;
274
+
275
+ return { findings: filteredFindings, score, stack, fileCount: files.size, profileData };
276
+ })();
277
+
278
+ let scanTimer;
279
+ const timeoutPromise = new Promise((_, reject) => {
280
+ scanTimer = setTimeout(() => {
281
+ timedOut = true;
282
+ reject(new Error('SCAN_TIMEOUT'));
283
+ }, timeoutMs);
284
+ });
285
+
286
+ let result;
287
+ try {
288
+ result = await Promise.race([scanPromise, timeoutPromise]);
289
+ clearTimeout(scanTimer);
290
+ } catch (err) {
291
+ clearTimeout(scanTimer);
292
+ if (err.message === 'SCAN_TIMEOUT') {
293
+ if (!silent) {
294
+ console.log('');
295
+ console.log(chalk.yellow(`⚠ Scan timed out after ${timeoutMs / 1000}s — returning partial results`));
296
+ }
297
+ result = { findings: [], score: 0, stack: {}, fileCount: 0, partial: true };
298
+ } else {
299
+ throw err;
300
+ }
301
+ }
302
+
303
+ // Timing summary
304
+ const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
305
+ if (!silent) {
306
+ console.log('');
307
+ console.log(chalk.gray(`Scanned ${result.fileCount} files in ${elapsed}s`));
308
+ }
309
+
310
+ // Print profiling data if requested
311
+ if (options.profile && result.profileData && !silent) {
312
+ console.log('');
313
+ console.log(chalk.bold(' Performance Profile (slowest rules):'));
314
+ const top = result.profileData.slice(0, 15);
315
+ for (const entry of top) {
316
+ const bar = '█'.repeat(Math.min(Math.ceil(entry.ms / 2), 40));
317
+ console.log(chalk.gray(` ${entry.id.padEnd(25)} ${entry.ms.toFixed(1).padStart(7)}ms ${entry.findings} findings ${bar}`));
318
+ }
319
+ const totalMs = result.profileData.reduce((s, e) => s + e.ms, 0);
320
+ console.log(chalk.gray(` ${'TOTAL'.padEnd(25)} ${totalMs.toFixed(1).padStart(7)}ms`));
321
+ }
322
+
323
+ // Record metrics
324
+ recordScan(resolvedPath, { score: result.score, findings: result.findings, stack: result.stack });
325
+ sendTelemetry(resolvedPath, result, result.stack).catch(() => {});
326
+
327
+ // Step 6: Output report
328
+ const reportMeta = {
329
+ filesScanned: result.fileCount,
330
+ scanDurationSeconds: parseFloat(elapsed),
331
+ mode: shouldUseIncremental(resolvedPath, options) ? 'incremental' : 'full',
332
+ ...(result.partial && { partial: true }),
333
+ };
334
+
335
+ if (options.json) {
336
+ const output = generateJSON(result.findings, result.stack, result.score, reportMeta);
337
+ console.log(JSON.stringify(output, null, 2));
338
+ } else if (options.sarif) {
339
+ const outPath = typeof options.sarif === 'string' ? options.sarif : 'doorman.sarif.json';
340
+ writeSARIF(outPath, result.findings, result.stack, result.score, reportMeta);
341
+ console.log(`SARIF report written to ${outPath}`);
342
+ console.log('Upload to GitHub Code Scanning: gh api repos/{owner}/{repo}/code-scanning/sarifs -f "sarif=@' + outPath + '"');
343
+ } else if (options.html) {
344
+ const outPath = typeof options.html === 'string' ? options.html : 'doorman-report.html';
345
+ writeHTML(outPath, result.findings, result.stack, result.score, reportMeta);
346
+ console.log(`HTML report written to ${outPath}`);
347
+ } else if (options.ci) {
348
+ // CI mode: only print the one-line summary
349
+ printSummaryLine(result.findings, result.score, { minScore: options.minScore });
350
+ } else if (options.quiet) {
351
+ // Quiet mode: only print the one-line summary
352
+ printSummaryLine(result.findings, result.score, { minScore: options.minScore });
353
+ } else if (!silent) {
354
+ printReport(result.findings, result.stack, result.score, options);
355
+ }
356
+
357
+ // Determine exit code
358
+ const minScore = options.minScore ? parseInt(options.minScore, 10) : null;
359
+ const counts = { critical: 0, high: 0 };
360
+ for (const f of result.findings) {
361
+ if (f.severity === 'critical') counts.critical++;
362
+ if (f.severity === 'high') counts.high++;
363
+ }
364
+
365
+ let exitCode = 0;
366
+ if (minScore != null) {
367
+ if (result.score < minScore || counts.critical > 0 || counts.high > 0) {
368
+ exitCode = 1;
369
+ }
370
+ } else if (options.ci) {
371
+ // CI without explicit min-score: fail on critical/high
372
+ if (counts.critical > 0 || counts.high > 0) {
373
+ exitCode = 1;
374
+ }
375
+ }
376
+
377
+ return {
378
+ findings: result.findings,
379
+ score: result.score,
380
+ stack: result.stack,
381
+ exitCode,
382
+ ...(result.partial && { partial: true }),
383
+ };
384
+ }