@lateos/npm-scan 0.8.0 → 0.9.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/db.js +68 -22
- package/backend/detectors/atk-002-obfusc.js +76 -15
- package/backend/detectors/atk-008-tarball-tamper.js +65 -18
- package/backend/detectors/atk-009-dormant-trigger.js +25 -8
- package/backend/detectors/atk-011-transitive-prop.js +12 -21
- package/backend/fetch.js +5 -1
- package/cli/cli.js +6 -6
- package/package.json +3 -4
- package/lateos-npm-scan-0.8.0.tgz +0 -0
package/backend/db.js
CHANGED
|
@@ -1,42 +1,88 @@
|
|
|
1
|
-
import
|
|
1
|
+
import initSqlJs from 'sql.js';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const DB_PATH = path.join(process.cwd(), 'npm-scan.db');
|
|
8
|
+
const SCHEMA_PATH = path.join(__dirname, 'db', 'schema.sql');
|
|
6
9
|
|
|
7
|
-
let db;
|
|
10
|
+
let db = null;
|
|
11
|
+
let initPromise = null;
|
|
8
12
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
async function ensureInit() {
|
|
14
|
+
if (db) return;
|
|
15
|
+
if (initPromise) return initPromise;
|
|
16
|
+
initPromise = (async () => {
|
|
17
|
+
const SQL = await initSqlJs();
|
|
18
|
+
if (fs.existsSync(DB_PATH)) {
|
|
19
|
+
db = new SQL.Database(fs.readFileSync(DB_PATH));
|
|
20
|
+
} else {
|
|
21
|
+
db = new SQL.Database();
|
|
22
|
+
}
|
|
23
|
+
if (fs.existsSync(SCHEMA_PATH)) {
|
|
24
|
+
db.run(fs.readFileSync(SCHEMA_PATH, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
return initPromise;
|
|
14
28
|
}
|
|
15
29
|
|
|
16
|
-
|
|
30
|
+
function queryAll(sql, params = []) {
|
|
31
|
+
const stmt = db.prepare(sql);
|
|
32
|
+
if (params.length) stmt.bind(params);
|
|
33
|
+
const rows = [];
|
|
34
|
+
while (stmt.step()) {
|
|
35
|
+
rows.push(stmt.getAsObject());
|
|
36
|
+
}
|
|
37
|
+
stmt.free();
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
17
40
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
41
|
+
function queryOne(sql, params = []) {
|
|
42
|
+
return queryAll(sql, params)[0] || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function lastId() {
|
|
46
|
+
const r = db.exec("SELECT last_insert_rowid()");
|
|
47
|
+
return Number(r[0].values[0][0]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function persist() {
|
|
51
|
+
fs.writeFileSync(DB_PATH, Buffer.from(db.export()));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function saveScan(pkgName, version = 'latest', findings = []) {
|
|
55
|
+
await ensureInit();
|
|
56
|
+
db.run("INSERT INTO scans (package_name, version) VALUES (?, ?)", [pkgName, version]);
|
|
57
|
+
const scanId = lastId();
|
|
58
|
+
const stmt = db.prepare("INSERT INTO findings (scan_id, atk_id, severity, description, evidence) VALUES (?, ?, ?, ?, ?)");
|
|
22
59
|
for (const f of findings) {
|
|
23
|
-
|
|
60
|
+
stmt.run([scanId, f.id, f.severity, f.title || f.description, f.evidence || '']);
|
|
24
61
|
}
|
|
62
|
+
stmt.free();
|
|
63
|
+
persist();
|
|
25
64
|
return scanId;
|
|
26
65
|
}
|
|
27
66
|
|
|
28
|
-
export function getRecentScans(limit = 10) {
|
|
29
|
-
|
|
67
|
+
export async function getRecentScans(limit = 10) {
|
|
68
|
+
await ensureInit();
|
|
69
|
+
return queryAll("SELECT * FROM scans ORDER BY scanned_at DESC LIMIT ?", [limit]);
|
|
30
70
|
}
|
|
31
71
|
|
|
32
|
-
export function getFindings(scanId) {
|
|
33
|
-
|
|
72
|
+
export async function getFindings(scanId) {
|
|
73
|
+
await ensureInit();
|
|
74
|
+
return queryAll("SELECT * FROM findings WHERE scan_id = ?", [scanId]);
|
|
34
75
|
}
|
|
35
76
|
|
|
36
|
-
export function getScan(scanId) {
|
|
37
|
-
|
|
77
|
+
export async function getScan(scanId) {
|
|
78
|
+
await ensureInit();
|
|
79
|
+
return queryOne("SELECT * FROM scans WHERE id = ?", [scanId]);
|
|
38
80
|
}
|
|
39
81
|
|
|
40
|
-
export function close() {
|
|
41
|
-
db
|
|
82
|
+
export async function close() {
|
|
83
|
+
if (db) {
|
|
84
|
+
persist();
|
|
85
|
+
db.close();
|
|
86
|
+
db = null;
|
|
87
|
+
}
|
|
42
88
|
}
|
|
@@ -1,29 +1,90 @@
|
|
|
1
1
|
export async function scan(pkgJson, files = []) {
|
|
2
2
|
const findings = [];
|
|
3
|
+
const pkgName = pkgJson?.name || '';
|
|
4
|
+
const selfName = pkgName.replace(/^@/, '').replace(/\//, '-');
|
|
5
|
+
|
|
3
6
|
for (const f of files) {
|
|
4
7
|
const code = f.content;
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
|
|
9
|
+
const hasEval = /eval\(|new Function\(|\bFunction\('/.test(code);
|
|
10
|
+
|
|
11
|
+
if (hasEval) {
|
|
12
|
+
const hexDecode = /Buffer\.from\(['"`][0-9a-f]+['"`],\s*['"]hex['"]/.test(code);
|
|
13
|
+
const b64Decode = /atob\(|Buffer\.from\([A-Za-z0-9+/=]{10,}/.test(code);
|
|
14
|
+
const b64UrlDecode = /try\s*\{[^}]*atob\s*\(/s.test(code) || /btoa\(.*\)\s*[^;]*\.replace\(/s.test(code);
|
|
15
|
+
|
|
16
|
+
if (hexDecode || b64Decode || b64UrlDecode) {
|
|
17
|
+
findings.push({
|
|
18
|
+
id: 'ATK-002',
|
|
19
|
+
severity: 'medium',
|
|
20
|
+
title: 'Obfuscated payload',
|
|
21
|
+
description: hexDecode ? 'Eval with hex-decoded payload' : 'Eval with base64-decoded payload',
|
|
22
|
+
evidence: 'eval + decode pattern detected'
|
|
23
|
+
});
|
|
24
|
+
return findings;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (btoa(btoa('x')) === 'eDuke'.padEnd(5)) {
|
|
28
|
+
const nested = /atob\([^)]*atob\(/s.test(code) || /btoa\([^)]*btoa\(/s.test(code);
|
|
29
|
+
if (nested) {
|
|
30
|
+
findings.push({
|
|
31
|
+
id: 'ATK-002',
|
|
32
|
+
severity: 'high',
|
|
33
|
+
title: 'Obfuscated payload',
|
|
34
|
+
description: 'Double-encoded nested payload',
|
|
35
|
+
evidence: 'nested encode/decode detected'
|
|
36
|
+
});
|
|
37
|
+
return findings;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
16
40
|
}
|
|
17
|
-
|
|
41
|
+
|
|
42
|
+
if (/atob\(|Buffer\.from/.test(code) && /url|fetch|curl|http\.request|https\.request/.test(code)) {
|
|
43
|
+
const isNetworkObfusc = /atob\(.*(https?:\/\/|\\x|http).*\)/s.test(code) ||
|
|
44
|
+
/Buffer\.from\(['"`][0-9a-f]+['"`],\s*['"]hex['"].*fetch\(|fetch\(.*atob\(/s.test(code);
|
|
45
|
+
if (isNetworkObfusc) {
|
|
46
|
+
findings.push({
|
|
47
|
+
id: 'ATK-002',
|
|
48
|
+
severity: 'medium',
|
|
49
|
+
title: 'Obfuscated payload',
|
|
50
|
+
description: 'Decoded string containing URL/fetch call',
|
|
51
|
+
evidence: 'obfuscation with network call'
|
|
52
|
+
});
|
|
53
|
+
return findings;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (/String\.fromCharCode\(.{20,}\)/.test(code) && hasEval) {
|
|
18
58
|
findings.push({
|
|
19
59
|
id: 'ATK-002',
|
|
20
60
|
severity: 'medium',
|
|
21
61
|
title: 'Obfuscated payload',
|
|
22
|
-
description: '
|
|
23
|
-
evidence: 'obfuscation
|
|
62
|
+
description: 'Eval with String.fromCharCode obfuscation',
|
|
63
|
+
evidence: 'charcode obfuscation detected'
|
|
24
64
|
});
|
|
25
65
|
return findings;
|
|
26
66
|
}
|
|
67
|
+
|
|
68
|
+
const shellPatterns = [
|
|
69
|
+
/eval\s*\(\s*process\.env\.[A-Z_]{4,}/,
|
|
70
|
+
/exec\s*\(\s*Buffer\.from\(/,
|
|
71
|
+
/new Function\s*\(\s*(?:atob|process\.env)/,
|
|
72
|
+
/eval\s*\(\s*(?:require|import\s*\()/,
|
|
73
|
+
/Function\s*\(\s*'use\s*strict'\s*;?\s*(?:atob|require)/,
|
|
74
|
+
];
|
|
75
|
+
for (const p of shellPatterns) {
|
|
76
|
+
if (p.test(code)) {
|
|
77
|
+
findings.push({
|
|
78
|
+
id: 'ATK-002',
|
|
79
|
+
severity: 'high',
|
|
80
|
+
title: 'Obfuscated payload',
|
|
81
|
+
description: 'Shell-code obfuscation pattern',
|
|
82
|
+
evidence: p.source.substring(0, 60)
|
|
83
|
+
});
|
|
84
|
+
return findings;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
27
87
|
}
|
|
88
|
+
|
|
28
89
|
return findings;
|
|
29
|
-
}
|
|
90
|
+
}
|
|
@@ -4,21 +4,64 @@ export async function scan(pkgJson, files = []) {
|
|
|
4
4
|
const repoUrl = typeof repo === 'string' ? repo : (repo.url || '');
|
|
5
5
|
const pkgName = (pkgJson.name || '').toLowerCase();
|
|
6
6
|
|
|
7
|
-
const knownRepos = {
|
|
7
|
+
const knownRepos = {
|
|
8
|
+
lodash: 'lodash/lodash',
|
|
9
|
+
chalk: 'chalk/chalk',
|
|
10
|
+
react: 'facebook/react',
|
|
11
|
+
axios: 'axios/axios',
|
|
12
|
+
express: 'expressjs/express',
|
|
13
|
+
vue: 'vuejs/core',
|
|
14
|
+
typescript: 'microsoft/typescript',
|
|
15
|
+
moment: 'moment/moment',
|
|
16
|
+
uuid: 'uuidjs/uuid',
|
|
17
|
+
commander: 'tj/commander.js',
|
|
18
|
+
debug: 'debug-js/debug',
|
|
19
|
+
semver: 'npm/node-semver',
|
|
20
|
+
underscore: 'jashkenas/underscore',
|
|
21
|
+
request: 'request/request',
|
|
22
|
+
async: 'caolan/async',
|
|
23
|
+
cheerio: 'cheeriojs/cheerio',
|
|
24
|
+
bluebird: 'petkaantonov/bluebird',
|
|
25
|
+
jest: 'jestjs/jest',
|
|
26
|
+
mocha: 'mochajs/mocha',
|
|
27
|
+
dotenv: 'motdotla/dotenv',
|
|
28
|
+
glob: 'isaacs/node-glob',
|
|
29
|
+
};
|
|
8
30
|
|
|
9
31
|
if (repoUrl && repoUrl.includes('github.com')) {
|
|
10
32
|
const repoMatch = repoUrl.match(/github\.com[\/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
11
33
|
if (repoMatch) {
|
|
12
34
|
const ghRepo = repoMatch[1].toLowerCase();
|
|
13
35
|
const ghName = ghRepo.split('/')[1];
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
36
|
+
const ghOrg = ghRepo.split('/')[0];
|
|
37
|
+
const shortName = pkgName.split('/').pop();
|
|
38
|
+
|
|
39
|
+
if (ghName !== shortName) {
|
|
40
|
+
const expectedRepo = knownRepos[pkgName] || knownRepos[shortName];
|
|
41
|
+
|
|
42
|
+
if (expectedRepo && expectedRepo !== ghRepo) {
|
|
43
|
+
findings.push({
|
|
44
|
+
id: 'ATK-008',
|
|
45
|
+
severity: 'high',
|
|
46
|
+
title: 'Tarball tampering suspect',
|
|
47
|
+
description: `Repository "${ghRepo}" does not match expected "${expectedRepo}" for package "${pkgName}"`,
|
|
48
|
+
evidence: `repo: ${ghRepo}, expected: ${expectedRepo}`
|
|
49
|
+
});
|
|
50
|
+
} else {
|
|
51
|
+
const orgExpected = knownRepos[shortName];
|
|
52
|
+
if (orgExpected) {
|
|
53
|
+
const expectedOrg = orgExpected.split('/')[0];
|
|
54
|
+
if (ghOrg !== expectedOrg) {
|
|
55
|
+
findings.push({
|
|
56
|
+
id: 'ATK-008',
|
|
57
|
+
severity: 'medium',
|
|
58
|
+
title: 'Tarball tampering suspect',
|
|
59
|
+
description: `Repository "${ghRepo}" is a different repo under a different org (legitimate: ${expectedRepo})`,
|
|
60
|
+
evidence: `org mismatch: ${ghOrg} vs ${expectedOrg}`
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
22
65
|
}
|
|
23
66
|
}
|
|
24
67
|
}
|
|
@@ -28,17 +71,21 @@ export async function scan(pkgJson, files = []) {
|
|
|
28
71
|
if (embeddedIntros && repoUrl) {
|
|
29
72
|
for (const intro of embeddedIntros) {
|
|
30
73
|
const srcUrl = intro.replace(/\/\/\s*Source:\s*/i, '').trim();
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
74
|
+
try {
|
|
75
|
+
if (!repoUrl.includes(new URL(srcUrl).hostname)) {
|
|
76
|
+
findings.push({
|
|
77
|
+
id: 'ATK-008',
|
|
78
|
+
severity: 'medium',
|
|
79
|
+
title: 'Tarball tampering suspect',
|
|
80
|
+
description: 'Source URL in file does not match declared repository',
|
|
81
|
+
evidence: srcUrl
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// ignore malformed URLs
|
|
39
86
|
}
|
|
40
87
|
}
|
|
41
88
|
}
|
|
42
89
|
|
|
43
90
|
return findings;
|
|
44
|
-
}
|
|
91
|
+
}
|
|
@@ -21,25 +21,42 @@ export async function scan(pkgJson, files = []) {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const suspiciousCode = /\beval\(|atob\(|btoa\(|new Function\(|child_process\b|\.exec\(|spawn\(/;
|
|
25
|
+
const suspiciousNetwork = /\.fetch\(|http\.request\(|https\.request\(|dns\.lookup\(/;
|
|
26
|
+
const suspiciousEnv = /process\.env\.(?!NODE_ENV)[A-Z_]{4,}/;
|
|
27
|
+
const hasSuspicious = suspiciousCode.test(code) || suspiciousNetwork.test(code) || suspiciousEnv.test(code);
|
|
28
|
+
|
|
24
29
|
const timePatterns = [
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
{
|
|
31
|
+
pattern: /new Date\(\)\s*[><=!]+\s*new Date\(['"]\d{4}/,
|
|
32
|
+
label: 'time-based activation',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
pattern: /\bDate\.now\(\)\s*[><=!]+.*(?:eval|fetch|exec|write|crypto|env\.CI)/i,
|
|
36
|
+
label: 'timestamp check with suspicious behavior',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
pattern: /\bsetTimeout\s*\([^)]*,\s*(?!0\b)[1-9]\d{3,}/,
|
|
40
|
+
label: 'long-delay execution (>1000ms)',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
pattern: /\bDate\(\)\b.*(?:exec|eval|fetch|write|crypto)/i,
|
|
44
|
+
label: 'date check with suspicious behavior',
|
|
45
|
+
},
|
|
29
46
|
];
|
|
30
47
|
|
|
31
48
|
for (const { pattern, label } of timePatterns) {
|
|
32
49
|
if (pattern.test(code)) {
|
|
33
50
|
findings.push({
|
|
34
51
|
id: 'ATK-009',
|
|
35
|
-
severity: 'medium',
|
|
52
|
+
severity: hasSuspicious ? 'high' : 'medium',
|
|
36
53
|
title: 'Conditional trigger (time-based)',
|
|
37
|
-
description: `Package
|
|
38
|
-
evidence: '
|
|
54
|
+
description: `Package uses ${label}`,
|
|
55
|
+
evidence: `${label}${hasSuspicious ? ' — elevated (suspicious context: eval/network/exec detected)' : ''}`
|
|
39
56
|
});
|
|
40
57
|
break;
|
|
41
58
|
}
|
|
42
59
|
}
|
|
43
60
|
|
|
44
61
|
return findings;
|
|
45
|
-
}
|
|
62
|
+
}
|
|
@@ -2,7 +2,7 @@ export async function scan(pkgJson, files = []) {
|
|
|
2
2
|
const findings = [];
|
|
3
3
|
const code = files.map(f => f.content).join('\n');
|
|
4
4
|
|
|
5
|
-
const highPatterns = [
|
|
5
|
+
const highPatterns = [
|
|
6
6
|
{
|
|
7
7
|
pattern: /(?:exec|execSync|spawn)\s*\([^)]*npm\s+(?:install|link)\s*\./i,
|
|
8
8
|
label: 'programmatic self-propagation via npm install/link'
|
|
@@ -19,10 +19,6 @@ const highPatterns = [
|
|
|
19
19
|
pattern: /fs\.(?:writeFile|writeFileSync)\s*\([^)]*\.\.\/[^)]*package\.json/i,
|
|
20
20
|
label: 'writes modified package.json to sibling package'
|
|
21
21
|
},
|
|
22
|
-
{
|
|
23
|
-
pattern: /(?:exec|execSync|spawn)\s*\([^)]*npm\s+(?:install|link)\s+(?!\.)(?!http)(?!git)/i,
|
|
24
|
-
label: 'programmatic propagation via npm install of local package'
|
|
25
|
-
},
|
|
26
22
|
{
|
|
27
23
|
pattern: /(?:exec|execSync|spawn)\s*\([^)]*(?:\.\.\/|process\.env\.INIT_CWD).*npm\s+install/i,
|
|
28
24
|
label: 'cross-directory npm install propagation'
|
|
@@ -42,38 +38,33 @@ const highPatterns = [
|
|
|
42
38
|
}
|
|
43
39
|
}
|
|
44
40
|
|
|
45
|
-
if (findings.length === 0) {
|
|
46
|
-
const selfName = pkgJson && pkgJson.name ? pkgJson.name.replace(/^@/, '').replace(/\//, '-') : null;
|
|
41
|
+
if (findings.length === 0) {
|
|
47
42
|
const mediumPatterns = [
|
|
48
43
|
{
|
|
49
44
|
pattern: /process\.env\.npm_package_name/,
|
|
50
|
-
label: 'reads own package name (
|
|
45
|
+
label: 'reads own package name from env (self-awareness indicator)'
|
|
51
46
|
},
|
|
52
47
|
{
|
|
53
|
-
pattern:
|
|
54
|
-
label:
|
|
48
|
+
pattern: /fs\.symlink(?:Sync)?\s*\([^)]*node_modules/,
|
|
49
|
+
label: 'creates symlinks in node_modules (worm spreading mechanism)'
|
|
55
50
|
},
|
|
56
51
|
{
|
|
57
52
|
pattern: /fs\.(?:mkdir|mkdirSync)\s*\([^)]*\.\.\/[^)]*node_modules/,
|
|
58
|
-
label: 'creates directories in parent node_modules
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
pattern: /fs\.symlink(?:Sync)?\s*\(/,
|
|
62
|
-
label: 'creates symlinks (potential worm link spreading)'
|
|
53
|
+
label: 'creates directories in parent node_modules'
|
|
63
54
|
},
|
|
64
55
|
{
|
|
65
|
-
pattern: /__dirname.*
|
|
66
|
-
label: '
|
|
56
|
+
pattern: /__dirname.*\.\.\/[^/]+\/node_modules.*require\(/,
|
|
57
|
+
label: 'dynamic parent-node_modules require for lateral spread'
|
|
67
58
|
},
|
|
68
59
|
];
|
|
69
60
|
|
|
70
|
-
for (const { pattern, label
|
|
71
|
-
if (pattern
|
|
61
|
+
for (const { pattern, label } of mediumPatterns) {
|
|
62
|
+
if (pattern.test(code)) {
|
|
72
63
|
findings.push({
|
|
73
64
|
id: 'ATK-011',
|
|
74
65
|
severity: 'medium',
|
|
75
66
|
title: 'Transitive propagation (worm)',
|
|
76
|
-
description:
|
|
67
|
+
description: label,
|
|
77
68
|
evidence: 'potential propagation indicator'
|
|
78
69
|
});
|
|
79
70
|
break;
|
|
@@ -82,4 +73,4 @@ if (findings.length === 0) {
|
|
|
82
73
|
}
|
|
83
74
|
|
|
84
75
|
return findings;
|
|
85
|
-
}
|
|
76
|
+
}
|
package/backend/fetch.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import fetch from 'node-fetch';
|
|
2
1
|
import fs from 'fs';
|
|
3
2
|
import os from 'os';
|
|
4
3
|
import path from 'path';
|
|
@@ -10,6 +9,11 @@ import { pipeline } from 'stream/promises';
|
|
|
10
9
|
export async function fetchPackage(target) {
|
|
11
10
|
const metaRes = await fetch(`https://registry.npmjs.org/${target}/latest`);
|
|
12
11
|
const meta = await metaRes.json();
|
|
12
|
+
|
|
13
|
+
if (!metaRes.ok || !meta.dist?.tarball) {
|
|
14
|
+
throw new Error(`Package '${target}' not found on npm (${metaRes.status})`);
|
|
15
|
+
}
|
|
16
|
+
|
|
13
17
|
const tarUrl = meta.dist.tarball;
|
|
14
18
|
const tarRes = await fetch(tarUrl);
|
|
15
19
|
const buffer = Buffer.from(await tarRes.arrayBuffer());
|
package/cli/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ function requirePremium(feature, licenseKey) {
|
|
|
15
15
|
const program = new Command()
|
|
16
16
|
.name('npm-scan')
|
|
17
17
|
.description('npm supply chain security scanner')
|
|
18
|
-
.version('0.
|
|
18
|
+
.version('0.9.0');
|
|
19
19
|
|
|
20
20
|
program
|
|
21
21
|
.command('scan')
|
|
@@ -41,7 +41,7 @@ program
|
|
|
41
41
|
const { pkgJson, jsFiles, tmpDir } = await import('../backend/fetch.js').then(m => m.fetchPackage(target));
|
|
42
42
|
const findings = await import('../backend/detectors/index.js').then(m => m.runAll(pkgJson, jsFiles));
|
|
43
43
|
const { saveScan } = await import('../backend/db.js');
|
|
44
|
-
const scanId = saveScan(target, 'latest', findings);
|
|
44
|
+
const scanId = await saveScan(target, 'latest', findings);
|
|
45
45
|
|
|
46
46
|
let outputFindings = findings;
|
|
47
47
|
let blocked = false;
|
|
@@ -100,8 +100,8 @@ program
|
|
|
100
100
|
const { getRecentScans, getFindings, getScan } = await import('../backend/db.js');
|
|
101
101
|
|
|
102
102
|
if (options.id) {
|
|
103
|
-
const findings = getFindings(options.id);
|
|
104
|
-
const scanInfo = getScan(options.id);
|
|
103
|
+
const findings = await getFindings(options.id);
|
|
104
|
+
const scanInfo = await getScan(options.id);
|
|
105
105
|
const pkgName = scanInfo?.package_name || 'scan-' + options.id;
|
|
106
106
|
const pkgVer = scanInfo?.version || 'unknown';
|
|
107
107
|
const pkg = { name: pkgName, version: pkgVer };
|
|
@@ -137,8 +137,8 @@ program
|
|
|
137
137
|
console.log(JSON.stringify(findings, null, 2));
|
|
138
138
|
}
|
|
139
139
|
} else {
|
|
140
|
-
const scans = getRecentScans();
|
|
141
|
-
const scansWithFindings = scans.map(s => ({ ...s, findings: getFindings(s.id) }));
|
|
140
|
+
const scans = await getRecentScans();
|
|
141
|
+
const scansWithFindings = await Promise.all(scans.map(async s => ({ ...s, findings: await getFindings(s.id) })));
|
|
142
142
|
|
|
143
143
|
if (options.siem) {
|
|
144
144
|
requirePremium('siem', licenseKey);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lateos/npm-scan",
|
|
3
|
-
"version": "0.
|
|
4
|
-
|
|
3
|
+
"version": "0.9.1",
|
|
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": {
|
|
7
7
|
"npm-scan": "cli/cli.js"
|
|
@@ -34,12 +34,11 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"acorn": "^8.16.0",
|
|
37
|
-
"better-sqlite3": "^11.10.0",
|
|
38
37
|
"commander": "^14.0.3",
|
|
39
38
|
"glob": "^13.0.6",
|
|
40
39
|
"js-yaml": "^4.1.1",
|
|
41
|
-
"node-fetch": "^3.3.2",
|
|
42
40
|
"pdf-lib": "^1.17.1",
|
|
41
|
+
"sql.js": "^1.11.0",
|
|
43
42
|
"tar": "^7.5.15"
|
|
44
43
|
}
|
|
45
44
|
}
|
|
Binary file
|