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.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/bin/doorman.js +444 -0
- package/package.json +74 -0
- package/src/ai-fixer.js +559 -0
- package/src/ast-scanner.js +434 -0
- package/src/auth.js +149 -0
- package/src/baseline.js +48 -0
- package/src/compliance.js +539 -0
- package/src/config.js +466 -0
- package/src/custom-rules.js +32 -0
- package/src/dashboard.js +202 -0
- package/src/detector.js +142 -0
- package/src/fix-engine.js +48 -0
- package/src/fix-registry-extra.js +95 -0
- package/src/fix-registry-go-rust.js +77 -0
- package/src/fix-registry-java-csharp.js +77 -0
- package/src/fix-registry-js.js +99 -0
- package/src/fix-registry-mcp-ai.js +57 -0
- package/src/fix-registry-python.js +87 -0
- package/src/fixer-ruby-php.js +608 -0
- package/src/fixer.js +2113 -0
- package/src/hooks.js +115 -0
- package/src/ignore.js +176 -0
- package/src/index.js +384 -0
- package/src/metrics.js +126 -0
- package/src/monorepo.js +65 -0
- package/src/presets.js +54 -0
- package/src/reporter.js +975 -0
- package/src/rule-worker.js +36 -0
- package/src/rules/ast-rules.js +756 -0
- package/src/rules/bugs/accessibility.js +235 -0
- package/src/rules/bugs/ai-codegen-fixable.js +172 -0
- package/src/rules/bugs/ai-codegen.js +365 -0
- package/src/rules/bugs/code-smell-bugs.js +247 -0
- package/src/rules/bugs/crypto-bugs.js +195 -0
- package/src/rules/bugs/docker-bugs.js +158 -0
- package/src/rules/bugs/general.js +361 -0
- package/src/rules/bugs/go-bugs.js +279 -0
- package/src/rules/bugs/index.js +73 -0
- package/src/rules/bugs/js-api.js +257 -0
- package/src/rules/bugs/js-array-object.js +210 -0
- package/src/rules/bugs/js-async-fixable.js +223 -0
- package/src/rules/bugs/js-async.js +211 -0
- package/src/rules/bugs/js-closure-scope.js +182 -0
- package/src/rules/bugs/js-database.js +203 -0
- package/src/rules/bugs/js-error-handling.js +148 -0
- package/src/rules/bugs/js-logic.js +261 -0
- package/src/rules/bugs/js-memory.js +214 -0
- package/src/rules/bugs/js-node.js +361 -0
- package/src/rules/bugs/js-react.js +373 -0
- package/src/rules/bugs/js-regex.js +200 -0
- package/src/rules/bugs/js-state.js +272 -0
- package/src/rules/bugs/js-type-coercion.js +318 -0
- package/src/rules/bugs/nextjs-bugs.js +242 -0
- package/src/rules/bugs/nextjs-fixable.js +120 -0
- package/src/rules/bugs/node-fixable.js +178 -0
- package/src/rules/bugs/python-advanced.js +245 -0
- package/src/rules/bugs/python-fixable.js +98 -0
- package/src/rules/bugs/python.js +284 -0
- package/src/rules/bugs/react-fixable.js +207 -0
- package/src/rules/bugs/ruby-bugs.js +182 -0
- package/src/rules/bugs/shell-bugs.js +181 -0
- package/src/rules/bugs/silent-failures.js +261 -0
- package/src/rules/bugs/ts-bugs.js +235 -0
- package/src/rules/bugs/unused-vars.js +65 -0
- package/src/rules/compliance/accessibility-ext.js +468 -0
- package/src/rules/compliance/education.js +322 -0
- package/src/rules/compliance/financial.js +421 -0
- package/src/rules/compliance/frameworks.js +507 -0
- package/src/rules/compliance/healthcare.js +520 -0
- package/src/rules/compliance/index.js +2714 -0
- package/src/rules/compliance/regional-eu.js +480 -0
- package/src/rules/compliance/regional-international.js +903 -0
- package/src/rules/cost/index.js +1993 -0
- package/src/rules/data/index.js +2503 -0
- package/src/rules/dependencies/index.js +1684 -0
- package/src/rules/deployment/index.js +2050 -0
- package/src/rules/index.js +71 -0
- package/src/rules/infrastructure/index.js +3048 -0
- package/src/rules/performance/index.js +3455 -0
- package/src/rules/quality/index.js +3175 -0
- package/src/rules/reliability/index.js +3040 -0
- package/src/rules/scope-rules.js +815 -0
- package/src/rules/security/ai-api.js +1177 -0
- package/src/rules/security/auth.js +1328 -0
- package/src/rules/security/cors.js +127 -0
- package/src/rules/security/crypto.js +527 -0
- package/src/rules/security/csharp.js +862 -0
- package/src/rules/security/csrf.js +193 -0
- package/src/rules/security/dart.js +835 -0
- package/src/rules/security/deserialization.js +291 -0
- package/src/rules/security/file-upload.js +187 -0
- package/src/rules/security/go.js +850 -0
- package/src/rules/security/headers.js +235 -0
- package/src/rules/security/index.js +65 -0
- package/src/rules/security/injection.js +1639 -0
- package/src/rules/security/mcp-server.js +71 -0
- package/src/rules/security/misconfiguration.js +660 -0
- package/src/rules/security/oauth-jwt.js +329 -0
- package/src/rules/security/path-traversal.js +295 -0
- package/src/rules/security/php.js +1054 -0
- package/src/rules/security/prototype-pollution.js +283 -0
- package/src/rules/security/rate-limiting.js +208 -0
- package/src/rules/security/ruby.js +1061 -0
- package/src/rules/security/rust.js +693 -0
- package/src/rules/security/secrets.js +747 -0
- package/src/rules/security/shell.js +647 -0
- package/src/rules/security/ssrf.js +298 -0
- package/src/rules/security/supply-chain-advanced.js +393 -0
- package/src/rules/security/supply-chain.js +734 -0
- package/src/rules/security/swift.js +835 -0
- package/src/rules/security/taint.js +27 -0
- package/src/rules/security/xss.js +520 -0
- package/src/scan-cache.js +71 -0
- package/src/scanner.js +710 -0
- package/src/scope-analyzer.js +685 -0
- package/src/share.js +88 -0
- package/src/taint.js +300 -0
- package/src/telemetry.js +183 -0
- package/src/tracer.js +190 -0
- package/src/upload.js +35 -0
- 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
|
+
}
|