@lateos/npm-scan 0.18.3 → 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/CHANGELOG.md +32 -0
- package/README.md +35 -0
- package/VALIDATION.md +92 -0
- package/backend/db/pg-schema.sql +155 -0
- package/backend/detectors/config/thresholds.js +66 -0
- package/backend/detectors/config/whitelist.json +74 -0
- package/backend/detectors/index.js +6 -0
- package/backend/detectors/lib/ast-patterns.js +21 -0
- package/backend/detectors/lib/entropy-analyzer.js +24 -0
- package/backend/detectors/tier1-binary-embed.js +34 -5
- package/backend/detectors/tier1-obfuscation-heuristics.js +156 -0
- package/backend/detectors/tier1-slsa-attestation.js +12 -0
- package/backend/detectors/tier1-version-anomaly.js +187 -0
- package/backend/detectors.test.js +88 -0
- package/backend/scripts/analyze-false-positives.js +146 -0
- package/backend/scripts/analyze-validation.js +151 -0
- package/backend/scripts/detect-false-positives.js +93 -0
- package/backend/scripts/fetch-top-packages.js +129 -0
- package/backend/scripts/validate-detectors.js +142 -0
- package/backend/tests-d5-enhanced.test.js +46 -0
- package/backend/tests-d6-version-anomaly.test.js +58 -0
- package/backend/tests-d6.test.js +116 -0
- package/backend/tests-d6c.test.js +106 -0
- package/backend/tests-d7-obfuscation.test.js +91 -0
- package/backend/tests.test.js +898 -0
- package/package.json +25 -8
- package/.dockerignore +0 -20
- package/.husky/pre-commit +0 -1
- package/SECURITY.md +0 -73
- package/deploy/helm/npm-scan/Chart.yaml +0 -22
- package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
- package/deploy/helm/npm-scan/templates/api.yaml +0 -94
- package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
- package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
- package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
- package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
- package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
- package/deploy/helm/npm-scan/values.yaml +0 -103
- package/scripts/download-corpus.js +0 -30
- package/scripts/gen-mal-corpus.js +0 -35
- package/scripts/generate-campaign-fixtures.js +0 -170
- package/src/config/top-5000.json +0 -87
- package/test/fixtures/lockfiles/npm-lock.json +0 -69
- package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
- package/test/fixtures/lockfiles/yarn.lock +0 -104
- package/test/fixtures/mock-data.js +0 -69
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
async function analyzeValidation(resultsFile) {
|
|
6
|
+
const stats = {
|
|
7
|
+
total_packages: 0,
|
|
8
|
+
total_detections: 0,
|
|
9
|
+
total_expected: 0,
|
|
10
|
+
total_matched: 0,
|
|
11
|
+
campaigns: {},
|
|
12
|
+
detectors: {},
|
|
13
|
+
detection_matrix: {},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const absPath = resolve(resultsFile);
|
|
17
|
+
if (!existsSync(absPath)) {
|
|
18
|
+
console.error(`[ERROR] Results file not found: ${absPath}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const text = readFileSync(absPath, 'utf-8');
|
|
23
|
+
const lines = text.split('\n').filter(l => l.trim());
|
|
24
|
+
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const result = JSON.parse(line);
|
|
27
|
+
if (result.error) continue;
|
|
28
|
+
|
|
29
|
+
stats.total_packages += 1;
|
|
30
|
+
stats.total_detections += result.detection_count;
|
|
31
|
+
|
|
32
|
+
const campaignId = result.campaign_id;
|
|
33
|
+
if (!stats.campaigns[campaignId]) {
|
|
34
|
+
stats.campaigns[campaignId] = {
|
|
35
|
+
name: result.campaign_name,
|
|
36
|
+
total: 0,
|
|
37
|
+
detected: 0,
|
|
38
|
+
detection_rate: 0,
|
|
39
|
+
total_expected: 0,
|
|
40
|
+
total_matched: 0,
|
|
41
|
+
avg_confidence: 0,
|
|
42
|
+
confidences: [],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const campaign = stats.campaigns[campaignId];
|
|
47
|
+
campaign.total += 1;
|
|
48
|
+
campaign.total_expected += result.expected_detectors.length;
|
|
49
|
+
|
|
50
|
+
const matched = result.expected_detectors.filter(
|
|
51
|
+
id => result.detected_detectors.includes(id)
|
|
52
|
+
);
|
|
53
|
+
campaign.total_matched += matched.length;
|
|
54
|
+
stats.total_expected += result.expected_detectors.length;
|
|
55
|
+
stats.total_matched += matched.length;
|
|
56
|
+
|
|
57
|
+
if (matched.length > 0) {
|
|
58
|
+
campaign.detected += 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const detection of result.detections) {
|
|
62
|
+
const detectorName = detection.id || detection.detector;
|
|
63
|
+
if (!stats.detectors[detectorName]) {
|
|
64
|
+
stats.detectors[detectorName] = {
|
|
65
|
+
total_hits: 0,
|
|
66
|
+
expected_count: 0,
|
|
67
|
+
avg_confidence: 0,
|
|
68
|
+
confidences: [],
|
|
69
|
+
severities: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
stats.detectors[detectorName].total_hits += 1;
|
|
74
|
+
stats.detectors[detectorName].confidences.push(detection.confidenceScore);
|
|
75
|
+
stats.detectors[detectorName].severities.push(detection.severity);
|
|
76
|
+
|
|
77
|
+
if (result.expected_detectors.includes(detectorName)) {
|
|
78
|
+
stats.detectors[detectorName].expected_count += 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!stats.detection_matrix[campaignId]) {
|
|
82
|
+
stats.detection_matrix[campaignId] = {};
|
|
83
|
+
}
|
|
84
|
+
if (!stats.detection_matrix[campaignId][detectorName]) {
|
|
85
|
+
stats.detection_matrix[campaignId][detectorName] = 0;
|
|
86
|
+
}
|
|
87
|
+
stats.detection_matrix[campaignId][detectorName] += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const campaignId of Object.keys(stats.campaigns)) {
|
|
92
|
+
const campaign = stats.campaigns[campaignId];
|
|
93
|
+
campaign.detection_rate = campaign.total > 0
|
|
94
|
+
? ((campaign.detected / campaign.total) * 100).toFixed(1) + '%'
|
|
95
|
+
: '0%';
|
|
96
|
+
campaign.expected_match_rate = campaign.total_expected > 0
|
|
97
|
+
? ((campaign.total_matched / campaign.total_expected) * 100).toFixed(1) + '%'
|
|
98
|
+
: '0%';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const detectorName of Object.keys(stats.detectors)) {
|
|
102
|
+
const detector = stats.detectors[detectorName];
|
|
103
|
+
detector.avg_confidence = detector.confidences.length > 0
|
|
104
|
+
? (detector.confidences.reduce((a, b) => a + b, 0) / detector.confidences.length).toFixed(1)
|
|
105
|
+
: '0.0';
|
|
106
|
+
detector.precision = detector.total_hits > 0
|
|
107
|
+
? ((detector.expected_count / detector.total_hits) * 100).toFixed(1) + '%'
|
|
108
|
+
: '0%';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return stats;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const resultsFile = process.argv[2] || 'validation-results.jsonl';
|
|
115
|
+
|
|
116
|
+
console.log(`[INFO] Analyzing ${resultsFile}...`);
|
|
117
|
+
const stats = await analyzeValidation(resultsFile);
|
|
118
|
+
|
|
119
|
+
console.log('\n=== CAMPAIGN DETECTION RATES ===');
|
|
120
|
+
console.log('Campaign Packages Detected Rate Expected Matched Match%');
|
|
121
|
+
console.log('─'.repeat(95));
|
|
122
|
+
for (const [id, campaign] of Object.entries(stats.campaigns)) {
|
|
123
|
+
const name = campaign.name.padEnd(33).slice(0, 33);
|
|
124
|
+
console.log(
|
|
125
|
+
`${name} ${String(campaign.total).padStart(8)} ${String(campaign.detected).padStart(9)} ` +
|
|
126
|
+
`${campaign.detection_rate.padStart(7)} ${String(campaign.total_expected).padStart(9)} ` +
|
|
127
|
+
`${String(campaign.total_matched).padStart(8)} ${campaign.expected_match_rate.padStart(7)}`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
console.log(`\nTotal: ${stats.total_packages} packages, ${stats.total_detections} detections`);
|
|
131
|
+
|
|
132
|
+
console.log('\n=== DETECTOR PERFORMANCE ===');
|
|
133
|
+
console.log('Detector Hits Expected Precision Avg Confidence');
|
|
134
|
+
console.log('─'.repeat(80));
|
|
135
|
+
for (const [name, detector] of Object.entries(stats.detectors).sort(
|
|
136
|
+
(a, b) => b[1].total_hits - a[1].total_hits
|
|
137
|
+
)) {
|
|
138
|
+
const dName = name.padEnd(32).slice(0, 32);
|
|
139
|
+
console.log(
|
|
140
|
+
`${dName} ${String(detector.total_hits).padStart(5)} ${String(detector.expected_count).padStart(9)} ` +
|
|
141
|
+
`${detector.precision.padStart(10)} ${detector.avg_confidence.padStart(14)}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log('\n=== DETECTION MATRIX (Hits per Campaign × Detector) ===');
|
|
146
|
+
console.log(JSON.stringify(stats.detection_matrix, null, 2));
|
|
147
|
+
|
|
148
|
+
writeFileSync('detection-rates.json', JSON.stringify(stats, null, 2), 'utf-8');
|
|
149
|
+
console.log('\n[INFO] Full results written to detection-rates.json');
|
|
150
|
+
|
|
151
|
+
process.exit(0);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { runAll } from '../detectors/index.js';
|
|
5
|
+
import whitelist from '../detectors/config/whitelist.json' with { type: 'json' };
|
|
6
|
+
|
|
7
|
+
const WHITELIST_MAP = new Map();
|
|
8
|
+
for (const entry of whitelist.packages) {
|
|
9
|
+
WHITELIST_MAP.set(entry.name, new Set(entry.detectors));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function detectFalsePositives(topPackagesFile, confidenceThreshold = 70) {
|
|
13
|
+
const absPath = resolve(topPackagesFile);
|
|
14
|
+
if (!existsSync(absPath)) {
|
|
15
|
+
console.error(`[ERROR] Top packages file not found: ${absPath}`);
|
|
16
|
+
console.error(' Run fetch-top-packages.js first');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const text = readFileSync(absPath, 'utf-8');
|
|
21
|
+
const lines = text.split('\n').filter(l => l.trim());
|
|
22
|
+
console.log(`[INFO] Loaded ${lines.length} packages from ${topPackagesFile}`);
|
|
23
|
+
|
|
24
|
+
const falsePositives = [];
|
|
25
|
+
let count = 0;
|
|
26
|
+
let skipped = 0;
|
|
27
|
+
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
const pkg = JSON.parse(line);
|
|
30
|
+
count += 1;
|
|
31
|
+
|
|
32
|
+
const pkgName = pkg.name;
|
|
33
|
+
const whitelistedDetectors = WHITELIST_MAP.get(pkgName);
|
|
34
|
+
|
|
35
|
+
if (whitelistedDetectors) {
|
|
36
|
+
skipped += 1;
|
|
37
|
+
if (count % 200 === 0 || count <= 5) {
|
|
38
|
+
console.log(`[SKIP] ${pkgName} (whitelisted for ${[...whitelistedDetectors].join(', ')})`);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
if (count % 100 === 0) {
|
|
42
|
+
console.log(`[PROGRESS] Processed ${count}/${lines.length} packages...`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const pkgJson = { name: pkgName, version: pkg.version };
|
|
48
|
+
const findings = await runAll(pkgJson, [], null, []);
|
|
49
|
+
|
|
50
|
+
for (const detection of findings) {
|
|
51
|
+
if (detection.confidenceScore < confidenceThreshold) continue;
|
|
52
|
+
if (whitelistedDetectors && whitelistedDetectors.has(detection.id)) continue;
|
|
53
|
+
|
|
54
|
+
falsePositives.push({
|
|
55
|
+
package: pkgName,
|
|
56
|
+
version: pkg.version,
|
|
57
|
+
detector: detection.id,
|
|
58
|
+
confidence: detection.confidenceScore,
|
|
59
|
+
severity: detection.severity,
|
|
60
|
+
subtype: detection.subtype,
|
|
61
|
+
message: detection.message,
|
|
62
|
+
evidence: detection.evidence,
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (falsePositives.length <= 10) {
|
|
67
|
+
console.log(`[FLAG] ${pkgName}@${pkg.version}: ${detection.id} (${detection.confidenceScore}%)`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error(`[ERROR] ${pkgName}: ${err.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const outPath = resolve('false-positives.jsonl');
|
|
76
|
+
const outputData = falsePositives.map(fp => JSON.stringify(fp)).join('\n') + '\n';
|
|
77
|
+
writeFileSync(outPath, outputData, 'utf-8');
|
|
78
|
+
|
|
79
|
+
const scannedCount = count - skipped;
|
|
80
|
+
console.log(`\n[SUMMARY] Scanned ${scannedCount} packages (skipped ${skipped} whitelisted)`);
|
|
81
|
+
console.log(`[SUMMARY] Found ${falsePositives.length} potential false positives (${(falsePositives.length / scannedCount * 100).toFixed(1)}% FP rate)`);
|
|
82
|
+
console.log(`[INFO] Written to ${outPath}`);
|
|
83
|
+
|
|
84
|
+
return falsePositives;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const topPackagesFile = process.argv[2] || 'top-packages.jsonl';
|
|
88
|
+
const confidenceThreshold = parseInt(process.argv[3]) || 70;
|
|
89
|
+
|
|
90
|
+
detectFalsePositives(topPackagesFile, confidenceThreshold).then(() => process.exit(0)).catch(err => {
|
|
91
|
+
console.error(`[FATAL] ${err.message}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
async function fetchTopPackages(limit = 1000) {
|
|
6
|
+
console.log(`[INFO] Fetching top ${limit} npm packages via npms.io...`);
|
|
7
|
+
|
|
8
|
+
const packages = [];
|
|
9
|
+
const pageSize = 50;
|
|
10
|
+
const numPages = Math.ceil(limit / pageSize);
|
|
11
|
+
|
|
12
|
+
for (let page = 0; page < numPages; page++) {
|
|
13
|
+
const from = page * pageSize;
|
|
14
|
+
const q = encodeURIComponent('not:deprecated');
|
|
15
|
+
const url = `https://api.npms.io/v2/search?q=${q}&size=${pageSize}&from=${from}`;
|
|
16
|
+
|
|
17
|
+
console.log(`[INFO] Fetching page ${page + 1}/${numPages} (offset ${from})...`);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
console.error(`[ERROR] npms.io returned ${response.status} for page ${page + 1}`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const data = await response.json();
|
|
27
|
+
|
|
28
|
+
for (const result of data.results || []) {
|
|
29
|
+
if (packages.length >= limit) break;
|
|
30
|
+
packages.push({
|
|
31
|
+
name: result.package.name,
|
|
32
|
+
version: result.package.version,
|
|
33
|
+
description: result.package.description || '',
|
|
34
|
+
keywords: result.package.keywords || [],
|
|
35
|
+
publisher: result.package.publisher ? result.package.publisher.username : null,
|
|
36
|
+
date: result.package.date,
|
|
37
|
+
score: result.score ? {
|
|
38
|
+
final: result.score.final,
|
|
39
|
+
quality: result.score.detail?.quality,
|
|
40
|
+
popularity: result.score.detail?.popularity,
|
|
41
|
+
maintenance: result.score.detail?.maintenance,
|
|
42
|
+
} : null,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log(` Retrieved ${packages.length} packages so far`);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error(`[ERROR] Failed page ${page + 1}: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (packages.length >= limit) break;
|
|
52
|
+
|
|
53
|
+
if (page < numPages - 1) {
|
|
54
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (packages.length === 0) {
|
|
59
|
+
console.log('[ERROR] No packages fetched. Using fallback known-top list.');
|
|
60
|
+
return await fallbackList(limit);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const outPath = resolve('top-packages.jsonl');
|
|
64
|
+
const lines = packages.map(pkg => JSON.stringify(pkg)).join('\n') + '\n';
|
|
65
|
+
writeFileSync(outPath, lines, 'utf-8');
|
|
66
|
+
console.log(`\n[INFO] Written ${packages.length} packages to ${outPath}`);
|
|
67
|
+
return packages;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function fallbackList(limit) {
|
|
71
|
+
const knownTop = [
|
|
72
|
+
'lodash', 'chalk', 'react', 'express', 'commander', 'axios', 'moment',
|
|
73
|
+
'webpack', 'eslint', 'typescript', 'prettier', 'babel', 'next', 'vue',
|
|
74
|
+
'angular', 'redux', 'jest', 'mocha', 'chai', 'sinon', 'nodemon',
|
|
75
|
+
'debug', 'async', 'request', 'colors', 'mkdirp', 'fs-extra', 'glob',
|
|
76
|
+
'yargs', 'minimist', 'uuid', 'date-fns', 'crypto-js', 'jsonwebtoken',
|
|
77
|
+
'passport', 'socket.io', 'ws', 'graphql', 'apollo', 'prisma',
|
|
78
|
+
'mongoose', 'pg', 'mysql2', 'redis', 'sequelize', 'typeorm',
|
|
79
|
+
'dotenv', 'cross-env', 'rimraf', 'semver', 'rimraf', 'tar',
|
|
80
|
+
'inquirer', 'ora', 'listr', 'conf', 'env-paths', 'find-up',
|
|
81
|
+
'p-locate', 'locate-path', 'path-exists', 'y18n', 'yallist',
|
|
82
|
+
'minipass', 'minizlib', 'supports-color', 'has-flag', 'wrap-ansi',
|
|
83
|
+
'string-width', 'strip-ansi', 'ansi-regex', 'is-fullwidth-code-point',
|
|
84
|
+
'emoji-regex', 'cliui', 'escalade', 'get-caller-file', 'require-directory',
|
|
85
|
+
'npm', 'node-fetch', 'got', 'phin', 'undici', 'make-fetch-happen',
|
|
86
|
+
'cacache', 'ssri', 'unique-filename', 'unique-slug', 'imurmurhash',
|
|
87
|
+
'signal-exit', 'which', 'isexe', 'minimatch', 'brace-expansion',
|
|
88
|
+
'balanced-match', 'concat-map', 'lru-cache', 'yallist', 'semver',
|
|
89
|
+
'json5', 'tslib', 'source-map', 'source-map-js', 'ms', 'mime',
|
|
90
|
+
'cookie', 'express-session', 'body-parser', 'cors', 'helmet',
|
|
91
|
+
'morgan', 'compression', 'serve-static', 'send', 'fresh',
|
|
92
|
+
'etag', 'parseurl', 'utils-merge', 'methods', 'array-flatten',
|
|
93
|
+
'qs', 'merge-descriptors', 'path-to-regexp', 'iconv-lite',
|
|
94
|
+
'raw-body', 'on-finished', 'ee-first', 'inherits', 'depd',
|
|
95
|
+
'http-errors', 'statuses', 'setprototypeof', 'toidentifier',
|
|
96
|
+
'content-type', 'negotiator', 'accepts', 'type-is', 'vary',
|
|
97
|
+
'encodeurl', 'escape-html', 'destroy', 'bytes', 'unpipe',
|
|
98
|
+
'finalhandler', 'media-typer', 'http-proxy', 'http-proxy-middleware',
|
|
99
|
+
'morgan', 'connect', 'pino', 'winston', 'bunyan', 'log4js',
|
|
100
|
+
'nanoid', 'uid', 'ulid', 'cuid', 'shortid', 'uuidv4', 'uuidv7',
|
|
101
|
+
'bcrypt', 'bcryptjs', 'argon2', 'scrypt', 'pbkdf2', 'crypto',
|
|
102
|
+
'node-forge', 'pkijs', 'asn1js', 'jsrsasign', 'jose', 'jwk',
|
|
103
|
+
];
|
|
104
|
+
const pkgs = knownTop.slice(0, limit).map((name, i) => ({
|
|
105
|
+
name,
|
|
106
|
+
version: '1.0.0',
|
|
107
|
+
description: '',
|
|
108
|
+
keywords: [],
|
|
109
|
+
publisher: null,
|
|
110
|
+
date: null,
|
|
111
|
+
score: { final: 1 - i / knownTop.length, quality: 0.9, popularity: 0.9, maintenance: 0.9 },
|
|
112
|
+
}));
|
|
113
|
+
console.log(`[FALLBACK] Using ${pkgs.length} known top packages`);
|
|
114
|
+
|
|
115
|
+
const outPath = resolve('top-packages.jsonl');
|
|
116
|
+
const lines = pkgs.map(pkg => JSON.stringify(pkg)).join('\n') + '\n';
|
|
117
|
+
writeFileSync(outPath, lines, 'utf-8');
|
|
118
|
+
console.log(`[INFO] Written ${pkgs.length} packages to ${outPath}`);
|
|
119
|
+
return pkgs;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const limit = parseInt(process.argv[2]) || 1000;
|
|
123
|
+
fetchTopPackages(limit).then(pkgs => {
|
|
124
|
+
console.log(`[DONE] ${pkgs.length} packages fetched`);
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}).catch(err => {
|
|
127
|
+
console.error(`[FATAL] ${err.message}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { runAll } from '../detectors/index.js';
|
|
5
|
+
|
|
6
|
+
const CAMPAIGN_FIXTURES = {
|
|
7
|
+
'campaign-1': 'fixtures/campaigns/campaign-1-dependency-confusion.jsonl',
|
|
8
|
+
'campaign-2': 'fixtures/campaigns/campaign-2-mini-shai-hulud.jsonl',
|
|
9
|
+
'campaign-3': 'fixtures/campaigns/campaign-3-bitwarden-impersonation.jsonl',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function loadFixture(filePath) {
|
|
13
|
+
const abs = resolve(filePath);
|
|
14
|
+
if (!existsSync(abs)) {
|
|
15
|
+
console.error(`[ERROR] Fixture not found: ${abs}`);
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const text = readFileSync(abs, 'utf-8');
|
|
19
|
+
return text.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function fetchNpmMetadata(pkgName, version) {
|
|
23
|
+
try {
|
|
24
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}/${version}`;
|
|
25
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
26
|
+
if (!response.ok) return null;
|
|
27
|
+
return await response.json();
|
|
28
|
+
} catch {
|
|
29
|
+
console.warn(` [WARN] Registry fetch failed for ${pkgName}@${version}; using fixture data`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function constructRegistryMeta(pkg, liveMeta) {
|
|
35
|
+
if (liveMeta) return liveMeta;
|
|
36
|
+
if (pkg.mockRegistryMeta) return pkg.mockRegistryMeta;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function constructPkgJson(pkg) {
|
|
41
|
+
const base = { name: pkg.package, version: pkg.version };
|
|
42
|
+
if (pkg.mockPackageJson) {
|
|
43
|
+
return { ...base, ...pkg.mockPackageJson };
|
|
44
|
+
}
|
|
45
|
+
return base;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function validateDetectors(campaigns, outputFile) {
|
|
49
|
+
const allResults = [];
|
|
50
|
+
|
|
51
|
+
const campaignKeys = campaigns === 'all'
|
|
52
|
+
? Object.keys(CAMPAIGN_FIXTURES)
|
|
53
|
+
: [campaigns];
|
|
54
|
+
|
|
55
|
+
for (const campaignKey of campaignKeys) {
|
|
56
|
+
const fixturePath = CAMPAIGN_FIXTURES[campaignKey];
|
|
57
|
+
if (!fixturePath) {
|
|
58
|
+
console.error(`[ERROR] Unknown campaign: ${campaignKey}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(`\n[${new Date().toISOString()}] Validating ${campaignKey}...`);
|
|
63
|
+
const packages = loadFixture(fixturePath);
|
|
64
|
+
console.log(` Loaded ${packages.length} packages from fixture`);
|
|
65
|
+
|
|
66
|
+
for (const pkg of packages) {
|
|
67
|
+
try {
|
|
68
|
+
const pkgJson = constructPkgJson(pkg);
|
|
69
|
+
const liveMeta = await fetchNpmMetadata(pkg.package, pkg.version);
|
|
70
|
+
const registryMeta = constructRegistryMeta(pkg, liveMeta);
|
|
71
|
+
|
|
72
|
+
const findings = await runAll(pkgJson, [], registryMeta, []);
|
|
73
|
+
|
|
74
|
+
const detectedIds = [...new Set(findings.map(f => f.id))];
|
|
75
|
+
|
|
76
|
+
const result = {
|
|
77
|
+
package: pkg.package,
|
|
78
|
+
version: pkg.version,
|
|
79
|
+
campaign_id: pkg.campaign_id,
|
|
80
|
+
campaign_name: pkg.campaign_name,
|
|
81
|
+
attack_vector: pkg.attack_vector,
|
|
82
|
+
expected_detectors: pkg.expected_detectors,
|
|
83
|
+
detected_detectors: detectedIds,
|
|
84
|
+
detection_count: findings.length,
|
|
85
|
+
detections: findings.map(f => ({
|
|
86
|
+
id: f.id,
|
|
87
|
+
detector: f.detector,
|
|
88
|
+
severity: f.severity,
|
|
89
|
+
confidence: f.confidence,
|
|
90
|
+
confidenceScore: f.confidenceScore,
|
|
91
|
+
subtype: f.subtype,
|
|
92
|
+
message: f.message,
|
|
93
|
+
})),
|
|
94
|
+
metadata_available: !!liveMeta,
|
|
95
|
+
registry_source: liveMeta ? 'live' : 'fixture',
|
|
96
|
+
timestamp: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
allResults.push(result);
|
|
100
|
+
|
|
101
|
+
const expectedCount = pkg.expected_detectors.length;
|
|
102
|
+
const hitCount = detectedIds.filter(id => pkg.expected_detectors.includes(id)).length;
|
|
103
|
+
console.log(
|
|
104
|
+
` ${hitCount > 0 ? '✓' : '✗'} ${pkg.package}@${pkg.version}: ${hitCount}/${expectedCount} expected detectors fired`
|
|
105
|
+
);
|
|
106
|
+
for (const f of findings) {
|
|
107
|
+
console.log(` ${f.id} (${f.confidenceScore}%, ${f.severity})`);
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error(` ✗ ${pkg.package}@${pkg.version}: ${err.message}`);
|
|
111
|
+
allResults.push({
|
|
112
|
+
package: pkg.package,
|
|
113
|
+
version: pkg.version,
|
|
114
|
+
campaign_id: pkg.campaign_id,
|
|
115
|
+
error: err.message,
|
|
116
|
+
timestamp: new Date().toISOString(),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (outputFile) {
|
|
123
|
+
const lines = allResults.map(r => JSON.stringify(r)).join('\n') + '\n';
|
|
124
|
+
writeFileSync(outputFile, lines, 'utf-8');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const processed = allResults.filter(r => !r.error).length;
|
|
128
|
+
const errors = allResults.filter(r => r.error).length;
|
|
129
|
+
console.log(`\n[SUMMARY] Processed ${processed} packages, ${errors} errors`);
|
|
130
|
+
console.log(`[INFO] Results written to ${outputFile}`);
|
|
131
|
+
|
|
132
|
+
return allResults;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const args = process.argv.slice(2);
|
|
136
|
+
const campaignArg = args[0] || 'all';
|
|
137
|
+
const outputArg = args[1] ? resolve(args[1]) : resolve('validation-results.jsonl');
|
|
138
|
+
|
|
139
|
+
validateDetectors(campaignArg, outputArg).then(() => process.exit(0)).catch(err => {
|
|
140
|
+
console.error(`[ERROR] ${err.message}`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'assert/strict';
|
|
3
|
+
import * as detectors from './detectors/index.js';
|
|
4
|
+
|
|
5
|
+
describe('D5: Binary Embed Enhancement', () => {
|
|
6
|
+
test('D5: flags cross-platform campaign-1 binary set with high confidence', async () => {
|
|
7
|
+
const allFiles = [
|
|
8
|
+
{ path: 'bin/agent-linux-x64', content: String.fromCharCode(0x7f) + 'ELF' },
|
|
9
|
+
{ path: 'bin/agent-macos-arm64', content: String.fromCharCode(0xcf, 0xfa, 0xed, 0xfe) },
|
|
10
|
+
{ path: 'bin/agent-windows-x86.exe', content: String.fromCharCode(0x4d, 0x5a) },
|
|
11
|
+
];
|
|
12
|
+
const pkg = { name: 'suspicious-pkg', version: '1.0.0' };
|
|
13
|
+
const findings = await detectors.runAll(pkg, [], null, allFiles);
|
|
14
|
+
const matches = findings.filter(f => f.id === 'TIER1-BINARY-EMBED');
|
|
15
|
+
assert(matches.length > 0, 'Expected TIER1-BINARY-EMBED finding');
|
|
16
|
+
const hasCrossPlatform = matches.some(m =>
|
|
17
|
+
m.evidence.some(e => e.includes('cross-platform')),
|
|
18
|
+
);
|
|
19
|
+
assert(hasCrossPlatform, 'Expected cross-platform binary set evidence');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('D5: cross-platform binary set scores > 85', async () => {
|
|
23
|
+
const allFiles = [
|
|
24
|
+
{ path: 'bin/agent-linux-x64', content: String.fromCharCode(0x7f) + 'ELF' },
|
|
25
|
+
{ path: 'bin/agent-macos-arm64', content: String.fromCharCode(0xcf, 0xfa, 0xed, 0xfe) },
|
|
26
|
+
];
|
|
27
|
+
const pkg = { name: 'suspicious-pkg', version: '1.0.0' };
|
|
28
|
+
const findings = await detectors.runAll(pkg, [], null, allFiles);
|
|
29
|
+
const matches = findings.filter(f => f.id === 'TIER1-BINARY-EMBED');
|
|
30
|
+
const hasHighScore = matches.some(m => m.confidenceScore > 85);
|
|
31
|
+
assert(hasHighScore, `No finding with confidenceScore > 85; scores: ${matches.map(m => m.confidenceScore).join(', ')}`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('D5: single binary not flagged as cross-platform', async () => {
|
|
35
|
+
const allFiles = [
|
|
36
|
+
{ path: 'bin/agent-linux-x64', content: String.fromCharCode(0x7f) + 'ELF' },
|
|
37
|
+
];
|
|
38
|
+
const pkg = { name: 'normal-pkg', version: '1.0.0' };
|
|
39
|
+
const findings = await detectors.runAll(pkg, [], null, allFiles);
|
|
40
|
+
const matches = findings.filter(f => f.id === 'TIER1-BINARY-EMBED');
|
|
41
|
+
const hasPlatformLabel = matches.some(m =>
|
|
42
|
+
m.evidence.some(e => e.includes('cross-platform')),
|
|
43
|
+
);
|
|
44
|
+
assert(!hasPlatformLabel, 'Single binary should not be flagged as cross-platform');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'assert/strict';
|
|
3
|
+
import * as detectors from './detectors/index.js';
|
|
4
|
+
|
|
5
|
+
describe('D6: Version Anomaly', () => {
|
|
6
|
+
test('D6: flags 99.99.99 when legitimate max is 5.3.2', async () => {
|
|
7
|
+
const pkg = { name: '@widget/core', version: '99.99.99' };
|
|
8
|
+
const registryMeta = ['1.0.0', '1.5.0', '2.0.0', '2.1.0', '3.0.0', '4.0.0', '5.0.0', '5.3.2'];
|
|
9
|
+
const findings = await detectors.runAll(pkg, [], registryMeta);
|
|
10
|
+
const match = findings.find(f => f.id === 'TIER1-VERSION-ANOMALY');
|
|
11
|
+
assert(match, 'Expected TIER1-VERSION-ANOMALY finding');
|
|
12
|
+
assert(match.confidenceScore > 90, `confidenceScore ${match.confidenceScore} <= 90`);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('D6: flags 11.11.11 with high confidence', async () => {
|
|
16
|
+
const pkg = { name: 'internal-utils', version: '11.11.11' };
|
|
17
|
+
const registryMeta = ['1.0.0', '1.0.1', '1.1.0', '1.2.0', '2.0.0'];
|
|
18
|
+
const findings = await detectors.runAll(pkg, [], registryMeta);
|
|
19
|
+
const match = findings.find(f => f.id === 'TIER1-VERSION-ANOMALY');
|
|
20
|
+
assert(match, 'Expected TIER1-VERSION-ANOMALY finding');
|
|
21
|
+
assert(match.confidenceScore > 85, `confidenceScore ${match.confidenceScore} <= 85`);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('D6: does NOT flag legitimate 2.0.0 jump from 1.9.9', async () => {
|
|
25
|
+
const pkg = { name: 'stable-lib', version: '2.0.0' };
|
|
26
|
+
const registryMeta = [
|
|
27
|
+
'1.0.0', '1.1.0', '1.2.0', '1.3.0', '1.4.0', '1.5.0',
|
|
28
|
+
'1.6.0', '1.7.0', '1.8.0', '1.9.0', '1.9.9',
|
|
29
|
+
];
|
|
30
|
+
const findings = await detectors.runAll(pkg, [], registryMeta);
|
|
31
|
+
const match = findings.find(f => f.id === 'TIER1-VERSION-ANOMALY');
|
|
32
|
+
assert(!match, 'Should not flag legitimate 2.0.0 major bump');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('D6: handles null registry gracefully — degrades confidence', async () => {
|
|
36
|
+
const pkg = { name: 'offline-pkg', version: '99.99.99' };
|
|
37
|
+
const findings = await detectors.runAll(pkg);
|
|
38
|
+
const match = findings.find(f => f.id === 'TIER1-VERSION-ANOMALY');
|
|
39
|
+
assert(match, 'Expected TIER1-VERSION-ANOMALY finding');
|
|
40
|
+
assert(match.confidenceScore < 70, `confidenceScore ${match.confidenceScore} >= 70`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('D6: no finding on KNOWN_REPUTABLE_PACKAGES regardless of version', async () => {
|
|
44
|
+
const pkg = { name: 'react', version: '99.99.99' };
|
|
45
|
+
const registryMeta = ['1.0.0', '2.0.0'];
|
|
46
|
+
const findings = await detectors.runAll(pkg, [], registryMeta);
|
|
47
|
+
const match = findings.find(f => f.id === 'TIER1-VERSION-ANOMALY');
|
|
48
|
+
assert(!match);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('D6: no finding on normal semver within range', async () => {
|
|
52
|
+
const pkg = { name: 'widget-core', version: '5.4.1' };
|
|
53
|
+
const registryMeta = ['1.0.0', '2.0.0', '3.0.0', '4.0.0', '5.0.0', '5.4.0'];
|
|
54
|
+
const findings = await detectors.runAll(pkg, [], registryMeta);
|
|
55
|
+
const match = findings.find(f => f.id === 'TIER1-VERSION-ANOMALY');
|
|
56
|
+
assert(!match);
|
|
57
|
+
});
|
|
58
|
+
});
|