@npm-safe/core-dsh 1.0.5
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 +204 -0
- package/dist/index.d.ts +513 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +711 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/anthropic.d.ts +47 -0
- package/dist/llm/anthropic.d.ts.map +1 -0
- package/dist/llm/anthropic.js +161 -0
- package/dist/llm/anthropic.js.map +1 -0
- package/dist/llm/gemini.d.ts +47 -0
- package/dist/llm/gemini.d.ts.map +1 -0
- package/dist/llm/gemini.js +165 -0
- package/dist/llm/gemini.js.map +1 -0
- package/dist/llm/llm-config.d.ts +97 -0
- package/dist/llm/llm-config.d.ts.map +1 -0
- package/dist/llm/llm-config.js +188 -0
- package/dist/llm/llm-config.js.map +1 -0
- package/dist/llm/parse.d.ts +95 -0
- package/dist/llm/parse.d.ts.map +1 -0
- package/dist/llm/parse.js +158 -0
- package/dist/llm/parse.js.map +1 -0
- package/dist/llm/provider.d.ts +122 -0
- package/dist/llm/provider.d.ts.map +1 -0
- package/dist/llm/provider.js +206 -0
- package/dist/llm/provider.js.map +1 -0
- package/dist/registry/client.d.ts +164 -0
- package/dist/registry/client.d.ts.map +1 -0
- package/dist/registry/client.js +378 -0
- package/dist/registry/client.js.map +1 -0
- package/dist/registry/types.d.ts +226 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/registry/types.js +32 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/registry/validator.d.ts +87 -0
- package/dist/registry/validator.d.ts.map +1 -0
- package/dist/registry/validator.js +214 -0
- package/dist/registry/validator.js.map +1 -0
- package/dist/scanner/ci-scan.d.ts +82 -0
- package/dist/scanner/ci-scan.d.ts.map +1 -0
- package/dist/scanner/ci-scan.js +130 -0
- package/dist/scanner/ci-scan.js.map +1 -0
- package/dist/scanner/rule-config.d.ts +61 -0
- package/dist/scanner/rule-config.d.ts.map +1 -0
- package/dist/scanner/rule-config.js +103 -0
- package/dist/scanner/rule-config.js.map +1 -0
- package/dist/scanner/rule-loader.d.ts +28 -0
- package/dist/scanner/rule-loader.d.ts.map +1 -0
- package/dist/scanner/rule-loader.js +67 -0
- package/dist/scanner/rule-loader.js.map +1 -0
- package/dist/scanner/static-rules.d.ts +88 -0
- package/dist/scanner/static-rules.d.ts.map +1 -0
- package/dist/scanner/static-rules.js +723 -0
- package/dist/scanner/static-rules.js.map +1 -0
- package/dist/scanner/types.d.ts +177 -0
- package/dist/scanner/types.d.ts.map +1 -0
- package/dist/scanner/types.js +53 -0
- package/dist/scanner/types.js.map +1 -0
- package/dist/scheduler/rate-limiter.d.ts +74 -0
- package/dist/scheduler/rate-limiter.d.ts.map +1 -0
- package/dist/scheduler/rate-limiter.js +182 -0
- package/dist/scheduler/rate-limiter.js.map +1 -0
- package/dist/scheduler/refresh-scheduler.d.ts +201 -0
- package/dist/scheduler/refresh-scheduler.d.ts.map +1 -0
- package/dist/scheduler/refresh-scheduler.js +295 -0
- package/dist/scheduler/refresh-scheduler.js.map +1 -0
- package/dist/store/cache-manager.d.ts +166 -0
- package/dist/store/cache-manager.d.ts.map +1 -0
- package/dist/store/cache-manager.js +356 -0
- package/dist/store/cache-manager.js.map +1 -0
- package/dist/store/database.d.ts +81 -0
- package/dist/store/database.d.ts.map +1 -0
- package/dist/store/database.js +182 -0
- package/dist/store/database.js.map +1 -0
- package/dist/store/schema.d.ts +42 -0
- package/dist/store/schema.d.ts.map +1 -0
- package/dist/store/schema.js +126 -0
- package/dist/store/schema.js.map +1 -0
- package/dist/translator/provider.d.ts +152 -0
- package/dist/translator/provider.d.ts.map +1 -0
- package/dist/translator/provider.js +159 -0
- package/dist/translator/provider.js.map +1 -0
- package/dist/translator/types.d.ts +83 -0
- package/dist/translator/types.d.ts.map +1 -0
- package/dist/translator/types.js +58 -0
- package/dist/translator/types.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static analysis rule engine for npm package security scanning.
|
|
3
|
+
*
|
|
4
|
+
* Pure regex/string analysis — no network calls, no LLM. The engine registers
|
|
5
|
+
* a set of {@link ScanRule} implementations and aggregates their findings into
|
|
6
|
+
* a {@link StaticScanReport} with a numeric score and overall security level.
|
|
7
|
+
*/
|
|
8
|
+
import { FindingCategory, SecurityLevel, Severity, } from './types.js';
|
|
9
|
+
/** Severity weights subtracted from the base score (100) per finding. */
|
|
10
|
+
const SEVERITY_WEIGHT = {
|
|
11
|
+
[Severity.Critical]: 25,
|
|
12
|
+
[Severity.High]: 15,
|
|
13
|
+
[Severity.Medium]: 8,
|
|
14
|
+
[Severity.Low]: 3,
|
|
15
|
+
};
|
|
16
|
+
/** Minimum score and maximum score bounds for clamping. */
|
|
17
|
+
const MIN_SCORE = 0;
|
|
18
|
+
const MAX_SCORE = 100;
|
|
19
|
+
/**
|
|
20
|
+
* A small inline list of popular npm package names used as a reference set for
|
|
21
|
+
* typosquatting detection. This is intentionally a compact approximation of the
|
|
22
|
+
* "top-1000" — a full list would require network access, which this engine
|
|
23
|
+
* forbids.
|
|
24
|
+
*/
|
|
25
|
+
const POPULAR_PACKAGES = [
|
|
26
|
+
'express',
|
|
27
|
+
'lodash',
|
|
28
|
+
'react',
|
|
29
|
+
'axios',
|
|
30
|
+
'chalk',
|
|
31
|
+
'commander',
|
|
32
|
+
'debug',
|
|
33
|
+
'request',
|
|
34
|
+
'moment',
|
|
35
|
+
'vue',
|
|
36
|
+
'angular',
|
|
37
|
+
'webpack',
|
|
38
|
+
'typescript',
|
|
39
|
+
'jest',
|
|
40
|
+
'eslint',
|
|
41
|
+
'fs-extra',
|
|
42
|
+
'dotenv',
|
|
43
|
+
'yargs',
|
|
44
|
+
'ramda',
|
|
45
|
+
'underscore',
|
|
46
|
+
];
|
|
47
|
+
/** Standard npm registry URL. Any other registry in publishConfig is flagged. */
|
|
48
|
+
const STANDARD_REGISTRY = 'https://registry.npmjs.org/';
|
|
49
|
+
/**
|
|
50
|
+
* Compute the Levenshtein edit distance between two strings.
|
|
51
|
+
*
|
|
52
|
+
* Uses the classic dynamic-programming formulation with O(a*b) time and space.
|
|
53
|
+
*
|
|
54
|
+
* @param a - First string.
|
|
55
|
+
* @param b - Second string.
|
|
56
|
+
* @returns Edit distance (number of single-character insertions/deletions/substitutions).
|
|
57
|
+
*/
|
|
58
|
+
function levenshtein(a, b) {
|
|
59
|
+
const m = a.length;
|
|
60
|
+
const n = b.length;
|
|
61
|
+
if (m === 0)
|
|
62
|
+
return n;
|
|
63
|
+
if (n === 0)
|
|
64
|
+
return m;
|
|
65
|
+
// dp[i][j] = distance between a[0..i) and b[0..j)
|
|
66
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
67
|
+
for (let i = 0; i <= m; i++)
|
|
68
|
+
dp[i][0] = i;
|
|
69
|
+
for (let j = 0; j <= n; j++)
|
|
70
|
+
dp[0][j] = j;
|
|
71
|
+
for (let i = 1; i <= m; i++) {
|
|
72
|
+
for (let j = 1; j <= n; j++) {
|
|
73
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
74
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return dp[m][n];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Split README content into 1-based lines, returning the array of lines
|
|
81
|
+
* (without trailing newline characters).
|
|
82
|
+
*
|
|
83
|
+
* @param readme - Raw README content.
|
|
84
|
+
* @returns Array of lines (1-based index = array index + 1).
|
|
85
|
+
*/
|
|
86
|
+
function splitLines(readme) {
|
|
87
|
+
return readme.split(/\r?\n/);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Find the 1-based line number of the first line in `readme` containing `needle`,
|
|
91
|
+
* or `undefined` if not found.
|
|
92
|
+
*/
|
|
93
|
+
function findLineNumber(readme, needle) {
|
|
94
|
+
const lines = splitLines(readme);
|
|
95
|
+
for (let i = 0; i < lines.length; i++) {
|
|
96
|
+
if (lines[i].includes(needle))
|
|
97
|
+
return i + 1;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Safely read a string-valued field from a parsed package.json object.
|
|
103
|
+
*
|
|
104
|
+
* @param pkg - Parsed package.json (may be undefined).
|
|
105
|
+
* @param key - Top-level key to read.
|
|
106
|
+
* @returns The string value if present and a string, otherwise undefined.
|
|
107
|
+
*/
|
|
108
|
+
function readStringField(pkg, key) {
|
|
109
|
+
if (!pkg)
|
|
110
|
+
return undefined;
|
|
111
|
+
const value = pkg[key];
|
|
112
|
+
return typeof value === 'string' ? value : undefined;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Read a nested object field from a parsed package.json object.
|
|
116
|
+
*/
|
|
117
|
+
function readObjectField(pkg, key) {
|
|
118
|
+
if (!pkg)
|
|
119
|
+
return undefined;
|
|
120
|
+
const value = pkg[key];
|
|
121
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Read the `scripts` map from package.json as a string-keyed record of string
|
|
128
|
+
* command values.
|
|
129
|
+
*/
|
|
130
|
+
function readScripts(pkg) {
|
|
131
|
+
const scripts = readObjectField(pkg, 'scripts');
|
|
132
|
+
if (!scripts)
|
|
133
|
+
return undefined;
|
|
134
|
+
const out = {};
|
|
135
|
+
for (const [k, v] of Object.entries(scripts)) {
|
|
136
|
+
if (typeof v === 'string')
|
|
137
|
+
out[k] = v;
|
|
138
|
+
}
|
|
139
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
140
|
+
}
|
|
141
|
+
/** Matches an IPv4 address such as 192.168.1.1. */
|
|
142
|
+
const IPV4_PATTERN = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
|
143
|
+
/** Matches curl or wget invocations. */
|
|
144
|
+
const CURL_WGET_PATTERN = /\b(?:curl|wget)\b/;
|
|
145
|
+
/** Matches base64-looking blobs (long runs of base64 alphabet chars). */
|
|
146
|
+
const BASE64_BLOB_PATTERN = /\b[A-Za-z0-9+/]{40,}={0,2}\b/;
|
|
147
|
+
/** Matches shell command keywords commonly used in malicious payloads. */
|
|
148
|
+
const SHELL_KEYWORD_PATTERN = /\b(?:sh|bash|curl|wget|nc|ncat|python|perl|ruby|powershell)\b/;
|
|
149
|
+
/** Matches npm auth tokens. */
|
|
150
|
+
const NPM_TOKEN_PATTERN = /npm_[A-Za-z0-9]{20,}/;
|
|
151
|
+
/** Matches AWS access key ids. */
|
|
152
|
+
const AWS_KEY_PATTERN = /AKIA[0-9A-Z]{16}/;
|
|
153
|
+
/** Matches SSH private key headers. */
|
|
154
|
+
const SSH_KEY_PATTERN = /-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/;
|
|
155
|
+
/** Matches links to executable files in markdown. */
|
|
156
|
+
const BINARY_LINK_PATTERN = /\bhttps?:\/\/[^\s)]+?\.(?:exe|sh|bat|ps1|cmd|scr|msi)\b/i;
|
|
157
|
+
/** Matches require/import of the child_process module. */
|
|
158
|
+
const CHILD_PROCESS_PATTERN = /(?:require\s*\(\s*['"]child_process['"]\)|\bfrom\s+['"]child_process['"]\b|import\s*\(\s*['"]child_process['"]\s*\))/;
|
|
159
|
+
/** Matches eval( and Function( invocations. */
|
|
160
|
+
const EVAL_FUNCTION_PATTERN = /\b(?:eval|Function)\s*\(/;
|
|
161
|
+
/** Matches hex-encoded escape sequences or unicode escapes that suggest obfuscation. */
|
|
162
|
+
const ENCODED_STRING_PATTERN = /\\x[0-9a-fA-F]{2}|\\u[0-9a-fA-F]{4}/;
|
|
163
|
+
/**
|
|
164
|
+
* Rule: install-script
|
|
165
|
+
*
|
|
166
|
+
* Detects lifecycle scripts (postinstall/preinstall) that fetch remote content
|
|
167
|
+
* via curl/wget to a raw IP address — a common supply-chain attack pattern.
|
|
168
|
+
*/
|
|
169
|
+
const installScriptRule = {
|
|
170
|
+
id: 'install-script',
|
|
171
|
+
name: 'Suspicious install script',
|
|
172
|
+
description: 'Lifecycle scripts (postinstall/preinstall) that curl/wget a raw IP address.',
|
|
173
|
+
severity: Severity.Critical,
|
|
174
|
+
category: FindingCategory.InstallScript,
|
|
175
|
+
enabled: true,
|
|
176
|
+
match(readme, packageJson) {
|
|
177
|
+
const findings = [];
|
|
178
|
+
const scripts = readScripts(packageJson);
|
|
179
|
+
if (!scripts)
|
|
180
|
+
return findings;
|
|
181
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
182
|
+
if (name === 'postinstall' ||
|
|
183
|
+
name === 'preinstall' ||
|
|
184
|
+
name === 'install') {
|
|
185
|
+
if (CURL_WGET_PATTERN.test(command) && IPV4_PATTERN.test(command)) {
|
|
186
|
+
findings.push({
|
|
187
|
+
ruleId: 'install-script',
|
|
188
|
+
ruleName: 'Suspicious install script',
|
|
189
|
+
severity: Severity.Critical,
|
|
190
|
+
message: `Lifecycle script "${name}" fetches content from a raw IP address via curl/wget.`,
|
|
191
|
+
codeSnippet: command,
|
|
192
|
+
recommendation: 'Remove network fetches from lifecycle scripts; vendor required assets instead.',
|
|
193
|
+
category: FindingCategory.InstallScript,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return findings;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Rule: eval-obfuscation
|
|
203
|
+
*
|
|
204
|
+
* Detects `eval(` or `Function(` invocations combined with encoded/nested
|
|
205
|
+
* string content (hex/unicode escapes), which is a hallmark of obfuscated
|
|
206
|
+
* malicious payloads.
|
|
207
|
+
*/
|
|
208
|
+
const evalObfuscationRule = {
|
|
209
|
+
id: 'eval-obfuscation',
|
|
210
|
+
name: 'eval/Function obfuscation',
|
|
211
|
+
description: 'Use of eval() or Function() with encoded (hex/unicode) string content.',
|
|
212
|
+
severity: Severity.High,
|
|
213
|
+
category: FindingCategory.CodeObfuscation,
|
|
214
|
+
enabled: true,
|
|
215
|
+
match(readme) {
|
|
216
|
+
const findings = [];
|
|
217
|
+
if (!EVAL_FUNCTION_PATTERN.test(readme))
|
|
218
|
+
return findings;
|
|
219
|
+
if (!ENCODED_STRING_PATTERN.test(readme))
|
|
220
|
+
return findings;
|
|
221
|
+
const lineNumber = findLineNumber(readme, EVAL_FUNCTION_PATTERN.source.includes('eval') ? 'eval' : 'Function');
|
|
222
|
+
findings.push({
|
|
223
|
+
ruleId: 'eval-obfuscation',
|
|
224
|
+
ruleName: 'eval/Function obfuscation',
|
|
225
|
+
severity: Severity.High,
|
|
226
|
+
message: 'eval() or Function() used alongside hex/unicode-encoded strings, suggesting obfuscation.',
|
|
227
|
+
lineNumber,
|
|
228
|
+
recommendation: 'Avoid eval/Function; replace with static imports or JSON.parse for trusted data.',
|
|
229
|
+
category: FindingCategory.CodeObfuscation,
|
|
230
|
+
});
|
|
231
|
+
return findings;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Rule: base64-shell
|
|
236
|
+
*
|
|
237
|
+
* Detects base64-encoded blobs appearing near shell command keywords, a
|
|
238
|
+
* common pattern for hiding payloads that are decoded and piped to a shell.
|
|
239
|
+
*/
|
|
240
|
+
const base64ShellRule = {
|
|
241
|
+
id: 'base64-shell',
|
|
242
|
+
name: 'Base64-encoded shell payload',
|
|
243
|
+
description: 'Base64-encoded blobs appearing near shell command keywords (curl, sh, bash, ...).',
|
|
244
|
+
severity: Severity.High,
|
|
245
|
+
category: FindingCategory.CodeObfuscation,
|
|
246
|
+
enabled: true,
|
|
247
|
+
match(readme) {
|
|
248
|
+
const findings = [];
|
|
249
|
+
if (!BASE64_BLOB_PATTERN.test(readme))
|
|
250
|
+
return findings;
|
|
251
|
+
if (!SHELL_KEYWORD_PATTERN.test(readme))
|
|
252
|
+
return findings;
|
|
253
|
+
const lines = splitLines(readme);
|
|
254
|
+
let blobLine;
|
|
255
|
+
let shellLine;
|
|
256
|
+
for (let i = 0; i < lines.length; i++) {
|
|
257
|
+
if (blobLine === undefined && BASE64_BLOB_PATTERN.test(lines[i])) {
|
|
258
|
+
blobLine = i + 1;
|
|
259
|
+
}
|
|
260
|
+
if (shellLine === undefined && SHELL_KEYWORD_PATTERN.test(lines[i])) {
|
|
261
|
+
shellLine = i + 1;
|
|
262
|
+
}
|
|
263
|
+
if (blobLine !== undefined && shellLine !== undefined)
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
findings.push({
|
|
267
|
+
ruleId: 'base64-shell',
|
|
268
|
+
ruleName: 'Base64-encoded shell payload',
|
|
269
|
+
severity: Severity.High,
|
|
270
|
+
message: 'Base64-encoded blob found near shell command keywords, possibly hiding a decoded payload.',
|
|
271
|
+
lineNumber: blobLine ?? shellLine,
|
|
272
|
+
recommendation: 'Decode and inspect any base64 blobs; remove shell execution from package code.',
|
|
273
|
+
category: FindingCategory.CodeObfuscation,
|
|
274
|
+
});
|
|
275
|
+
return findings;
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* Rule: binary-links
|
|
280
|
+
*
|
|
281
|
+
* Detects README links pointing directly to executable files (.exe/.sh/.bat/
|
|
282
|
+
* .ps1), which may be used to trick users into running untrusted binaries.
|
|
283
|
+
*/
|
|
284
|
+
const binaryLinksRule = {
|
|
285
|
+
id: 'binary-links',
|
|
286
|
+
name: 'Direct binary download links',
|
|
287
|
+
description: 'README links pointing directly to executable files (.exe/.sh/.bat/.ps1).',
|
|
288
|
+
severity: Severity.Medium,
|
|
289
|
+
category: FindingCategory.BinaryDownload,
|
|
290
|
+
enabled: true,
|
|
291
|
+
match(readme) {
|
|
292
|
+
const findings = [];
|
|
293
|
+
const lines = splitLines(readme);
|
|
294
|
+
for (let i = 0; i < lines.length; i++) {
|
|
295
|
+
const match = lines[i].match(BINARY_LINK_PATTERN);
|
|
296
|
+
if (match) {
|
|
297
|
+
findings.push({
|
|
298
|
+
ruleId: 'binary-links',
|
|
299
|
+
ruleName: 'Direct binary download links',
|
|
300
|
+
severity: Severity.Medium,
|
|
301
|
+
message: `README links directly to an executable: ${match[0]}`,
|
|
302
|
+
codeSnippet: match[0],
|
|
303
|
+
lineNumber: i + 1,
|
|
304
|
+
recommendation: 'Avoid linking to raw executables; publish to a registry or provide source.',
|
|
305
|
+
category: FindingCategory.BinaryDownload,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return findings;
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
/**
|
|
313
|
+
* Rule: typosquatting
|
|
314
|
+
*
|
|
315
|
+
* Detects package names within Levenshtein distance <= 2 of a popular package
|
|
316
|
+
* name, while not being an exact match (scoped variants are compared on the
|
|
317
|
+
* unscoped portion).
|
|
318
|
+
*/
|
|
319
|
+
const typosquattingRule = {
|
|
320
|
+
id: 'typosquatting',
|
|
321
|
+
name: 'Typosquatting candidate',
|
|
322
|
+
description: 'Package name within edit distance 2 of a popular package name.',
|
|
323
|
+
severity: Severity.High,
|
|
324
|
+
category: FindingCategory.Typosquatting,
|
|
325
|
+
enabled: true,
|
|
326
|
+
match(_readme, packageJson) {
|
|
327
|
+
const findings = [];
|
|
328
|
+
const rawName = readStringField(packageJson, 'name');
|
|
329
|
+
if (!rawName)
|
|
330
|
+
return findings;
|
|
331
|
+
const name = rawName.startsWith('@')
|
|
332
|
+
? rawName.split('/').pop() ?? rawName
|
|
333
|
+
: rawName;
|
|
334
|
+
if (name.length < 3)
|
|
335
|
+
return findings;
|
|
336
|
+
for (const popular of POPULAR_PACKAGES) {
|
|
337
|
+
if (name === popular)
|
|
338
|
+
return findings; // exact match — not typosquatting
|
|
339
|
+
const distance = levenshtein(name.toLowerCase(), popular.toLowerCase());
|
|
340
|
+
if (distance > 0 && distance <= 2) {
|
|
341
|
+
findings.push({
|
|
342
|
+
ruleId: 'typosquatting',
|
|
343
|
+
ruleName: 'Typosquatting candidate',
|
|
344
|
+
severity: Severity.High,
|
|
345
|
+
message: `Package name "${rawName}" is within edit distance ${distance} of popular package "${popular}".`,
|
|
346
|
+
recommendation: `Verify this is the intended package and not a typosquat of "${popular}".`,
|
|
347
|
+
category: FindingCategory.Typosquatting,
|
|
348
|
+
});
|
|
349
|
+
return findings; // one finding is enough
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return findings;
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
/**
|
|
356
|
+
* Rule: secret-exposure
|
|
357
|
+
*
|
|
358
|
+
* Detects exposed secrets in README or package.json fields: npm tokens
|
|
359
|
+
* (`npm_...`), AWS access keys (`AKIA...`), and SSH private key blocks.
|
|
360
|
+
*/
|
|
361
|
+
const secretExposureRule = {
|
|
362
|
+
id: 'secret-exposure',
|
|
363
|
+
name: 'Exposed secret',
|
|
364
|
+
description: 'npm tokens, AWS keys, or SSH private keys found in README or package.json.',
|
|
365
|
+
severity: Severity.Critical,
|
|
366
|
+
category: FindingCategory.SensitiveExposure,
|
|
367
|
+
enabled: true,
|
|
368
|
+
match(readme, packageJson) {
|
|
369
|
+
const findings = [];
|
|
370
|
+
const haystacks = [
|
|
371
|
+
{ text: readme, source: 'README' },
|
|
372
|
+
];
|
|
373
|
+
if (packageJson) {
|
|
374
|
+
haystacks.push({
|
|
375
|
+
text: JSON.stringify(packageJson),
|
|
376
|
+
source: 'package.json',
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
for (const { text, source } of haystacks) {
|
|
380
|
+
if (NPM_TOKEN_PATTERN.test(text)) {
|
|
381
|
+
findings.push({
|
|
382
|
+
ruleId: 'secret-exposure',
|
|
383
|
+
ruleName: 'Exposed secret',
|
|
384
|
+
severity: Severity.Critical,
|
|
385
|
+
message: `npm access token found in ${source}.`,
|
|
386
|
+
recommendation: 'Rotate the token immediately and remove it from the package.',
|
|
387
|
+
category: FindingCategory.SensitiveExposure,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
if (AWS_KEY_PATTERN.test(text)) {
|
|
391
|
+
findings.push({
|
|
392
|
+
ruleId: 'secret-exposure',
|
|
393
|
+
ruleName: 'Exposed secret',
|
|
394
|
+
severity: Severity.Critical,
|
|
395
|
+
message: `AWS access key id found in ${source}.`,
|
|
396
|
+
recommendation: 'Rotate the AWS key immediately and remove it from the package.',
|
|
397
|
+
category: FindingCategory.SensitiveExposure,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (SSH_KEY_PATTERN.test(text)) {
|
|
401
|
+
findings.push({
|
|
402
|
+
ruleId: 'secret-exposure',
|
|
403
|
+
ruleName: 'Exposed secret',
|
|
404
|
+
severity: Severity.Critical,
|
|
405
|
+
message: `SSH private key block found in ${source}.`,
|
|
406
|
+
recommendation: 'Remove the private key and regenerate the keypair.',
|
|
407
|
+
category: FindingCategory.SensitiveExposure,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return findings;
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* Rule: child-process-browser
|
|
416
|
+
*
|
|
417
|
+
* Detects use of `child_process` in packages that appear to target the
|
|
418
|
+
* browser (declared `browser` field in package.json, or a name suggesting a
|
|
419
|
+
* frontend framework).
|
|
420
|
+
*/
|
|
421
|
+
const childProcessBrowserRule = {
|
|
422
|
+
id: 'child-process-browser',
|
|
423
|
+
name: 'child_process in browser-targeted package',
|
|
424
|
+
description: 'Use of child_process in a package that declares browser targeting.',
|
|
425
|
+
severity: Severity.High,
|
|
426
|
+
category: FindingCategory.SuspiciousDep,
|
|
427
|
+
enabled: true,
|
|
428
|
+
match(readme, packageJson) {
|
|
429
|
+
const findings = [];
|
|
430
|
+
if (!packageJson)
|
|
431
|
+
return findings;
|
|
432
|
+
const hasBrowserField = Object.prototype.hasOwnProperty.call(packageJson, 'browser');
|
|
433
|
+
const name = readStringField(packageJson, 'name') ?? '';
|
|
434
|
+
const frontendHint = /\b(?:react|vue|angular|svelte|solid|frontend|client|browser|dom|ui)\b/i.test(name);
|
|
435
|
+
if (!hasBrowserField && !frontendHint)
|
|
436
|
+
return findings;
|
|
437
|
+
if (!CHILD_PROCESS_PATTERN.test(readme)) {
|
|
438
|
+
// Also check package.json scripts for child_process usage.
|
|
439
|
+
const scripts = readScripts(packageJson);
|
|
440
|
+
if (!scripts)
|
|
441
|
+
return findings;
|
|
442
|
+
const allScripts = Object.values(scripts).join(' ');
|
|
443
|
+
if (!CHILD_PROCESS_PATTERN.test(allScripts))
|
|
444
|
+
return findings;
|
|
445
|
+
}
|
|
446
|
+
findings.push({
|
|
447
|
+
ruleId: 'child-process-browser',
|
|
448
|
+
ruleName: 'child_process in browser-targeted package',
|
|
449
|
+
severity: Severity.High,
|
|
450
|
+
message: 'Package appears browser-targeted but references child_process, which is unavailable in browsers.',
|
|
451
|
+
recommendation: 'Remove child_process usage from browser-targeted code paths.',
|
|
452
|
+
category: FindingCategory.SuspiciousDep,
|
|
453
|
+
});
|
|
454
|
+
return findings;
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
/**
|
|
458
|
+
* Rule: suspicious-build-metadata
|
|
459
|
+
*
|
|
460
|
+
* Detects odd build metadata in package.json such as a `_generatedBy` field
|
|
461
|
+
* or other underscore-prefixed private keys that are not part of the standard
|
|
462
|
+
* npm metadata set.
|
|
463
|
+
*/
|
|
464
|
+
const suspiciousBuildMetadataRule = {
|
|
465
|
+
id: 'suspicious-build-metadata',
|
|
466
|
+
name: 'Suspicious build metadata',
|
|
467
|
+
description: 'Non-standard underscore-prefixed metadata fields in package.json.',
|
|
468
|
+
severity: Severity.Low,
|
|
469
|
+
category: FindingCategory.Informational,
|
|
470
|
+
enabled: true,
|
|
471
|
+
match(_readme, packageJson) {
|
|
472
|
+
const findings = [];
|
|
473
|
+
if (!packageJson)
|
|
474
|
+
return findings;
|
|
475
|
+
const knownUnderscoreKeys = new Set([
|
|
476
|
+
'_from',
|
|
477
|
+
'_id',
|
|
478
|
+
'_nodeVersion',
|
|
479
|
+
'_npmVersion',
|
|
480
|
+
'_npmUser',
|
|
481
|
+
'_npmOperationalInternal',
|
|
482
|
+
'_resolved',
|
|
483
|
+
'_shasum',
|
|
484
|
+
'_integrity',
|
|
485
|
+
'_location',
|
|
486
|
+
'_phantomChildren',
|
|
487
|
+
'_requested',
|
|
488
|
+
'_requiredBy',
|
|
489
|
+
'_inCache',
|
|
490
|
+
]);
|
|
491
|
+
for (const key of Object.keys(packageJson)) {
|
|
492
|
+
if (!key.startsWith('_'))
|
|
493
|
+
continue;
|
|
494
|
+
if (knownUnderscoreKeys.has(key))
|
|
495
|
+
continue;
|
|
496
|
+
findings.push({
|
|
497
|
+
ruleId: 'suspicious-build-metadata',
|
|
498
|
+
ruleName: 'Suspicious build metadata',
|
|
499
|
+
severity: Severity.Low,
|
|
500
|
+
message: `Non-standard metadata field "${key}" present in package.json.`,
|
|
501
|
+
recommendation: 'Inspect the field; remove if injected by a build tool.',
|
|
502
|
+
category: FindingCategory.Informational,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return findings;
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
/**
|
|
509
|
+
* Rule: homograph-attack
|
|
510
|
+
*
|
|
511
|
+
* Detects Unicode homograph characters in the package name — e.g. Cyrillic
|
|
512
|
+
* 'а' (U+0430) substituted for Latin 'a' (U+0061) to impersonate a popular
|
|
513
|
+
* package.
|
|
514
|
+
*/
|
|
515
|
+
const homographAttackRule = {
|
|
516
|
+
id: 'homograph-attack',
|
|
517
|
+
name: 'Homograph attack in package name',
|
|
518
|
+
description: 'Non-ASCII (homograph) characters in the package name that mimic ASCII lookalikes.',
|
|
519
|
+
severity: Severity.Critical,
|
|
520
|
+
category: FindingCategory.HomographAttack,
|
|
521
|
+
enabled: true,
|
|
522
|
+
match(_readme, packageJson) {
|
|
523
|
+
const findings = [];
|
|
524
|
+
const name = readStringField(packageJson, 'name');
|
|
525
|
+
if (!name)
|
|
526
|
+
return findings;
|
|
527
|
+
// Strip the scope prefix; homograph attacks target the unscoped portion.
|
|
528
|
+
const unscoped = name.startsWith('@')
|
|
529
|
+
? (name.split('/').pop() ?? name)
|
|
530
|
+
: name;
|
|
531
|
+
// Allowed ASCII for npm package names: a-z 0-9 - _ . ~
|
|
532
|
+
// Anything outside this set (excluding the scope slash handled above) is
|
|
533
|
+
// a potential homograph character.
|
|
534
|
+
const allowed = /^[a-z0-9._-]+$/i;
|
|
535
|
+
if (!allowed.test(unscoped)) {
|
|
536
|
+
const suspiciousChars = [];
|
|
537
|
+
for (const ch of unscoped) {
|
|
538
|
+
if (!/[a-z0-9._-]/i.test(ch)) {
|
|
539
|
+
suspiciousChars.push(ch);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
findings.push({
|
|
543
|
+
ruleId: 'homograph-attack',
|
|
544
|
+
ruleName: 'Homograph attack in package name',
|
|
545
|
+
severity: Severity.Critical,
|
|
546
|
+
message: `Package name "${name}" contains non-ASCII characters that may be homograph lookalikes: ${suspiciousChars.join(', ')}.`,
|
|
547
|
+
recommendation: 'Verify the package name uses only ASCII characters and is the intended package.',
|
|
548
|
+
category: FindingCategory.HomographAttack,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
return findings;
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
/**
|
|
555
|
+
* Rule: registry-mismatch
|
|
556
|
+
*
|
|
557
|
+
* Detects a `publishConfig.registry` value that does not point to the
|
|
558
|
+
* standard npm registry, which can indicate packages published to a private or
|
|
559
|
+
* attacker-controlled registry.
|
|
560
|
+
*/
|
|
561
|
+
const registryMismatchRule = {
|
|
562
|
+
id: 'registry-mismatch',
|
|
563
|
+
name: 'Non-standard publish registry',
|
|
564
|
+
description: 'publishConfig.registry points to a registry other than registry.npmjs.org.',
|
|
565
|
+
severity: Severity.Medium,
|
|
566
|
+
category: FindingCategory.RegistryMismatch,
|
|
567
|
+
enabled: true,
|
|
568
|
+
match(_readme, packageJson) {
|
|
569
|
+
const findings = [];
|
|
570
|
+
const publishConfig = readObjectField(packageJson, 'publishConfig');
|
|
571
|
+
if (!publishConfig)
|
|
572
|
+
return findings;
|
|
573
|
+
const registry = readStringField(publishConfig, 'registry');
|
|
574
|
+
if (!registry)
|
|
575
|
+
return findings;
|
|
576
|
+
if (registry !== STANDARD_REGISTRY && !registry.startsWith(STANDARD_REGISTRY)) {
|
|
577
|
+
findings.push({
|
|
578
|
+
ruleId: 'registry-mismatch',
|
|
579
|
+
ruleName: 'Non-standard publish registry',
|
|
580
|
+
severity: Severity.Medium,
|
|
581
|
+
message: `publishConfig.registry is set to a non-standard registry: ${registry}`,
|
|
582
|
+
codeSnippet: registry,
|
|
583
|
+
recommendation: 'Confirm the registry is trusted; standard npm packages use https://registry.npmjs.org/.',
|
|
584
|
+
category: FindingCategory.RegistryMismatch,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return findings;
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
/** All built-in static analysis rules, in registration order. */
|
|
591
|
+
export const BUILTIN_RULES = [
|
|
592
|
+
installScriptRule,
|
|
593
|
+
evalObfuscationRule,
|
|
594
|
+
base64ShellRule,
|
|
595
|
+
binaryLinksRule,
|
|
596
|
+
typosquattingRule,
|
|
597
|
+
secretExposureRule,
|
|
598
|
+
childProcessBrowserRule,
|
|
599
|
+
suspiciousBuildMetadataRule,
|
|
600
|
+
homographAttackRule,
|
|
601
|
+
registryMismatchRule,
|
|
602
|
+
];
|
|
603
|
+
/** Ids of the built-in rules, used to label rule provenance. */
|
|
604
|
+
export const BUILTIN_RULE_IDS = new Set(BUILTIN_RULES.map((r) => r.id));
|
|
605
|
+
/**
|
|
606
|
+
* Static analysis engine that runs a set of {@link ScanRule}s against a
|
|
607
|
+
* package's README and package.json and aggregates the findings into a
|
|
608
|
+
* {@link StaticScanReport}.
|
|
609
|
+
*/
|
|
610
|
+
export class StaticAnalyzer {
|
|
611
|
+
rules;
|
|
612
|
+
config;
|
|
613
|
+
/**
|
|
614
|
+
* @param rules - Optional custom rule set. Defaults to all built-in rules.
|
|
615
|
+
* @param config - Optional per-rule configuration manager whose overrides
|
|
616
|
+
* (enabled / severity) are applied at analysis time.
|
|
617
|
+
*/
|
|
618
|
+
constructor(rules, config) {
|
|
619
|
+
this.rules = new Map((rules ?? [...BUILTIN_RULES]).map((r) => [r.id, r]));
|
|
620
|
+
this.config = config ?? null;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Register a rule at runtime. A rule with the same id replaces the existing
|
|
624
|
+
* one (keeping its position in the registration order).
|
|
625
|
+
*
|
|
626
|
+
* @param rule - The rule to register.
|
|
627
|
+
*/
|
|
628
|
+
registerRule(rule) {
|
|
629
|
+
this.rules.set(rule.id, rule);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Remove a rule by id.
|
|
633
|
+
*
|
|
634
|
+
* @param ruleId - Id of the rule to remove.
|
|
635
|
+
* @returns `true` if a rule was removed, `false` if no such rule exists.
|
|
636
|
+
*/
|
|
637
|
+
unregisterRule(ruleId) {
|
|
638
|
+
return this.rules.delete(ruleId);
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Describe every registered rule with its effective status.
|
|
642
|
+
*
|
|
643
|
+
* @returns Rule descriptors in registration order.
|
|
644
|
+
*/
|
|
645
|
+
listRules() {
|
|
646
|
+
const descriptors = [];
|
|
647
|
+
for (const rule of this.rules.values()) {
|
|
648
|
+
const severity = this.config?.getSeverityOverride(rule.id) ?? rule.severity;
|
|
649
|
+
const enabled = this.config?.isEnabled(rule.id, rule.enabled) ?? rule.enabled;
|
|
650
|
+
descriptors.push({
|
|
651
|
+
id: rule.id,
|
|
652
|
+
name: rule.name,
|
|
653
|
+
description: rule.description,
|
|
654
|
+
severity,
|
|
655
|
+
category: rule.category,
|
|
656
|
+
enabled,
|
|
657
|
+
source: BUILTIN_RULE_IDS.has(rule.id) ? 'builtin' : 'plugin',
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
return descriptors;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Run all enabled rules against the given README and package.json, aggregate
|
|
664
|
+
* the findings, and compute a numeric score plus overall security level.
|
|
665
|
+
*
|
|
666
|
+
* Scoring starts at 100 and subtracts a per-finding weight based on severity:
|
|
667
|
+
* critical=-25, high=-15, medium=-8, low=-3. The score is clamped to [0, 100].
|
|
668
|
+
*
|
|
669
|
+
* Overall level: score >= 80 → Safe, >= 50 → Suspicious, >= 20 → Dangerous,
|
|
670
|
+
* else Unknown.
|
|
671
|
+
*
|
|
672
|
+
* @param readme - README content as a string (may be empty).
|
|
673
|
+
* @param packageJson - Parsed package.json, if available.
|
|
674
|
+
* @returns The aggregated static scan report.
|
|
675
|
+
*/
|
|
676
|
+
analyze(readme, packageJson) {
|
|
677
|
+
const findings = [];
|
|
678
|
+
for (const rule of this.rules.values()) {
|
|
679
|
+
const enabled = this.config?.isEnabled(rule.id, rule.enabled) ?? rule.enabled;
|
|
680
|
+
if (!enabled)
|
|
681
|
+
continue;
|
|
682
|
+
const severityOverride = this.config?.getSeverityOverride(rule.id);
|
|
683
|
+
const ruleFindings = rule.match(readme, packageJson);
|
|
684
|
+
for (const f of ruleFindings) {
|
|
685
|
+
findings.push(severityOverride && severityOverride !== f.severity
|
|
686
|
+
? { ...f, severity: severityOverride }
|
|
687
|
+
: f);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
let score = MAX_SCORE;
|
|
691
|
+
for (const f of findings) {
|
|
692
|
+
score -= SEVERITY_WEIGHT[f.severity];
|
|
693
|
+
}
|
|
694
|
+
score = Math.max(MIN_SCORE, Math.min(MAX_SCORE, score));
|
|
695
|
+
const overallLevel = this.levelFromScore(score);
|
|
696
|
+
const packageName = readStringField(packageJson, 'name') ?? '<unknown>';
|
|
697
|
+
const version = readStringField(packageJson, 'version') ?? '0.0.0';
|
|
698
|
+
return {
|
|
699
|
+
packageName,
|
|
700
|
+
version,
|
|
701
|
+
overallLevel,
|
|
702
|
+
score,
|
|
703
|
+
findings,
|
|
704
|
+
scannedAt: new Date().toISOString(),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Map a numeric score to a {@link SecurityLevel}.
|
|
709
|
+
*
|
|
710
|
+
* @param score - Clamped score in [0, 100].
|
|
711
|
+
* @returns The corresponding security level.
|
|
712
|
+
*/
|
|
713
|
+
levelFromScore(score) {
|
|
714
|
+
if (score >= 80)
|
|
715
|
+
return SecurityLevel.Safe;
|
|
716
|
+
if (score >= 50)
|
|
717
|
+
return SecurityLevel.Suspicious;
|
|
718
|
+
if (score >= 20)
|
|
719
|
+
return SecurityLevel.Dangerous;
|
|
720
|
+
return SecurityLevel.Unknown;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
//# sourceMappingURL=static-rules.js.map
|