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/config.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { resolve, relative } from 'path';
|
|
3
|
+
import { minimatch } from 'minimatch';
|
|
4
|
+
import { getPreset } from './presets.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Severity ordering (lower index = more severe)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Defaults
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CONFIG = {
|
|
17
|
+
rules: {},
|
|
18
|
+
categories: [],
|
|
19
|
+
severity: 'low',
|
|
20
|
+
ignore: [],
|
|
21
|
+
extends: 'recommended',
|
|
22
|
+
// Legacy fields kept for backward compat with old .doormanrc.json files
|
|
23
|
+
_legacyIgnore: [], // old-style ignore entries [{ruleId, file}]
|
|
24
|
+
_legacySeverity: {}, // old-style severity overrides {ruleId: level}
|
|
25
|
+
thresholds: {
|
|
26
|
+
minScore: 70,
|
|
27
|
+
failOn: 'critical',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Deep-merge source into target (one level for arrays, shallow for objects).
|
|
37
|
+
*/
|
|
38
|
+
function merge(target, source) {
|
|
39
|
+
const out = { ...target };
|
|
40
|
+
for (const key of Object.keys(source)) {
|
|
41
|
+
if (Array.isArray(source[key])) {
|
|
42
|
+
out[key] = [...(target[key] || []), ...source[key]];
|
|
43
|
+
} else if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
44
|
+
out[key] = { ...(target[key] || {}), ...source[key] };
|
|
45
|
+
} else {
|
|
46
|
+
out[key] = source[key];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info']);
|
|
53
|
+
const VALID_RULE_LEVELS = new Set(['off', 'warn', 'error']);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Basic structural validation — throws on bad shapes.
|
|
57
|
+
*/
|
|
58
|
+
function validate(raw) {
|
|
59
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
60
|
+
throw new Error('.doormanrc: config must be a JSON object');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// New-style "rules" object
|
|
64
|
+
if (raw.rules !== undefined) {
|
|
65
|
+
if (typeof raw.rules !== 'object' || Array.isArray(raw.rules)) {
|
|
66
|
+
throw new Error('.doormanrc: "rules" must be an object');
|
|
67
|
+
}
|
|
68
|
+
for (const [ruleId, level] of Object.entries(raw.rules)) {
|
|
69
|
+
if (!VALID_RULE_LEVELS.has(level)) {
|
|
70
|
+
throw new Error(`.doormanrc: invalid rule level "${level}" for "${ruleId}" — use "off", "warn", or "error"`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Categories
|
|
76
|
+
if (raw.categories !== undefined) {
|
|
77
|
+
if (!Array.isArray(raw.categories)) {
|
|
78
|
+
throw new Error('.doormanrc: "categories" must be an array');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Severity threshold (string)
|
|
83
|
+
if (raw.severity !== undefined && typeof raw.severity === 'string') {
|
|
84
|
+
if (!VALID_SEVERITIES.has(raw.severity)) {
|
|
85
|
+
throw new Error(`.doormanrc: invalid severity threshold "${raw.severity}"`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Ignore (array of glob strings)
|
|
90
|
+
if (raw.ignore !== undefined) {
|
|
91
|
+
if (!Array.isArray(raw.ignore)) {
|
|
92
|
+
throw new Error('.doormanrc: "ignore" must be an array');
|
|
93
|
+
}
|
|
94
|
+
// Support both new-style (strings) and legacy (objects with ruleId)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Extends
|
|
98
|
+
if (raw.extends !== undefined) {
|
|
99
|
+
if (typeof raw.extends !== 'string') {
|
|
100
|
+
throw new Error('.doormanrc: "extends" must be a string');
|
|
101
|
+
}
|
|
102
|
+
if (!getPreset(raw.extends)) {
|
|
103
|
+
throw new Error(`.doormanrc: unknown preset "${raw.extends}" — use "recommended", "strict", or "minimal"`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Legacy severity overrides (object form)
|
|
108
|
+
if (raw.severity !== undefined && typeof raw.severity === 'object') {
|
|
109
|
+
if (Array.isArray(raw.severity)) {
|
|
110
|
+
throw new Error('.doormanrc: "severity" must be a string or object');
|
|
111
|
+
}
|
|
112
|
+
for (const [ruleId, level] of Object.entries(raw.severity)) {
|
|
113
|
+
if (!VALID_SEVERITIES.has(level)) {
|
|
114
|
+
throw new Error(`.doormanrc: invalid severity "${level}" for rule "${ruleId}"`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (raw.thresholds !== undefined) {
|
|
120
|
+
if (typeof raw.thresholds !== 'object' || Array.isArray(raw.thresholds)) {
|
|
121
|
+
throw new Error('.doormanrc: "thresholds" must be an object');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Normalize raw config into the canonical shape, handling legacy formats.
|
|
128
|
+
*/
|
|
129
|
+
function normalize(raw) {
|
|
130
|
+
const out = {};
|
|
131
|
+
|
|
132
|
+
// "extends" — apply preset as base, then overlay user settings
|
|
133
|
+
if (raw.extends) out.extends = raw.extends;
|
|
134
|
+
|
|
135
|
+
// "rules" — per-rule level overrides
|
|
136
|
+
if (raw.rules) out.rules = { ...raw.rules };
|
|
137
|
+
|
|
138
|
+
// "categories"
|
|
139
|
+
if (raw.categories) out.categories = [...raw.categories];
|
|
140
|
+
|
|
141
|
+
// "severity" — can be a string (threshold) or object (legacy per-rule overrides)
|
|
142
|
+
if (typeof raw.severity === 'string') {
|
|
143
|
+
out.severity = raw.severity;
|
|
144
|
+
} else if (typeof raw.severity === 'object' && raw.severity !== null) {
|
|
145
|
+
// Legacy format: severity overrides per rule
|
|
146
|
+
out._legacySeverity = { ...raw.severity };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// "ignore" — can be array of strings (new) or array of objects (legacy)
|
|
150
|
+
if (Array.isArray(raw.ignore)) {
|
|
151
|
+
const stringIgnores = [];
|
|
152
|
+
const legacyIgnores = [];
|
|
153
|
+
for (const entry of raw.ignore) {
|
|
154
|
+
if (typeof entry === 'string') {
|
|
155
|
+
stringIgnores.push(entry);
|
|
156
|
+
} else if (entry && typeof entry === 'object' && entry.ruleId) {
|
|
157
|
+
legacyIgnores.push(entry);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (stringIgnores.length > 0) out.ignore = stringIgnores;
|
|
161
|
+
if (legacyIgnores.length > 0) out._legacyIgnore = legacyIgnores;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// "thresholds"
|
|
165
|
+
if (raw.thresholds) out.thresholds = { ...raw.thresholds };
|
|
166
|
+
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Public API
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Load configuration.
|
|
176
|
+
*
|
|
177
|
+
* Searches (in order) unless `configPath` is specified:
|
|
178
|
+
* 1. .doormanrc (JSON)
|
|
179
|
+
* 2. .doormanrc.json (JSON)
|
|
180
|
+
* 3. doorman.config.js (ESM default export)
|
|
181
|
+
* 4. package.json ("doorman" key)
|
|
182
|
+
*
|
|
183
|
+
* Returns the merged config (preset + user overrides) and the path it was loaded from.
|
|
184
|
+
*/
|
|
185
|
+
export async function loadConfig(targetPath, configPath) {
|
|
186
|
+
const dir = resolve(targetPath);
|
|
187
|
+
|
|
188
|
+
let raw = null;
|
|
189
|
+
let loadedFrom = null;
|
|
190
|
+
|
|
191
|
+
if (configPath) {
|
|
192
|
+
// Explicit --config flag
|
|
193
|
+
const absPath = resolve(configPath);
|
|
194
|
+
if (!existsSync(absPath)) {
|
|
195
|
+
throw new Error(`Config file not found: ${absPath}`);
|
|
196
|
+
}
|
|
197
|
+
if (absPath.endsWith('.js')) {
|
|
198
|
+
const mod = await import(`file://${absPath}`);
|
|
199
|
+
raw = mod.default ?? mod;
|
|
200
|
+
} else {
|
|
201
|
+
const text = readFileSync(absPath, 'utf-8');
|
|
202
|
+
try {
|
|
203
|
+
raw = JSON.parse(text);
|
|
204
|
+
} catch {
|
|
205
|
+
throw new Error(`Failed to parse ${absPath} as JSON`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
loadedFrom = absPath;
|
|
209
|
+
} else {
|
|
210
|
+
const candidates = [
|
|
211
|
+
{ path: resolve(dir, '.doormanrc'), type: 'json' },
|
|
212
|
+
{ path: resolve(dir, '.doormanrc.json'), type: 'json' },
|
|
213
|
+
{ path: resolve(dir, 'doorman.config.js'), type: 'esm' },
|
|
214
|
+
{ path: resolve(dir, 'package.json'), type: 'pkg' },
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
for (const candidate of candidates) {
|
|
218
|
+
if (!existsSync(candidate.path)) continue;
|
|
219
|
+
|
|
220
|
+
if (candidate.type === 'json') {
|
|
221
|
+
const text = readFileSync(candidate.path, 'utf-8');
|
|
222
|
+
try {
|
|
223
|
+
raw = JSON.parse(text);
|
|
224
|
+
} catch {
|
|
225
|
+
throw new Error(`Failed to parse ${candidate.path} as JSON`);
|
|
226
|
+
}
|
|
227
|
+
loadedFrom = candidate.path;
|
|
228
|
+
break;
|
|
229
|
+
} else if (candidate.type === 'esm') {
|
|
230
|
+
const mod = await import(`file://${candidate.path}`);
|
|
231
|
+
raw = mod.default ?? mod;
|
|
232
|
+
loadedFrom = candidate.path;
|
|
233
|
+
break;
|
|
234
|
+
} else if (candidate.type === 'pkg') {
|
|
235
|
+
const text = readFileSync(candidate.path, 'utf-8');
|
|
236
|
+
try {
|
|
237
|
+
const pkg = JSON.parse(text);
|
|
238
|
+
if (pkg.doorman && typeof pkg.doorman === 'object') {
|
|
239
|
+
raw = pkg.doorman;
|
|
240
|
+
loadedFrom = candidate.path;
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// Ignore unparseable package.json
|
|
244
|
+
}
|
|
245
|
+
if (raw) break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// No config found — return defaults with recommended preset applied
|
|
251
|
+
if (!raw) {
|
|
252
|
+
const preset = getPreset('recommended');
|
|
253
|
+
const config = merge({ ...DEFAULT_CONFIG }, preset);
|
|
254
|
+
config._loadedFrom = null;
|
|
255
|
+
return config;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
validate(raw);
|
|
259
|
+
|
|
260
|
+
// Normalize into canonical shape
|
|
261
|
+
const normalized = normalize(raw);
|
|
262
|
+
|
|
263
|
+
// Resolve preset base
|
|
264
|
+
const presetName = normalized.extends || 'recommended';
|
|
265
|
+
const preset = getPreset(presetName) || {};
|
|
266
|
+
|
|
267
|
+
// Merge: defaults ← preset ← user config
|
|
268
|
+
const config = merge(merge({ ...DEFAULT_CONFIG }, preset), normalized);
|
|
269
|
+
config._loadedFrom = loadedFrom;
|
|
270
|
+
|
|
271
|
+
return config;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Check whether a ruleId matches a pattern (supports trailing wildcards).
|
|
276
|
+
* E.g. "PERF-*" matches "PERF-001", "PERF-MEM-002", etc.
|
|
277
|
+
*/
|
|
278
|
+
export function ruleMatchesPattern(ruleId, pattern) {
|
|
279
|
+
if (pattern === ruleId) return true;
|
|
280
|
+
if (pattern.endsWith('*')) {
|
|
281
|
+
const prefix = pattern.slice(0, -1);
|
|
282
|
+
return ruleId.startsWith(prefix);
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get the effective rule level for a given ruleId based on the rules config.
|
|
289
|
+
* Returns "off", "warn", or "error".
|
|
290
|
+
*/
|
|
291
|
+
export function getRuleLevel(ruleId, rulesConfig) {
|
|
292
|
+
if (!rulesConfig || Object.keys(rulesConfig).length === 0) return 'error';
|
|
293
|
+
|
|
294
|
+
// Exact match takes priority
|
|
295
|
+
if (rulesConfig[ruleId] !== undefined) return rulesConfig[ruleId];
|
|
296
|
+
|
|
297
|
+
// Wildcard patterns
|
|
298
|
+
for (const [pattern, level] of Object.entries(rulesConfig)) {
|
|
299
|
+
if (pattern.includes('*') && ruleMatchesPattern(ruleId, pattern)) {
|
|
300
|
+
return level;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return 'error';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Check whether a finding passes the severity threshold.
|
|
309
|
+
*/
|
|
310
|
+
export function passesSeverityThreshold(findingSeverity, threshold) {
|
|
311
|
+
if (!threshold || threshold === 'info') return true;
|
|
312
|
+
const findingIdx = SEVERITY_ORDER.indexOf(findingSeverity);
|
|
313
|
+
const thresholdIdx = SEVERITY_ORDER.indexOf(threshold);
|
|
314
|
+
if (findingIdx === -1 || thresholdIdx === -1) return true;
|
|
315
|
+
return findingIdx <= thresholdIdx;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Check whether a finding's category is in the allowed categories list.
|
|
320
|
+
*/
|
|
321
|
+
export function passesCategory(findingCategory, categories) {
|
|
322
|
+
if (!categories || categories.length === 0) return true;
|
|
323
|
+
return categories.includes(findingCategory);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Check whether a single finding should be ignored based on the config's
|
|
328
|
+
* legacy ignore list. Supports optional glob patterns in the `file` field
|
|
329
|
+
* of an ignore rule.
|
|
330
|
+
*/
|
|
331
|
+
export function shouldIgnore(finding, config) {
|
|
332
|
+
const ignoreList = config._legacyIgnore || [];
|
|
333
|
+
if (ignoreList.length === 0) return false;
|
|
334
|
+
|
|
335
|
+
for (const rule of ignoreList) {
|
|
336
|
+
if (rule.ruleId !== finding.ruleId) continue;
|
|
337
|
+
|
|
338
|
+
// If the ignore rule is scoped to a file pattern, check it
|
|
339
|
+
if (rule.file) {
|
|
340
|
+
const findingFile = finding.file || '';
|
|
341
|
+
if (
|
|
342
|
+
minimatch(findingFile, rule.file, { dot: true }) ||
|
|
343
|
+
findingFile === rule.file
|
|
344
|
+
) {
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// No file scope — blanket ignore for this ruleId
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Apply the full config to a findings array:
|
|
359
|
+
* 1. Filter out ignored findings (legacy ignore list)
|
|
360
|
+
* 2. Filter by rule levels (off/warn)
|
|
361
|
+
* 3. Filter by category
|
|
362
|
+
* 4. Filter by severity threshold
|
|
363
|
+
* 5. Override severities where configured (legacy)
|
|
364
|
+
* 6. Downgrade findings where rule level is "warn"
|
|
365
|
+
*
|
|
366
|
+
* Returns a new array (does not mutate the input).
|
|
367
|
+
*/
|
|
368
|
+
export function applyConfig(findings, config) {
|
|
369
|
+
let filtered = findings;
|
|
370
|
+
|
|
371
|
+
// 1. Legacy ignore entries
|
|
372
|
+
filtered = filtered.filter((f) => !shouldIgnore(f, config));
|
|
373
|
+
|
|
374
|
+
// 2. Rule level: "off" = drop entirely, "warn" = downgrade later
|
|
375
|
+
if (config.rules && Object.keys(config.rules).length > 0) {
|
|
376
|
+
filtered = filtered.filter((f) => {
|
|
377
|
+
const level = getRuleLevel(f.ruleId, config.rules);
|
|
378
|
+
return level !== 'off';
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 3. Category filter
|
|
383
|
+
if (config.categories && config.categories.length > 0) {
|
|
384
|
+
filtered = filtered.filter((f) => passesCategory(f.category, config.categories));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 4. Severity threshold
|
|
388
|
+
if (config.severity && config.severity !== 'info') {
|
|
389
|
+
filtered = filtered.filter((f) => passesSeverityThreshold(f.severity, config.severity));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 5. Legacy severity overrides
|
|
393
|
+
const legacySev = config._legacySeverity || {};
|
|
394
|
+
if (Object.keys(legacySev).length > 0) {
|
|
395
|
+
filtered = filtered.map((f) => {
|
|
396
|
+
const override = legacySev[f.ruleId];
|
|
397
|
+
if (override) {
|
|
398
|
+
return { ...f, severity: override };
|
|
399
|
+
}
|
|
400
|
+
return f;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 6. Rule-level "warn" → downgrade severity to "info"
|
|
405
|
+
if (config.rules && Object.keys(config.rules).length > 0) {
|
|
406
|
+
filtered = filtered.map((f) => {
|
|
407
|
+
const level = getRuleLevel(f.ruleId, config.rules);
|
|
408
|
+
if (level === 'warn') {
|
|
409
|
+
return { ...f, severity: 'info', _downgraded: true };
|
|
410
|
+
}
|
|
411
|
+
return f;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return filtered;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Get the additional file ignore patterns from the RC config.
|
|
420
|
+
* These are glob patterns to be appended to the scanner's ignore list.
|
|
421
|
+
*/
|
|
422
|
+
export function getFileIgnorePatterns(config) {
|
|
423
|
+
return config.ignore || [];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Add (or append) an ignore entry and persist to `.doormanrc.json`.
|
|
428
|
+
*
|
|
429
|
+
* Creates the file if it does not exist.
|
|
430
|
+
*/
|
|
431
|
+
export function addIgnore(targetPath, ruleId, file, reason) {
|
|
432
|
+
const dir = resolve(targetPath);
|
|
433
|
+
const configPath = resolve(dir, '.doormanrc.json');
|
|
434
|
+
|
|
435
|
+
let config = { ignore: [] };
|
|
436
|
+
if (existsSync(configPath)) {
|
|
437
|
+
const text = readFileSync(configPath, 'utf-8');
|
|
438
|
+
try {
|
|
439
|
+
config = JSON.parse(text);
|
|
440
|
+
} catch {
|
|
441
|
+
throw new Error(`Failed to parse ${configPath} as JSON`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (!Array.isArray(config.ignore)) {
|
|
446
|
+
config.ignore = [];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const entry = { ruleId };
|
|
450
|
+
if (file) entry.file = file;
|
|
451
|
+
if (reason) entry.reason = reason;
|
|
452
|
+
|
|
453
|
+
// Avoid exact duplicates
|
|
454
|
+
const isDuplicate = config.ignore.some(
|
|
455
|
+
(e) => e.ruleId === ruleId && (e.file || null) === (file || null),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
if (!isDuplicate) {
|
|
459
|
+
config.ignore.push(entry);
|
|
460
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return config;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export { SEVERITY_ORDER };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Custom rule loader: loads .doorman/rules/*.js files
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load custom rules from .doorman/rules/ directory.
|
|
7
|
+
* Each file should export default an array of rule objects.
|
|
8
|
+
*/
|
|
9
|
+
export async function loadCustomRules(targetPath) {
|
|
10
|
+
const rulesDir = join(targetPath, '.doorman', 'rules');
|
|
11
|
+
if (!existsSync(rulesDir)) return [];
|
|
12
|
+
|
|
13
|
+
const files = readdirSync(rulesDir).filter(f => f.endsWith('.js'));
|
|
14
|
+
const rules = [];
|
|
15
|
+
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
try {
|
|
18
|
+
const mod = await import(join(rulesDir, file));
|
|
19
|
+
const loaded = mod.default || mod.rules || [];
|
|
20
|
+
const arr = Array.isArray(loaded) ? loaded : [loaded];
|
|
21
|
+
for (const rule of arr) {
|
|
22
|
+
if (rule.id && typeof rule.check === 'function') {
|
|
23
|
+
rule._custom = true;
|
|
24
|
+
rules.push(rule);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.warn(`[custom-rules] Failed to load ${file}: ${err.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return rules;
|
|
32
|
+
}
|
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join, resolve, basename } from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { loadMetrics } from './metrics.js';
|
|
5
|
+
import { getScanUsage } from './auth.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate a standalone HTML dashboard from local .doorman/metrics.json
|
|
9
|
+
* and open it in the browser.
|
|
10
|
+
*/
|
|
11
|
+
export function openDashboard(targetPath) {
|
|
12
|
+
const resolvedPath = resolve(targetPath);
|
|
13
|
+
const projectName = basename(resolvedPath);
|
|
14
|
+
const metrics = loadMetrics(resolvedPath);
|
|
15
|
+
|
|
16
|
+
if (!metrics.scans || metrics.scans.length === 0) {
|
|
17
|
+
return { error: 'No scans yet. Run `npx getdoorman check` first.' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const html = generateDashboardHTML(projectName, metrics);
|
|
21
|
+
const outPath = join(resolvedPath, '.doorman', 'dashboard.html');
|
|
22
|
+
writeFileSync(outPath, html);
|
|
23
|
+
|
|
24
|
+
// Try to open in browser
|
|
25
|
+
try {
|
|
26
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
27
|
+
execSync(`${cmd} ${outPath}`, { stdio: 'ignore' });
|
|
28
|
+
} catch { /* ignore */ }
|
|
29
|
+
|
|
30
|
+
return { path: outPath };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function generateDashboardHTML(projectName, metrics) {
|
|
34
|
+
const scans = metrics.scans;
|
|
35
|
+
const scores = metrics.scores || [];
|
|
36
|
+
const fixes = metrics.fixes || [];
|
|
37
|
+
const latest = scans[scans.length - 1];
|
|
38
|
+
const previous = scans.length > 1 ? scans[scans.length - 2] : null;
|
|
39
|
+
const scoreDelta = previous ? latest.score - previous.score : 0;
|
|
40
|
+
|
|
41
|
+
const totalFixes = fixes.reduce((sum, f) => sum + f.applied, 0);
|
|
42
|
+
|
|
43
|
+
// Plan info
|
|
44
|
+
const usage = getScanUsage();
|
|
45
|
+
const planLabel = usage.plan || 'Free';
|
|
46
|
+
const scansUsed = usage.scansUsed || 0;
|
|
47
|
+
const maxScans = usage.maxScans === Infinity ? '∞' : usage.maxScans;
|
|
48
|
+
|
|
49
|
+
const scoreColor = latest.score >= 70 ? '#10b981' : latest.score >= 40 ? '#f59e0b' : '#ef4444';
|
|
50
|
+
const statusText = latest.score >= 70 ? 'Safe to Launch' : latest.score >= 40 ? 'Needs Work' : 'Not Safe';
|
|
51
|
+
|
|
52
|
+
const scoresJSON = JSON.stringify(scores.map(s => ({ t: s.timestamp, s: s.score })));
|
|
53
|
+
const sevData = latest.bySeverity || { critical: 0, high: 0, medium: 0, low: 0 };
|
|
54
|
+
|
|
55
|
+
return `<!DOCTYPE html>
|
|
56
|
+
<html lang="en">
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="UTF-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
60
|
+
<title>${esc(projectName)} — Doorman Dashboard</title>
|
|
61
|
+
<style>
|
|
62
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
63
|
+
:root{--bg:#0b0e14;--surface:#12161f;--surface2:#1a1f2e;--border:#252b3b;--text:#e2e8f0;--text-dim:#8892a8;--text-faint:#5a6478;--green:#10b981;--red:#ef4444;--yellow:#f59e0b;--critical:#ff2d55;--high:#ff6b35;--medium:#ffb800;--low:#3b82f6;--radius:12px;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
|
64
|
+
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;line-height:1.5}
|
|
65
|
+
.container{max-width:1000px;margin:0 auto;padding:32px 24px}
|
|
66
|
+
header{text-align:center;margin-bottom:40px}
|
|
67
|
+
.logo{font-size:14px;font-weight:700;color:var(--text-dim);letter-spacing:2px;text-transform:uppercase}
|
|
68
|
+
.logo span{color:var(--green)}
|
|
69
|
+
.project{font-size:24px;font-weight:800;margin-top:8px}
|
|
70
|
+
.grid{display:grid;gap:20px;margin-bottom:20px}
|
|
71
|
+
.grid-3{grid-template-columns:1fr 1fr 1fr}
|
|
72
|
+
.grid-2{grid-template-columns:1fr 1fr}
|
|
73
|
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:28px}
|
|
74
|
+
.card h2{font-size:12px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text-dim);margin-bottom:16px;font-weight:600}
|
|
75
|
+
.score-card{text-align:center}
|
|
76
|
+
.score-num{font-size:72px;font-weight:800;color:${scoreColor};line-height:1;letter-spacing:-3px}
|
|
77
|
+
.score-num span{font-size:24px;color:var(--text-faint);font-weight:400}
|
|
78
|
+
.score-status{font-size:14px;font-weight:700;color:${scoreColor};margin-top:6px;text-transform:uppercase;letter-spacing:1px}
|
|
79
|
+
.score-delta{display:inline-block;padding:4px 12px;border-radius:16px;font-size:13px;font-weight:600;margin-top:12px}
|
|
80
|
+
.delta-up{background:rgba(16,185,129,0.15);color:var(--green)}
|
|
81
|
+
.delta-down{background:rgba(239,68,68,0.15);color:var(--red)}
|
|
82
|
+
.delta-flat{background:var(--surface2);color:var(--text-dim)}
|
|
83
|
+
.stat{text-align:center}
|
|
84
|
+
.stat-val{font-size:32px;font-weight:800;color:var(--text)}
|
|
85
|
+
.stat-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-dim);margin-top:4px}
|
|
86
|
+
.sev-row{display:flex;align-items:center;gap:12px;margin-bottom:12px}
|
|
87
|
+
.sev-label{width:65px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px}
|
|
88
|
+
.sev-bar-bg{flex:1;background:var(--surface2);border-radius:6px;height:24px;overflow:hidden}
|
|
89
|
+
.sev-bar{height:100%;border-radius:6px;transition:width 0.6s ease}
|
|
90
|
+
.sev-count{font-size:13px;font-weight:600;width:30px;text-align:right}
|
|
91
|
+
.chart-area{width:100%;height:200px}
|
|
92
|
+
.chart-area svg{width:100%;height:100%}
|
|
93
|
+
.scan-row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px}
|
|
94
|
+
.scan-score{font-weight:700}
|
|
95
|
+
footer{text-align:center;padding:40px 0 20px;color:var(--text-faint);font-size:12px}
|
|
96
|
+
@media(max-width:768px){.grid-3,.grid-2{grid-template-columns:1fr}}
|
|
97
|
+
</style>
|
|
98
|
+
</head>
|
|
99
|
+
<body>
|
|
100
|
+
<div class="container">
|
|
101
|
+
<header>
|
|
102
|
+
<div class="logo">Door<span>man</span> Dashboard</div>
|
|
103
|
+
<div class="project">${esc(projectName)}</div>
|
|
104
|
+
<div style="margin-top:8px;font-size:13px;color:#5a6478">${esc(planLabel)} plan — ${scansUsed}/${maxScans} scans this month</div>
|
|
105
|
+
</header>
|
|
106
|
+
|
|
107
|
+
<div class="grid grid-3">
|
|
108
|
+
<div class="card score-card">
|
|
109
|
+
<h2>Safety Score</h2>
|
|
110
|
+
<div class="score-num">${latest.score}<span>/100</span></div>
|
|
111
|
+
<div class="score-status">${statusText}</div>
|
|
112
|
+
${scoreDelta !== 0 ? `<div class="score-delta ${scoreDelta > 0 ? 'delta-up' : 'delta-down'}">${scoreDelta > 0 ? '▲' : '▼'} ${Math.abs(scoreDelta)} points since last scan</div>` : '<div class="score-delta delta-flat">— First scan or no change</div>'}
|
|
113
|
+
</div>
|
|
114
|
+
<div class="card stat">
|
|
115
|
+
<h2>Scans</h2>
|
|
116
|
+
<div class="stat-val">${scans.length}</div>
|
|
117
|
+
<div class="stat-label">Total scans</div>
|
|
118
|
+
<div style="margin-top:16px">
|
|
119
|
+
<div class="stat-val">${totalFixes}</div>
|
|
120
|
+
<div class="stat-label">Issues fixed</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="card stat">
|
|
124
|
+
<h2>Latest Scan</h2>
|
|
125
|
+
<div class="stat-val">${latest.totalFindings}</div>
|
|
126
|
+
<div class="stat-label">Issues found</div>
|
|
127
|
+
<div style="margin-top:16px">
|
|
128
|
+
<div class="stat-val">${latest.fixable}</div>
|
|
129
|
+
<div class="stat-label">Auto-fixable</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div class="grid grid-2">
|
|
135
|
+
<div class="card">
|
|
136
|
+
<h2>Score History</h2>
|
|
137
|
+
<div class="chart-area" id="chart"></div>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="card">
|
|
140
|
+
<h2>Issues by Severity</h2>
|
|
141
|
+
${sevBar('critical', sevData.critical, '#ff2d55')}
|
|
142
|
+
${sevBar('high', sevData.high, '#ff6b35')}
|
|
143
|
+
${sevBar('medium', sevData.medium, '#ffb800')}
|
|
144
|
+
${sevBar('low', sevData.low, '#3b82f6')}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div class="card">
|
|
149
|
+
<h2>Recent Scans</h2>
|
|
150
|
+
${scans.slice(-10).reverse().map(s => `
|
|
151
|
+
<div class="scan-row">
|
|
152
|
+
<span style="color:var(--text-dim)">${new Date(s.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
|
153
|
+
<span>${s.stack}</span>
|
|
154
|
+
<span>${s.totalFindings} issues</span>
|
|
155
|
+
<span class="scan-score" style="color:${s.score >= 70 ? '#10b981' : s.score >= 40 ? '#f59e0b' : '#ef4444'}">${s.score}/100</span>
|
|
156
|
+
</div>
|
|
157
|
+
`).join('')}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<footer>Doorman — Ship with confidence</footer>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<script>
|
|
164
|
+
var scores = ${scoresJSON};
|
|
165
|
+
if (scores.length > 1) {
|
|
166
|
+
var el = document.getElementById('chart');
|
|
167
|
+
var W=600,H=180,P=40,PR=16,PT=10,PB=30;
|
|
168
|
+
var cW=W-P-PR, cH=H-PT-PB;
|
|
169
|
+
var vals=scores.map(function(s){return s.s});
|
|
170
|
+
var mn=Math.max(0,Math.min.apply(null,vals)-10);
|
|
171
|
+
var mx=Math.min(100,Math.max.apply(null,vals)+10);
|
|
172
|
+
var rng=mx-mn||1;
|
|
173
|
+
function x(i){return P+(i/(scores.length-1))*cW}
|
|
174
|
+
function y(v){return PT+cH-((v-mn)/rng)*cH}
|
|
175
|
+
var pts=scores.map(function(s,i){return x(i).toFixed(1)+','+y(s.s).toFixed(1)});
|
|
176
|
+
var line='M'+pts.join('L');
|
|
177
|
+
var area=line+'L'+x(scores.length-1).toFixed(1)+','+(PT+cH)+'L'+x(0).toFixed(1)+','+(PT+cH)+'Z';
|
|
178
|
+
var last=vals[vals.length-1];
|
|
179
|
+
var col=last>=70?'var(--green)':last>=40?'var(--yellow)':'var(--red)';
|
|
180
|
+
var dots=scores.map(function(s,i){return '<circle cx="'+x(i).toFixed(1)+'" cy="'+y(s.s).toFixed(1)+'" r="4" fill="'+col+'" stroke="var(--surface)" stroke-width="2"/>'}).join('');
|
|
181
|
+
el.innerHTML='<svg viewBox="0 0 '+W+' '+H+'"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="'+col+'" stop-opacity="0.2"/><stop offset="100%" stop-color="'+col+'" stop-opacity="0.02"/></linearGradient></defs><path d="'+area+'" fill="url(#g)"/><path d="'+line+'" fill="none" stroke="'+col+'" stroke-width="2.5" stroke-linecap="round"/>'+ dots+'</svg>';
|
|
182
|
+
} else {
|
|
183
|
+
document.getElementById('chart').innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-faint)">Run more scans to see the chart</div>';
|
|
184
|
+
}
|
|
185
|
+
</script>
|
|
186
|
+
</body>
|
|
187
|
+
</html>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sevBar(label, count, color) {
|
|
191
|
+
const max = 50;
|
|
192
|
+
const pct = Math.max(count > 0 ? 4 : 0, Math.min(100, (count / max) * 100));
|
|
193
|
+
return `<div class="sev-row">
|
|
194
|
+
<div class="sev-label" style="color:${color}">${label}</div>
|
|
195
|
+
<div class="sev-bar-bg"><div class="sev-bar" style="width:${pct}%;background:${color}"></div></div>
|
|
196
|
+
<div class="sev-count">${count}</div>
|
|
197
|
+
</div>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function esc(s) {
|
|
201
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
202
|
+
}
|