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/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
+ }
@@ -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 &mdash; ${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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
202
+ }