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/share.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { writeFileSync } from 'fs';
|
|
2
|
+
import { join, basename, resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a shareable HTML score card for social media / team sharing.
|
|
6
|
+
* Returns the file path of the generated card.
|
|
7
|
+
*/
|
|
8
|
+
export function generateScoreCard(targetPath, result) {
|
|
9
|
+
const resolvedPath = resolve(targetPath);
|
|
10
|
+
const projectName = basename(resolvedPath);
|
|
11
|
+
const score = result.score ?? 0;
|
|
12
|
+
const findings = result.findings || [];
|
|
13
|
+
const stack = result.stack || {};
|
|
14
|
+
|
|
15
|
+
const critical = findings.filter(f => f.severity === 'critical').length;
|
|
16
|
+
const high = findings.filter(f => f.severity === 'high').length;
|
|
17
|
+
const medium = findings.filter(f => f.severity === 'medium').length;
|
|
18
|
+
const fixable = findings.filter(f => f.fix).length;
|
|
19
|
+
|
|
20
|
+
const stackLabel = [stack.framework || stack.language || 'Unknown', stack.orm, stack.database]
|
|
21
|
+
.filter(Boolean).join(' + ');
|
|
22
|
+
|
|
23
|
+
const scoreColor = score >= 70 ? '#10b981' : score >= 40 ? '#f59e0b' : '#ef4444';
|
|
24
|
+
const statusText = score >= 70 ? 'SAFE TO LAUNCH' : 'NOT SAFE TO LAUNCH';
|
|
25
|
+
const statusEmoji = score >= 70 ? '✅' : '⚠️';
|
|
26
|
+
|
|
27
|
+
const html = `<!DOCTYPE html>
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="UTF-8">
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
32
|
+
<title>Doorman Score: ${score}/100 — ${projectName}</title>
|
|
33
|
+
<meta property="og:title" content="Doorman Score: ${score}/100 ${statusEmoji}">
|
|
34
|
+
<meta property="og:description" content="${projectName}: ${critical} critical, ${high} high, ${medium} medium issues. ${fixable} auto-fixable.">
|
|
35
|
+
<meta name="twitter:card" content="summary">
|
|
36
|
+
<style>
|
|
37
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
38
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0b0e14; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
39
|
+
.card { background: #12161f; border: 1px solid #252b3b; border-radius: 16px; padding: 48px; max-width: 480px; width: 100%; text-align: center; }
|
|
40
|
+
.logo { font-size: 14px; font-weight: 700; color: #8892a8; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 24px; }
|
|
41
|
+
.logo span { color: #10b981; }
|
|
42
|
+
.project { font-size: 18px; color: #8892a8; margin-bottom: 8px; }
|
|
43
|
+
.stack { font-size: 13px; color: #5a6478; margin-bottom: 32px; }
|
|
44
|
+
.score { font-size: 80px; font-weight: 800; color: ${scoreColor}; line-height: 1; letter-spacing: -3px; }
|
|
45
|
+
.score span { font-size: 28px; color: #5a6478; font-weight: 400; }
|
|
46
|
+
.status { font-size: 14px; font-weight: 700; color: ${scoreColor}; margin-top: 8px; letter-spacing: 1px; }
|
|
47
|
+
.issues { display: flex; justify-content: center; gap: 24px; margin-top: 32px; padding-top: 24px; border-top: 1px solid #252b3b; }
|
|
48
|
+
.issue { text-align: center; }
|
|
49
|
+
.issue-num { font-size: 24px; font-weight: 800; }
|
|
50
|
+
.issue-label { font-size: 11px; color: #5a6478; text-transform: uppercase; letter-spacing: 1px; margin-top: 4px; }
|
|
51
|
+
.critical { color: #ff2d55; }
|
|
52
|
+
.high { color: #ff6b35; }
|
|
53
|
+
.medium { color: #ffb800; }
|
|
54
|
+
.fixable { color: #10b981; }
|
|
55
|
+
.cta { margin-top: 32px; padding-top: 24px; border-top: 1px solid #252b3b; }
|
|
56
|
+
.cta code { background: #1a1f2e; padding: 8px 16px; border-radius: 8px; font-family: 'SF Mono', monospace; font-size: 14px; color: #10b981; }
|
|
57
|
+
.cta p { font-size: 12px; color: #5a6478; margin-top: 12px; }
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<div class="card">
|
|
62
|
+
<div class="logo">Safe<span>Launch</span> Score</div>
|
|
63
|
+
<div class="project">${escapeHtml(projectName)}</div>
|
|
64
|
+
<div class="stack">${escapeHtml(stackLabel)}</div>
|
|
65
|
+
<div class="score">${score}<span>/100</span></div>
|
|
66
|
+
<div class="status">${statusText}</div>
|
|
67
|
+
<div class="issues">
|
|
68
|
+
<div class="issue"><div class="issue-num critical">${critical}</div><div class="issue-label">Critical</div></div>
|
|
69
|
+
<div class="issue"><div class="issue-num high">${high}</div><div class="issue-label">High</div></div>
|
|
70
|
+
<div class="issue"><div class="issue-num medium">${medium}</div><div class="issue-label">Medium</div></div>
|
|
71
|
+
<div class="issue"><div class="issue-num fixable">${fixable}</div><div class="issue-label">Fixable</div></div>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="cta">
|
|
74
|
+
<code>npx getdoorman check</code>
|
|
75
|
+
<p>What's your score? Try it free.</p>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</body>
|
|
79
|
+
</html>`;
|
|
80
|
+
|
|
81
|
+
const outPath = join(resolvedPath, 'doorman-score.html');
|
|
82
|
+
writeFileSync(outPath, html);
|
|
83
|
+
return outPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function escapeHtml(str) {
|
|
87
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
88
|
+
}
|
package/src/taint.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-file taint analysis engine.
|
|
3
|
+
*
|
|
4
|
+
* Tracks user-controlled data (req.body, req.query, etc.) through variable
|
|
5
|
+
* assignments within a single file and flags when tainted data reaches a
|
|
6
|
+
* dangerous sink (exec, SQL query, eval, res.send, readFile, redirect).
|
|
7
|
+
*
|
|
8
|
+
* Only reports INDIRECT flows — at least one variable hop between source and
|
|
9
|
+
* sink. Direct cases (e.g. exec(req.query.x)) are already caught by the
|
|
10
|
+
* pattern-matching rules.
|
|
11
|
+
*
|
|
12
|
+
* Level 1: single-file only. Cross-file propagation is Level 2.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ─── TAINT SOURCES ────────────────────────────────────────────────────────────
|
|
16
|
+
// Expressions that introduce user-controlled data.
|
|
17
|
+
const SOURCE_PATTERNS = [
|
|
18
|
+
/\breq\.(body|query|params|headers|cookies)\b/,
|
|
19
|
+
/\brequest\.(body|query|params|headers|cookies)\b/,
|
|
20
|
+
/\bctx\.(request\.body|query|params|headers|cookies)\b/,
|
|
21
|
+
/\bevent\.(body|queryStringParameters|pathParameters|multiValueQueryStringParameters)\b/,
|
|
22
|
+
/\bc\.req\.(body|query|param|header)\b/, // Hono
|
|
23
|
+
/\bprocess\.argv\b/,
|
|
24
|
+
/\bURL\.searchParams\b|\.searchParams\.get\s*\(/,
|
|
25
|
+
/\bnew\s+URL\s*\(\s*req\b/, // new URL(req.url)
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// ─── SINKS ────────────────────────────────────────────────────────────────────
|
|
29
|
+
const SINKS = [
|
|
30
|
+
{
|
|
31
|
+
id: 'TAINT-CMD-001',
|
|
32
|
+
severity: 'critical',
|
|
33
|
+
title: 'Indirect command injection — tainted variable reaches exec()',
|
|
34
|
+
pattern: /\bexec(?:Sync|File|FileSync)?\s*\(|\bspawn(?:Sync)?\s*\(/,
|
|
35
|
+
category: 'Command Injection',
|
|
36
|
+
fix: 'Validate and sanitize the value, or avoid including user input in shell commands entirely.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'TAINT-SQL-001',
|
|
40
|
+
severity: 'critical',
|
|
41
|
+
title: 'Indirect SQL injection — tainted variable reaches database query',
|
|
42
|
+
pattern: /\.(?:query|raw|execute|run|prepare)\s*\(|db\.\w+\s*\(/,
|
|
43
|
+
category: 'SQL Injection',
|
|
44
|
+
fix: 'Use parameterized queries. Pass the tainted value as a bound parameter, never concatenated into the query string.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'TAINT-EVAL-001',
|
|
48
|
+
severity: 'critical',
|
|
49
|
+
title: 'Indirect code injection — tainted variable reaches eval() or new Function()',
|
|
50
|
+
pattern: /\beval\s*\(|\bnew\s+Function\s*\(/,
|
|
51
|
+
category: 'Code Injection',
|
|
52
|
+
fix: 'Never evaluate user-controlled strings as code. Redesign to avoid eval entirely.',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'TAINT-XSS-001',
|
|
56
|
+
severity: 'high',
|
|
57
|
+
title: 'Indirect XSS — tainted variable reaches HTTP response',
|
|
58
|
+
pattern: /\bres\.(send|write|end|render)\s*\(|\bresponse\.(send|write|end)\s*\(/,
|
|
59
|
+
category: 'Cross-Site Scripting',
|
|
60
|
+
fix: 'Escape the value with a library like DOMPurify or he before including it in the response.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'TAINT-PATH-001',
|
|
64
|
+
severity: 'high',
|
|
65
|
+
title: 'Indirect path traversal — tainted variable reaches filesystem operation',
|
|
66
|
+
pattern: /\breadFile(?:Sync)?\s*\(|\bwriteFile(?:Sync)?\s*\(|\bcreate(?:Read|Write)Stream\s*\(|\bunlink(?:Sync)?\s*\(|\bstat(?:Sync)?\s*\(|\breaddir(?:Sync)?\s*\(|\bexists(?:Sync)?\s*\(|\bappendFile(?:Sync)?\s*\(/,
|
|
67
|
+
category: 'Path Traversal',
|
|
68
|
+
fix: 'Use path.basename() to strip directory components, or validate against an allowlist of permitted paths.',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'TAINT-REDIR-001',
|
|
72
|
+
severity: 'high',
|
|
73
|
+
title: 'Indirect open redirect — tainted variable reaches redirect()',
|
|
74
|
+
pattern: /\bres\.redirect\s*\(|\bredirect\s*\(/,
|
|
75
|
+
category: 'Open Redirect',
|
|
76
|
+
fix: 'Validate the URL against an allowlist of permitted destinations before redirecting.',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'TAINT-REQUIRE-001',
|
|
80
|
+
severity: 'critical',
|
|
81
|
+
title: 'Indirect RCE — tainted variable reaches dynamic require() or import()',
|
|
82
|
+
pattern: /\brequire\s*\(|\bimport\s*\(/,
|
|
83
|
+
category: 'Remote Code Execution',
|
|
84
|
+
fix: 'Never load modules from user-controlled paths. Validate against an allowlist of permitted module names.',
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// ─── ASSIGNMENT PATTERNS ──────────────────────────────────────────────────────
|
|
89
|
+
// Matches: const/let/var name = expr
|
|
90
|
+
const VAR_DECL = /^(?:const|let|var)\s+(\w+)\s*=(?!=)\s*(.+)/;
|
|
91
|
+
// Matches: const { a, b: alias, c = default } = expr
|
|
92
|
+
const OBJ_DESTRUCT = /^(?:const|let|var)\s+\{\s*([^}]+)\}\s*=(?!=)\s*(.+)/;
|
|
93
|
+
// Matches: const [a, b] = expr
|
|
94
|
+
const ARR_DESTRUCT = /^(?:const|let|var)\s+\[\s*([^\]]+)\]\s*=(?!=)\s*(.+)/;
|
|
95
|
+
// Matches: name = expr (reassignment, not ==, !=, <=, >=, ===, !==)
|
|
96
|
+
const REASSIGN = /^(\w+)\s*=(?![=>])\s*(.+)/;
|
|
97
|
+
// Matches: name += / name -= / name += expr (compound assignment also propagates)
|
|
98
|
+
const COMPOUND = /^(\w+)\s*[+\-*/%]=\s*(.+)/;
|
|
99
|
+
|
|
100
|
+
function escapeRegex(s) {
|
|
101
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Returns true if the expression contains a known taint source. */
|
|
105
|
+
function isSourceExpr(expr) {
|
|
106
|
+
return SOURCE_PATTERNS.some(p => p.test(expr));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build a regex that matches a variable name as a standalone identifier —
|
|
111
|
+
* NOT preceded by a dot (property access like req.query won't match "query").
|
|
112
|
+
*/
|
|
113
|
+
function varRegex(name) {
|
|
114
|
+
return new RegExp(`(?<!\\.\\s*)\\b${escapeRegex(name)}\\b`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Returns true if any tainted variable appears as a standalone identifier. */
|
|
118
|
+
function exprIsTainted(expr, taintedVars) {
|
|
119
|
+
for (const v of taintedVars) {
|
|
120
|
+
if (varRegex(v).test(expr)) return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse names out of a destructuring pattern string.
|
|
127
|
+
* "a, b: aliasB, c = 'default'" → ['a', 'aliasB', 'c']
|
|
128
|
+
*/
|
|
129
|
+
function parseDestructuredNames(str) {
|
|
130
|
+
return str
|
|
131
|
+
.split(',')
|
|
132
|
+
.map(part => {
|
|
133
|
+
const t = part.trim().split(/\s*[=:]\s*/)[0].trim(); // take key, ignore alias/default
|
|
134
|
+
// For "key: alias" patterns the alias is the actual binding
|
|
135
|
+
const colonIdx = part.indexOf(':');
|
|
136
|
+
if (colonIdx !== -1) {
|
|
137
|
+
return part.slice(colonIdx + 1).trim().split(/[\s,=]/)[0];
|
|
138
|
+
}
|
|
139
|
+
return t;
|
|
140
|
+
})
|
|
141
|
+
.filter(n => n && /^\w+$/.test(n));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Analyse a single file for indirect taint flows.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} content File content
|
|
148
|
+
* @param {string} filepath File path (used in findings)
|
|
149
|
+
* @returns {Array} Array of finding objects
|
|
150
|
+
*/
|
|
151
|
+
export function analyzeTaint(content, filepath) {
|
|
152
|
+
const findings = [];
|
|
153
|
+
const lines = content.split('\n');
|
|
154
|
+
|
|
155
|
+
// varName → line number where it was first tainted
|
|
156
|
+
const tainted = new Map();
|
|
157
|
+
// Deduplicate: (lineNum, ruleId) pairs already reported
|
|
158
|
+
const reported = new Set();
|
|
159
|
+
|
|
160
|
+
// Track brace depth to detect function boundaries and reset taint per-function.
|
|
161
|
+
// When braceDepth returns to the depth at which the last function started,
|
|
162
|
+
// we flush taint so variables don't leak across handler closures.
|
|
163
|
+
let braceDepth = 0;
|
|
164
|
+
// Stack of { depth, taintSnapshot } so nested functions get their own scope.
|
|
165
|
+
const scopeStack = [];
|
|
166
|
+
|
|
167
|
+
for (let i = 0; i < lines.length; i++) {
|
|
168
|
+
const raw = lines[i];
|
|
169
|
+
const line = raw.trim();
|
|
170
|
+
const lineN = i + 1;
|
|
171
|
+
|
|
172
|
+
// Skip blank lines and comments
|
|
173
|
+
if (!line) continue;
|
|
174
|
+
if (line.startsWith('//') || line.startsWith('*') || line.startsWith('/*')) continue;
|
|
175
|
+
|
|
176
|
+
// ── SCOPE TRACKING ───────────────────────────────────────────────────────
|
|
177
|
+
// Count net brace change to detect function entry/exit.
|
|
178
|
+
// Strip string literals and comments before counting braces to avoid false counts
|
|
179
|
+
const strippedLine = line.replace(/(['"`])(?:(?!\1|\\).|\\.)*\1/g, '').replace(/\/\/.*$/, '');
|
|
180
|
+
const opens = (strippedLine.match(/\{/g) || []).length;
|
|
181
|
+
const closes = (strippedLine.match(/\}/g) || []).length;
|
|
182
|
+
|
|
183
|
+
// If this line starts a new function/arrow, push a scope boundary.
|
|
184
|
+
const isFnEntry = /(?:function\s*\w*\s*\(|\=>\s*\{|\bfunction\s*\()/.test(line) && opens > closes;
|
|
185
|
+
if (isFnEntry) {
|
|
186
|
+
scopeStack.push({ depth: braceDepth + opens - closes, savedTaint: new Map(tainted) });
|
|
187
|
+
// Reset taint for the new function scope
|
|
188
|
+
tainted.clear();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
braceDepth += opens - closes;
|
|
192
|
+
|
|
193
|
+
// Pop scope when we return to the depth where the function started.
|
|
194
|
+
if (scopeStack.length > 0 && braceDepth <= scopeStack[scopeStack.length - 1].depth - 1) {
|
|
195
|
+
const { savedTaint } = scopeStack.pop();
|
|
196
|
+
tainted.clear();
|
|
197
|
+
for (const [k, v] of savedTaint) tainted.set(k, v);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── TAINT PROPAGATION ────────────────────────────────────────────────────
|
|
201
|
+
let m;
|
|
202
|
+
|
|
203
|
+
// Object destructuring
|
|
204
|
+
if ((m = OBJ_DESTRUCT.exec(line))) {
|
|
205
|
+
const [, names, expr] = m;
|
|
206
|
+
if (isSourceExpr(expr) || exprIsTainted(expr, tainted.keys())) {
|
|
207
|
+
for (const name of parseDestructuredNames(names)) {
|
|
208
|
+
if (!tainted.has(name)) tainted.set(name, lineN);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Array destructuring
|
|
213
|
+
else if ((m = ARR_DESTRUCT.exec(line))) {
|
|
214
|
+
const [, names, expr] = m;
|
|
215
|
+
if (isSourceExpr(expr) || exprIsTainted(expr, tainted.keys())) {
|
|
216
|
+
for (const name of names.split(',').map(s => s.trim()).filter(s => s && s !== '_' && /^\w+$/.test(s))) {
|
|
217
|
+
if (!tainted.has(name)) tainted.set(name, lineN);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Simple variable declaration
|
|
222
|
+
else if ((m = VAR_DECL.exec(line))) {
|
|
223
|
+
const [, name, expr] = m;
|
|
224
|
+
if (isSourceExpr(expr) || exprIsTainted(expr, tainted.keys())) {
|
|
225
|
+
tainted.set(name, lineN);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Reassignment (may re-taint or further propagate)
|
|
229
|
+
else if ((m = REASSIGN.exec(line))) {
|
|
230
|
+
const [, name, expr] = m;
|
|
231
|
+
if (isSourceExpr(expr) || exprIsTainted(expr, tainted.keys())) {
|
|
232
|
+
tainted.set(name, lineN);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Compound assignment (+=, etc.) — propagates if either side is tainted
|
|
236
|
+
else if ((m = COMPOUND.exec(line))) {
|
|
237
|
+
const [, name, expr] = m;
|
|
238
|
+
if (tainted.has(name) || isSourceExpr(expr) || exprIsTainted(expr, tainted.keys())) {
|
|
239
|
+
tainted.set(name, lineN); // update taint line to compound op
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── SINK DETECTION ───────────────────────────────────────────────────────
|
|
244
|
+
if (tainted.size === 0) continue;
|
|
245
|
+
|
|
246
|
+
for (const sink of SINKS) {
|
|
247
|
+
if (!sink.pattern.test(line)) continue;
|
|
248
|
+
|
|
249
|
+
// Only fire if a tainted variable appears as a standalone identifier
|
|
250
|
+
for (const [varName, taintedAtLine] of tainted) {
|
|
251
|
+
// Skip if tainted on the same line — direct case, existing rules cover it
|
|
252
|
+
if (taintedAtLine === lineN) continue;
|
|
253
|
+
|
|
254
|
+
if (!varRegex(varName).test(line)) continue;
|
|
255
|
+
|
|
256
|
+
const key = `${lineN}:${sink.id}:${varName}`;
|
|
257
|
+
if (reported.has(key)) continue;
|
|
258
|
+
reported.add(key);
|
|
259
|
+
|
|
260
|
+
const taintLine = lines[taintedAtLine - 1]?.trim() ?? '';
|
|
261
|
+
findings.push({
|
|
262
|
+
ruleId: sink.id,
|
|
263
|
+
category: 'security',
|
|
264
|
+
severity: sink.severity,
|
|
265
|
+
confidence: 'likely',
|
|
266
|
+
title: sink.title,
|
|
267
|
+
description:
|
|
268
|
+
`Variable "${varName}" receives user-controlled input at line ${taintedAtLine} ` +
|
|
269
|
+
`and flows into ${sink.category} sink at line ${lineN} without sanitization. ` +
|
|
270
|
+
`Taint chain: \`${taintLine}\` → \`${line.slice(0, 120)}\`. ${sink.fix}`,
|
|
271
|
+
file: filepath,
|
|
272
|
+
line: lineN,
|
|
273
|
+
fix: null,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return findings;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Run taint analysis across all JS/TS files in the file map.
|
|
284
|
+
* Called from the taint rule's check() function.
|
|
285
|
+
*/
|
|
286
|
+
export function runTaintAnalysis(files) {
|
|
287
|
+
const JS_TS = /\.(js|jsx|ts|tsx|mjs|cjs)$/;
|
|
288
|
+
const findings = [];
|
|
289
|
+
for (const [fp, content] of files) {
|
|
290
|
+
if (!JS_TS.test(fp)) continue;
|
|
291
|
+
// Skip test files — they intentionally pass tainted data around
|
|
292
|
+
if (/[./](?:test|spec)[./]|__tests__|\.test\.|\.spec\./.test(fp)) continue;
|
|
293
|
+
try {
|
|
294
|
+
findings.push(...analyzeTaint(content, fp));
|
|
295
|
+
} catch {
|
|
296
|
+
// Never let a parse error crash the scan
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return findings;
|
|
300
|
+
}
|
package/src/telemetry.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Telemetry Module — Privacy Guarantees
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// 1. OPT-IN ONLY: Telemetry is DISABLED by default. It is never sent unless
|
|
6
|
+
// the user explicitly enables it via one of these mechanisms:
|
|
7
|
+
// - `doorman init --telemetry`
|
|
8
|
+
// - Setting `"telemetry": true` in `.doormanrc.json`
|
|
9
|
+
// - Setting the environment variable `DOORMAN_TELEMETRY=1`
|
|
10
|
+
//
|
|
11
|
+
// 2. NO PII COLLECTED: We never collect personally identifiable information.
|
|
12
|
+
// No usernames, emails, IP addresses, hostnames, file paths, file contents,
|
|
13
|
+
// code snippets, repository names, or Git URLs are transmitted. Project
|
|
14
|
+
// identity is a one-way SHA-256 hash (first 16 hex chars) that cannot be
|
|
15
|
+
// reversed to reveal the original URL or path.
|
|
16
|
+
//
|
|
17
|
+
// 3. AGGREGATE DATA ONLY: The payload contains only aggregate counts (score,
|
|
18
|
+
// finding counts by severity/category, rule IDs, file count) and generic
|
|
19
|
+
// stack metadata (language, framework, runtime, boolean feature flags).
|
|
20
|
+
//
|
|
21
|
+
// 4. DISABLE AT ANY TIME: Set `DOORMAN_TELEMETRY=0` or remove the
|
|
22
|
+
// `"telemetry": true` entry from `.doormanrc.json`. Locally cached
|
|
23
|
+
// telemetry data is stored in `.doorman/telemetry.json` and can be
|
|
24
|
+
// deleted freely.
|
|
25
|
+
//
|
|
26
|
+
// 5. NEVER BLOCKS: Telemetry runs fire-and-forget with a 3-second timeout.
|
|
27
|
+
// Failures are silently ignored and never affect scan results.
|
|
28
|
+
//
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
32
|
+
import { join } from 'path';
|
|
33
|
+
import { createHash } from 'crypto';
|
|
34
|
+
import { execSync } from 'child_process';
|
|
35
|
+
|
|
36
|
+
const TELEMETRY_ENDPOINT = 'https://api.doorman.dev/telemetry';
|
|
37
|
+
const CONFIG_DIR = '.doorman';
|
|
38
|
+
const TELEMETRY_FILE = 'telemetry.json';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if telemetry is enabled.
|
|
42
|
+
*
|
|
43
|
+
* IMPORTANT: This is opt-in only. Returns false by default.
|
|
44
|
+
*
|
|
45
|
+
* Enable via:
|
|
46
|
+
* - Environment variable: DOORMAN_TELEMETRY=1
|
|
47
|
+
* - Config file: { "telemetry": true } in .doormanrc.json
|
|
48
|
+
*
|
|
49
|
+
* Disable via:
|
|
50
|
+
* - Environment variable: DOORMAN_TELEMETRY=0 (overrides config file)
|
|
51
|
+
* - Config file: { "telemetry": false } or omit the key
|
|
52
|
+
*/
|
|
53
|
+
export function isTelemetryEnabled(targetPath) {
|
|
54
|
+
if (process.env.DOORMAN_TELEMETRY === '0') return false;
|
|
55
|
+
if (process.env.DOORMAN_TELEMETRY === '1') return true;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const rcPath = join(targetPath, '.doormanrc.json');
|
|
59
|
+
if (existsSync(rcPath)) {
|
|
60
|
+
const rc = JSON.parse(readFileSync(rcPath, 'utf-8'));
|
|
61
|
+
if (rc.telemetry === true) return true;
|
|
62
|
+
if (rc.telemetry === false) return false;
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
|
|
66
|
+
return false; // Opt-in by default
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate an anonymous project ID (hash of git remote URL or cwd).
|
|
71
|
+
* This allows deduplication without identifying the project.
|
|
72
|
+
*/
|
|
73
|
+
export function getAnonymousId(targetPath) {
|
|
74
|
+
try {
|
|
75
|
+
const remote = execSync('git remote get-url origin', {
|
|
76
|
+
cwd: targetPath,
|
|
77
|
+
stdio: 'pipe',
|
|
78
|
+
}).toString().trim();
|
|
79
|
+
return createHash('sha256').update(remote).digest('hex').slice(0, 16);
|
|
80
|
+
} catch {
|
|
81
|
+
return createHash('sha256').update(targetPath).digest('hex').slice(0, 16);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the telemetry payload from scan results.
|
|
87
|
+
* IMPORTANT: No code, no file paths, no secrets. Only aggregate numbers.
|
|
88
|
+
*/
|
|
89
|
+
export function buildPayload(scanResult, stack) {
|
|
90
|
+
return {
|
|
91
|
+
version: '1.0.0',
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
// What stack (generic, not specific project)
|
|
94
|
+
stack: {
|
|
95
|
+
language: stack.language || 'unknown',
|
|
96
|
+
framework: stack.framework || 'unknown',
|
|
97
|
+
runtime: stack.runtime || 'unknown',
|
|
98
|
+
hasTypescript: !!stack.hasTypescript,
|
|
99
|
+
hasDocker: !!stack.hasDocker,
|
|
100
|
+
hasCI: !!stack.hasCI,
|
|
101
|
+
hasTests: !!stack.hasTests,
|
|
102
|
+
},
|
|
103
|
+
// Aggregate findings (no file paths or code)
|
|
104
|
+
scan: {
|
|
105
|
+
score: scanResult.score,
|
|
106
|
+
totalFindings: scanResult.findings.length,
|
|
107
|
+
bySeverity: {
|
|
108
|
+
critical: scanResult.findings.filter(f => f.severity === 'critical').length,
|
|
109
|
+
high: scanResult.findings.filter(f => f.severity === 'high').length,
|
|
110
|
+
medium: scanResult.findings.filter(f => f.severity === 'medium').length,
|
|
111
|
+
low: scanResult.findings.filter(f => f.severity === 'low').length,
|
|
112
|
+
},
|
|
113
|
+
byCategory: {},
|
|
114
|
+
topRuleIds: [], // Most common rule IDs (just IDs, no context)
|
|
115
|
+
fixableCount: scanResult.findings.filter(f => f.fix).length,
|
|
116
|
+
fileCount: scanResult.fileCount || 0,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Enrich payload with category and rule breakdowns.
|
|
123
|
+
*/
|
|
124
|
+
function enrichPayload(payload, findings) {
|
|
125
|
+
const catCounts = {};
|
|
126
|
+
const ruleCounts = {};
|
|
127
|
+
for (const f of findings) {
|
|
128
|
+
catCounts[f.category] = (catCounts[f.category] || 0) + 1;
|
|
129
|
+
ruleCounts[f.ruleId] = (ruleCounts[f.ruleId] || 0) + 1;
|
|
130
|
+
}
|
|
131
|
+
payload.scan.byCategory = catCounts;
|
|
132
|
+
payload.scan.topRuleIds = Object.entries(ruleCounts)
|
|
133
|
+
.sort((a, b) => b[1] - a[1])
|
|
134
|
+
.slice(0, 10)
|
|
135
|
+
.map(([id, count]) => ({ id, count }));
|
|
136
|
+
return payload;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Send telemetry data. Non-blocking, fire-and-forget.
|
|
141
|
+
* Never throws, never blocks the user.
|
|
142
|
+
*/
|
|
143
|
+
export async function sendTelemetry(targetPath, scanResult, stack) {
|
|
144
|
+
if (!isTelemetryEnabled(targetPath)) return;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const payload = buildPayload(scanResult, stack);
|
|
148
|
+
enrichPayload(payload, scanResult.findings);
|
|
149
|
+
|
|
150
|
+
// Store locally for batch sending later
|
|
151
|
+
const dir = join(targetPath, CONFIG_DIR);
|
|
152
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
153
|
+
|
|
154
|
+
const telPath = join(dir, TELEMETRY_FILE);
|
|
155
|
+
let existing = [];
|
|
156
|
+
try {
|
|
157
|
+
if (existsSync(telPath)) {
|
|
158
|
+
existing = JSON.parse(readFileSync(telPath, 'utf-8'));
|
|
159
|
+
}
|
|
160
|
+
} catch {}
|
|
161
|
+
|
|
162
|
+
existing.push(payload);
|
|
163
|
+
// Keep last 50 entries
|
|
164
|
+
if (existing.length > 50) existing = existing.slice(-50);
|
|
165
|
+
writeFileSync(telPath, JSON.stringify(existing, null, 2));
|
|
166
|
+
|
|
167
|
+
// Try to send (non-blocking, fails silently)
|
|
168
|
+
try {
|
|
169
|
+
const controller = new AbortController();
|
|
170
|
+
setTimeout(() => controller.abort(), 3000); // 3s timeout
|
|
171
|
+
await fetch(TELEMETRY_ENDPOINT, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: { 'Content-Type': 'application/json' },
|
|
174
|
+
body: JSON.stringify(payload),
|
|
175
|
+
signal: controller.signal,
|
|
176
|
+
});
|
|
177
|
+
} catch {
|
|
178
|
+
// Network unavailable — data stored locally for later
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// Telemetry should never break the tool
|
|
182
|
+
}
|
|
183
|
+
}
|