@lateos/npm-scan 0.15.0 → 0.15.1
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/backend/detectors/atk-002-obfusc.js +53 -5
- package/backend/policy.js +75 -10
- package/package.json +4 -4
|
@@ -1,3 +1,45 @@
|
|
|
1
|
+
const DIST_BUILD_PATTERNS = [/\/dist\//, /\/build\//, /\/bundle/, /\/min\//, /\.min\.js$/, /\.bundled?\.js$/];
|
|
2
|
+
const TEST_FIXTURE_PATTERNS = [/\/test\//, /\/tests\//, /\/__tests__\//, /\/spec\//, /\.test\.js$/, /\.spec\.js$/, /fixtures?/];
|
|
3
|
+
const LIFECYCLE_HOOK_PATTERNS = [/postinstall/, /preinstall/, /['"]install['"]/, /['"]prepare['"]/];
|
|
4
|
+
const KNOWN_SAFE_DOMAINS = [
|
|
5
|
+
'registry.npmjs.org', 'cdn.jsdelivr.net', 'unpkg.com', 'cdn.skypack.dev',
|
|
6
|
+
'esm.sh', 'deno.land', 'raw.githubusercontent.com', 'github.com',
|
|
7
|
+
'npmjs.com', 'nodejs.org', 'v8.dev', 'typescriptlang.org'
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
function extractUrlDomain(code) {
|
|
11
|
+
const urlMatch = code.match(/https?:\/\/([^/'"\s]+)/);
|
|
12
|
+
return urlMatch ? urlMatch[1] : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isDistOrBuild(filePath) {
|
|
16
|
+
return DIST_BUILD_PATTERNS.some(p => p.test(filePath));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isTestOrFixture(filePath) {
|
|
20
|
+
return TEST_FIXTURE_PATTERNS.some(p => p.test(filePath));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLifecycleHook(code) {
|
|
24
|
+
return LIFECYCLE_HOOK_PATTERNS.some(p => p.test(code));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isKnownSafeDomain(domain) {
|
|
28
|
+
if (!domain) return false;
|
|
29
|
+
return KNOWN_SAFE_DOMAINS.some(safe => domain === safe || domain.endsWith('.' + safe));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createContext(filePath, code) {
|
|
33
|
+
return {
|
|
34
|
+
file_path: filePath,
|
|
35
|
+
is_dist_build: isDistOrBuild(filePath),
|
|
36
|
+
is_test_fixture: isTestOrFixture(filePath),
|
|
37
|
+
is_lifecycle_hook: isLifecycleHook(code),
|
|
38
|
+
url_domain: extractUrlDomain(code),
|
|
39
|
+
is_known_safe_domain: isKnownSafeDomain(extractUrlDomain(code)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
1
43
|
export async function scan(pkgJson, files = []) {
|
|
2
44
|
const findings = [];
|
|
3
45
|
const pkgName = pkgJson?.name || '';
|
|
@@ -5,6 +47,7 @@ export async function scan(pkgJson, files = []) {
|
|
|
5
47
|
|
|
6
48
|
for (const f of files) {
|
|
7
49
|
const code = f.content;
|
|
50
|
+
const ctx = createContext(f.path, code);
|
|
8
51
|
|
|
9
52
|
const hasEval = /eval\(|new Function\(|\bFunction\('/.test(code);
|
|
10
53
|
|
|
@@ -19,7 +62,8 @@ export async function scan(pkgJson, files = []) {
|
|
|
19
62
|
severity: 'medium',
|
|
20
63
|
title: 'Obfuscated payload',
|
|
21
64
|
description: hexDecode ? 'Eval with hex-decoded payload' : 'Eval with base64-decoded payload',
|
|
22
|
-
evidence: 'eval + decode pattern detected'
|
|
65
|
+
evidence: 'eval + decode pattern detected',
|
|
66
|
+
context: ctx,
|
|
23
67
|
});
|
|
24
68
|
return findings;
|
|
25
69
|
}
|
|
@@ -32,7 +76,8 @@ export async function scan(pkgJson, files = []) {
|
|
|
32
76
|
severity: 'high',
|
|
33
77
|
title: 'Obfuscated payload',
|
|
34
78
|
description: 'Double-encoded nested payload',
|
|
35
|
-
evidence: 'nested encode/decode detected'
|
|
79
|
+
evidence: 'nested encode/decode detected',
|
|
80
|
+
context: { ...ctx, is_multi_layer: true },
|
|
36
81
|
});
|
|
37
82
|
return findings;
|
|
38
83
|
}
|
|
@@ -48,7 +93,8 @@ export async function scan(pkgJson, files = []) {
|
|
|
48
93
|
severity: 'medium',
|
|
49
94
|
title: 'Obfuscated payload',
|
|
50
95
|
description: 'Decoded string containing URL/fetch call',
|
|
51
|
-
evidence: 'obfuscation with network call'
|
|
96
|
+
evidence: 'obfuscation with network call',
|
|
97
|
+
context: ctx,
|
|
52
98
|
});
|
|
53
99
|
return findings;
|
|
54
100
|
}
|
|
@@ -60,7 +106,8 @@ export async function scan(pkgJson, files = []) {
|
|
|
60
106
|
severity: 'medium',
|
|
61
107
|
title: 'Obfuscated payload',
|
|
62
108
|
description: 'Eval with String.fromCharCode obfuscation',
|
|
63
|
-
evidence: 'charcode obfuscation detected'
|
|
109
|
+
evidence: 'charcode obfuscation detected',
|
|
110
|
+
context: ctx,
|
|
64
111
|
});
|
|
65
112
|
return findings;
|
|
66
113
|
}
|
|
@@ -79,7 +126,8 @@ export async function scan(pkgJson, files = []) {
|
|
|
79
126
|
severity: 'high',
|
|
80
127
|
title: 'Obfuscated payload',
|
|
81
128
|
description: 'Shell-code obfuscation pattern',
|
|
82
|
-
evidence: p.source.substring(0, 60)
|
|
129
|
+
evidence: p.source.substring(0, 60),
|
|
130
|
+
context: ctx,
|
|
83
131
|
});
|
|
84
132
|
return findings;
|
|
85
133
|
}
|
package/backend/policy.js
CHANGED
|
@@ -4,10 +4,77 @@ import { load as yamlLoad } from 'js-yaml';
|
|
|
4
4
|
const SEVERITY_ORDER = ['none', 'low', 'medium', 'high', 'critical'];
|
|
5
5
|
const VALID_SEVERITIES = new Set(SEVERITY_ORDER);
|
|
6
6
|
|
|
7
|
+
const KNOWN_REPUTABLE_PACKAGES = new Set([
|
|
8
|
+
'react', 'react-dom', 'vue', 'angular', 'next', 'nuxt',
|
|
9
|
+
'express', 'fastify', 'hono', 'koa', 'connect',
|
|
10
|
+
'webpack', 'vite', 'rollup', 'esbuild', 'typescript', 'babel-core',
|
|
11
|
+
'lodash', 'ramda', 'underscore',
|
|
12
|
+
'axios', 'node-fetch', 'got', 'superagent',
|
|
13
|
+
'sequelize', 'prisma', 'typeorm', 'mongoose',
|
|
14
|
+
'jest', 'mocha', 'vitest', 'ava',
|
|
15
|
+
'prettier', 'eslint', 'stylelint',
|
|
16
|
+
'socket.io', 'ws',
|
|
17
|
+
'rimraf', 'glob', 'minimatch', 'fs-extra',
|
|
18
|
+
]);
|
|
19
|
+
|
|
7
20
|
function severityIndex(s) {
|
|
8
21
|
return SEVERITY_ORDER.indexOf(s);
|
|
9
22
|
}
|
|
10
23
|
|
|
24
|
+
function matchesFilePath(filePath, pattern) {
|
|
25
|
+
if (!pattern) return false;
|
|
26
|
+
if (pattern === '*') return true;
|
|
27
|
+
const regexPattern = pattern
|
|
28
|
+
.replace(/\./g, '\\.')
|
|
29
|
+
.replace(/\*\*/g, '___DOUBLE_STAR___')
|
|
30
|
+
.replace(/\*/g, '[^/]*')
|
|
31
|
+
.replace(/___DOUBLE_STAR___/g, '.*');
|
|
32
|
+
return new RegExp(`^${regexPattern}$`).test(filePath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function matchesContext(finding, rule) {
|
|
36
|
+
const ctx = finding.context;
|
|
37
|
+
if (!ctx) return false;
|
|
38
|
+
|
|
39
|
+
if (rule.context?.is_dist_build === true && !ctx.is_dist_build) return false;
|
|
40
|
+
if (rule.context?.is_dist_build === false && ctx.is_dist_build) return false;
|
|
41
|
+
if (rule.context?.is_test_fixture === true && !ctx.is_test_fixture) return false;
|
|
42
|
+
if (rule.context?.is_test_fixture === false && ctx.is_test_fixture) return false;
|
|
43
|
+
if (rule.context?.is_lifecycle_hook === true && !ctx.is_lifecycle_hook) return false;
|
|
44
|
+
if (rule.context?.is_lifecycle_hook === false && ctx.is_lifecycle_hook) return false;
|
|
45
|
+
if (rule.context?.is_known_safe_domain === true && !ctx.is_known_safe_domain) return false;
|
|
46
|
+
if (rule.context?.is_known_safe_domain === false && ctx.is_known_safe_domain) return false;
|
|
47
|
+
|
|
48
|
+
if (rule.context?.file_path && !matchesFilePath(ctx.file_path, rule.context.file_path)) return false;
|
|
49
|
+
if (rule.context?.url_domain) {
|
|
50
|
+
if (!ctx.url_domain) return false;
|
|
51
|
+
const domainPattern = rule.context.url_domain.replace(/\*/g, '.*');
|
|
52
|
+
if (!new RegExp(`^${domainPattern}$`).test(ctx.url_domain)) return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getPackageReputationTier(pkgName) {
|
|
59
|
+
const name = pkgName?.replace(/^@/, '').replace(/\/.*/, '') || '';
|
|
60
|
+
if (KNOWN_REPUTABLE_PACKAGES.has(name)) return 'trusted';
|
|
61
|
+
return 'unknown';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function matchesSuppressRule(finding, pkgName, rule) {
|
|
65
|
+
if (rule.atk_id !== (finding.atk_id || finding.id)) return false;
|
|
66
|
+
if (rule.package && rule.package !== '*' && rule.package !== pkgName) return false;
|
|
67
|
+
|
|
68
|
+
if (rule.context && !matchesContext(finding, rule)) return false;
|
|
69
|
+
|
|
70
|
+
if (rule.reputation_tier) {
|
|
71
|
+
const tier = getPackageReputationTier(pkgName);
|
|
72
|
+
if (rule.reputation_tier !== tier && !(rule.reputation_tier === '*' || rule.reputation_tier === 'any')) return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
11
78
|
function loadPolicy(path) {
|
|
12
79
|
const raw = readFileSync(path, 'utf8').trim();
|
|
13
80
|
let policy;
|
|
@@ -63,6 +130,8 @@ function sanitizePolicy(policy) {
|
|
|
63
130
|
atk_id: r.atk_id,
|
|
64
131
|
package: r.package || '*',
|
|
65
132
|
reason: r.reason || '',
|
|
133
|
+
context: r.context || null,
|
|
134
|
+
reputation_tier: r.reputation_tier || null,
|
|
66
135
|
})),
|
|
67
136
|
};
|
|
68
137
|
}
|
|
@@ -73,19 +142,15 @@ function isAllowed(packageName, policy) {
|
|
|
73
142
|
return policy.allow.packages.some(p => p === packageName || p === nameOnly);
|
|
74
143
|
}
|
|
75
144
|
|
|
76
|
-
function matchesSuppressRule(finding, pkgName, rule) {
|
|
77
|
-
if (rule.atk_id !== (finding.atk_id || finding.id)) return false;
|
|
78
|
-
if (rule.package === '*') return true;
|
|
79
|
-
return rule.package === pkgName;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
145
|
function applyPolicy(findings, packageName, policy) {
|
|
83
146
|
let filtered = [...findings];
|
|
84
147
|
|
|
85
148
|
if (policy.suppress.length) {
|
|
86
|
-
filtered = filtered.filter(f =>
|
|
87
|
-
|
|
88
|
-
|
|
149
|
+
filtered = filtered.filter(f => {
|
|
150
|
+
if (f.context?.is_lifecycle_hook) return true;
|
|
151
|
+
if (f.context?.is_multi_layer) return true;
|
|
152
|
+
return !policy.suppress.some(r => matchesSuppressRule(f, packageName, r));
|
|
153
|
+
});
|
|
89
154
|
}
|
|
90
155
|
|
|
91
156
|
filtered = filtered.map(f => {
|
|
@@ -108,4 +173,4 @@ function checkFailOn(findings, policy) {
|
|
|
108
173
|
return findings.some(f => severityIndex(f.severity) >= threshold);
|
|
109
174
|
}
|
|
110
175
|
|
|
111
|
-
export { loadPolicy, applyPolicy, isAllowed };
|
|
176
|
+
export { loadPolicy, applyPolicy, isAllowed, getPackageReputationTier, matchesContext };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lateos/npm-scan",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Modern npm supply chain security scanner — detects obfuscated payloads, credential stealers, conditional triggers, sandbox evasion, and worm-like propagation. 11 attack types, SBOM, NIST/EU CRA compliance reporting.",
|
|
5
5
|
"main": "backend/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"corpus": "node tests/corpus/run.js"
|
|
35
35
|
},
|
|
36
36
|
"lint-staged": {
|
|
37
|
-
"**/package{,-lock}.json": "node cli/cli.js scan-lockfile --fail-on high",
|
|
38
|
-
"**/yarn.lock": "node cli/cli.js scan-lockfile --fail-on high --yarn",
|
|
39
|
-
"**/pnpm-lock.yaml": "node cli/cli.js scan-lockfile --fail-on high --pnpm"
|
|
37
|
+
"**/package{,-lock}.json": "sh -c 'node cli/cli.js scan-lockfile --fail-on high'",
|
|
38
|
+
"**/yarn.lock": "sh -c 'node cli/cli.js scan-lockfile --fail-on high --yarn'",
|
|
39
|
+
"**/pnpm-lock.yaml": "sh -c 'node cli/cli.js scan-lockfile --fail-on high --pnpm'"
|
|
40
40
|
},
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public"
|