@lateos/npm-scan 0.3.3 → 0.4.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/backend/cra.js ADDED
@@ -0,0 +1,69 @@
1
+ export function generateCRA(scans) {
2
+ const atkMap = {};
3
+ for (const s of scans) {
4
+ for (const f of (s.findings || [])) {
5
+ const key = f.atk_id || f.id;
6
+ if (!atkMap[key]) atkMap[key] = [];
7
+ atkMap[key].push({ ...f, package_name: s.package_name, version: s.version });
8
+ }
9
+ }
10
+
11
+ const CRA_ARTICLES = [
12
+ { article: 'Art. 7', title: 'Secure by default configuration', atkId: 'ATK-001', desc: 'Lifecycle hooks used for insecure defaults' },
13
+ { article: 'Art. 7', title: 'Secure by default configuration', atkId: 'ATK-010', desc: 'Anti-analysis in default state' },
14
+ { article: 'Art. 10(1)', title: 'Vulnerability disclosure', atkId: 'ATK-008', desc: 'Tarball integrity prevents disclosure accuracy' },
15
+ { article: 'Art. 10(2)', title: 'Known vulnerability reporting', atkId: 'ATK-006', desc: 'Dependency confusion undermines visibility' },
16
+ { article: 'Art. 11', title: 'Software Bill of Materials', atkId: 'ATK-008', desc: 'Integrity of SBOM entries must be verified' },
17
+ { article: 'Art. 11', title: 'Software Bill of Materials', atkId: 'ATK-006', desc: 'SBOM must reflect actual dependency graph' },
18
+ { article: 'Annex I(1.1)', title: 'No known exploitable vulnerabilities', atkId: 'ATK-009', desc: 'Conditional triggers may activate known vulns' },
19
+ { article: 'Annex I(1.3)', title: 'Least privilege', atkId: 'ATK-003', desc: 'Credential harvesting violates least privilege' },
20
+ { article: 'Annex I(1.5)', title: 'Limited attack surface', atkId: 'ATK-002', desc: 'Obfuscation increases attack surface' },
21
+ { article: 'Annex I(1.5)', title: 'Limited attack surface', atkId: 'ATK-004', desc: 'Persistence mechanisms expand attack surface' },
22
+ { article: 'Annex I(1.5)', title: 'Limited attack surface', atkId: 'ATK-005', desc: 'Network exfiltration expands attack surface' },
23
+ { article: 'Annex I(2.1)', title: 'Protection against unauthorized access', atkId: 'ATK-003', desc: 'Credential harvesting enables unauthorized access' },
24
+ { article: 'Annex I(2.3)', title: 'Data integrity', atkId: 'ATK-008', desc: 'Tarball tampering violates data integrity' },
25
+ { article: 'Annex I(2.3)', title: 'Data integrity', atkId: 'ATK-011', desc: 'Propagation attacks compromise data integrity' },
26
+ { article: 'Annex I(3.2)', title: 'Incident detection and reporting', atkId: 'ATK-009', desc: 'Conditional triggers evade incident detection' },
27
+ { article: 'Annex I(3.2)', title: 'Incident detection and reporting', atkId: 'ATK-010', desc: 'Sandbox evasion defeats incident detection' },
28
+ { article: 'Annex I(3.3)', title: 'Supply chain security monitoring', atkId: 'ATK-011', desc: 'Propagation requires SC monitoring' },
29
+ { article: 'Annex I(3.3)', title: 'Supply chain security monitoring', atkId: 'ATK-007', desc: 'Typosquatting undermines SC trust' },
30
+ ];
31
+
32
+ let rows = '';
33
+ for (const { article, title, atkId, desc } of CRA_ARTICLES) {
34
+ const findings = atkMap[atkId] || [];
35
+ const status = findings.length > 0 ? 'fail' : 'pass';
36
+ const colorClass = status === 'pass' ? 'pass' : 'fail';
37
+ const label = findings.length > 0 ? `${findings.length} finding(s)` : 'No findings';
38
+ rows += `<tr><td>${article}</td><td>${title}</td><td>${desc}</td><td class="nist-${colorClass}">${label}</td><td>${atkId}</td></tr>`;
39
+ }
40
+
41
+ return `<h2>EU CRA Compliance Summary</h2>
42
+ <table>
43
+ <thead><tr><th>CRA Article</th><th>Requirement</th><th>Relevance</th><th>Status</th><th>ATK</th></tr></thead>
44
+ <tbody>${rows}</tbody>
45
+ </table>`;
46
+ }
47
+
48
+ export function generateCRAHTML(scans) {
49
+ const body = generateCRA(scans);
50
+ return `<!DOCTYPE html>
51
+ <html lang="en">
52
+ <head><meta charset="UTF-8"><title>EU CRA Compliance Report</title>
53
+ <style>
54
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 960px; margin: 0 auto; padding: 20px; background: #0d1117; color: #c9d1d9; }
55
+ h1, h2 { color: #58a6ff; }
56
+ table { width: 100%; border-collapse: collapse; margin: 12px 0; }
57
+ th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #30363d; }
58
+ th { background: #161b22; }
59
+ .nist-pass { background: #1b3a1b; color: #7ee787; }
60
+ .nist-fail { background: #3a1b1b; color: #ff7b72; }
61
+ .meta { color: #8b949e; font-size: 13px; margin-top: 30px; }
62
+ </style></head>
63
+ <body>
64
+ <h1>npm-scan EU CRA Compliance Report</h1>
65
+ <p>Generated ${new Date().toISOString()} | npm-scan v${process.env.npm_package_version || '0.4.0'}</p>
66
+ ${body}
67
+ <p class="meta">EU Cyber Resilience Act (Regulation 2023/2841) mapped to ATK findings.</p>
68
+ </body></html>`;
69
+ }
@@ -0,0 +1,85 @@
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
+ {
7
+ pattern: /(?:exec|execSync|spawn)\s*\([^)]*npm\s+(?:install|link)\s*\./i,
8
+ label: 'programmatic self-propagation via npm install/link'
9
+ },
10
+ {
11
+ pattern: /fs\.(?:writeFile|writeFileSync|copyFile|copyFileSync)\s*\([^)]*(?:node_modules\/(?!\.)[^/]+).*(?:index\.js|main\.js|package\.json)/i,
12
+ label: 'direct file write to peer node_modules'
13
+ },
14
+ {
15
+ pattern: /fs\.(?:writeFile|writeFileSync)\s*\([^)]*package\.json[^)]*["']scripts["']/i,
16
+ label: 'package.json script injection in another package'
17
+ },
18
+ {
19
+ pattern: /fs\.(?:writeFile|writeFileSync)\s*\([^)]*\.\.\/[^)]*package\.json/i,
20
+ label: 'writes modified package.json to sibling package'
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
+ {
27
+ pattern: /(?:exec|execSync|spawn)\s*\([^)]*(?:\.\.\/|process\.env\.INIT_CWD).*npm\s+install/i,
28
+ label: 'cross-directory npm install propagation'
29
+ },
30
+ ];
31
+
32
+ for (const { pattern, label } of highPatterns) {
33
+ if (pattern.test(code)) {
34
+ findings.push({
35
+ id: 'ATK-011',
36
+ severity: 'high',
37
+ title: 'Transitive propagation (worm)',
38
+ description: `Package attempts lateral worm-style spread: ${label}`,
39
+ evidence: 'transitive propagation pattern detected'
40
+ });
41
+ break;
42
+ }
43
+ }
44
+
45
+ if (findings.length === 0) {
46
+ const selfName = pkgJson && pkgJson.name ? pkgJson.name.replace(/^@/, '').replace(/\//, '-') : null;
47
+ const mediumPatterns = [
48
+ {
49
+ pattern: /process\.env\.npm_package_name/,
50
+ label: 'reads own package name (potential self-awareness for spread)'
51
+ },
52
+ {
53
+ pattern: selfName ? new RegExp('require\\([\'"]' + selfName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\'"]\\)', 'i') : null,
54
+ label: selfName ? `require() of own package name "${selfName}"` : null
55
+ },
56
+ {
57
+ pattern: /fs\.(?:mkdir|mkdirSync)\s*\([^)]*\.\.\/[^)]*node_modules/,
58
+ label: 'creates directories in parent node_modules structure'
59
+ },
60
+ {
61
+ pattern: /fs\.symlink(?:Sync)?\s*\(/,
62
+ label: 'creates symlinks (potential worm link spreading)'
63
+ },
64
+ {
65
+ pattern: /__dirname.*node_modules/,
66
+ label: 'references own directory path in node_modules'
67
+ },
68
+ ];
69
+
70
+ for (const { pattern, label: mLabel } of mediumPatterns) {
71
+ if (pattern && pattern.test(code) && mLabel) {
72
+ findings.push({
73
+ id: 'ATK-011',
74
+ severity: 'medium',
75
+ title: 'Transitive propagation (worm)',
76
+ description: mLabel,
77
+ evidence: 'potential propagation indicator'
78
+ });
79
+ break;
80
+ }
81
+ }
82
+ }
83
+
84
+ return findings;
85
+ }
@@ -8,6 +8,7 @@ import * as atk007 from './atk-007-typosquat.js';
8
8
  import * as atk008 from './atk-008-tarball-tamper.js';
9
9
  import * as atk009 from './atk-009-dormant-trigger.js';
10
10
  import * as atk010 from './atk-010-sandbox-evasion.js';
11
+ import * as atk011 from './atk-011-transitive-prop.js';
11
12
 
12
13
  export async function runAll(pkgJson, files = []) {
13
14
  const findings = [];
@@ -21,5 +22,6 @@ export async function runAll(pkgJson, files = []) {
21
22
  findings.push(...await atk008.scan(pkgJson, files));
22
23
  findings.push(...await atk009.scan(pkgJson, files));
23
24
  findings.push(...await atk010.scan(pkgJson, files));
25
+ findings.push(...await atk011.scan(pkgJson, files));
24
26
  return findings.sort((a, b) => b.severity.localeCompare(a.severity));
25
27
  }
@@ -67,6 +67,12 @@ test('ATK-010 detects sandbox evasion', async () => {
67
67
  assert(findings.some(f => f.id === 'ATK-010'), 'Expected ATK-010');
68
68
  });
69
69
 
70
+ test('ATK-011 detects transitive propagation', async () => {
71
+ const files = [{ path: 'i.js', content: 'exec("npm install ./malicious-pkg")' }];
72
+ const findings = await detectors.runAll({}, files);
73
+ assert(findings.some(f => f.id === 'ATK-011'), 'Expected ATK-011');
74
+ });
75
+
70
76
  test('no false positives on clean package', async () => {
71
77
  const pkg = { name: 'test-pkg', version: '1.0.0', scripts: { test: 'node test.js' }, dependencies: { 'express': '4.0.0' } };
72
78
  const files = [{ path: 'index.js', content: 'module.exports = function() { return 42 }' }];
@@ -75,8 +81,8 @@ test('no false positives on clean package', async () => {
75
81
  assert.equal(highCrit.length, 0, `Expected no high/crit findings on clean pkg: ${JSON.stringify(highCrit)}`);
76
82
  });
77
83
 
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'];
84
+ test('all 11 ATK IDs present', async () => {
85
+ const expected = ['ATK-001', 'ATK-002', 'ATK-003', 'ATK-004', 'ATK-005', 'ATK-006', 'ATK-007', 'ATK-008', 'ATK-009', 'ATK-010', 'ATK-011'];
80
86
  const exports = Object.keys(detectors);
81
87
  assert.equal(exports.includes('runAll'), true);
82
88
  });
package/backend/report.js CHANGED
@@ -93,6 +93,7 @@ const NIST_SR_MAP = {
93
93
  'ATK-008': { control: 'SR-8.1', title: 'Integrity verification' },
94
94
  'ATK-009': { control: 'SR-9.2', title: 'Conditional behavior analysis' },
95
95
  'ATK-010': { control: 'SR-10.3', title: 'Anti-evasion detection' },
96
+ 'ATK-011': { control: 'SR-11.4', title: 'Supply chain propagation monitoring' },
96
97
  };
97
98
 
98
99
  function generateNistTable(scans) {
package/backend/sbom.js CHANGED
@@ -15,7 +15,7 @@ function generateCycloneDX(pkgJson, findings) {
15
15
  version: pkgJson.version || 'unknown',
16
16
  purl: `pkg:npm/${pkgJson.name || 'unknown'}@${pkgJson.version || 'unknown'}`
17
17
  },
18
- tools: [{ name: 'npm-scan', version: '0.3.2' }]
18
+ tools: [{ name: 'npm-scan', version: process.env.npm_package_version || '0.3.2' }]
19
19
  },
20
20
  vulnerabilities: findings.map(f => {
21
21
  const atkId = f.atk_id || f.id;
@@ -0,0 +1,33 @@
1
+ export function generateCEF(scans) {
2
+ const entries = [];
3
+ for (const s of scans) {
4
+ for (const f of (s.findings || [])) {
5
+ const atkId = f.atk_id || f.id;
6
+ const desc = (f.description || f.title || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
7
+ const sevMap = { critical: 10, high: 8, medium: 5, low: 2 };
8
+ const sev = sevMap[f.severity] || 5;
9
+ const pkgName = (s.package_name || 'unknown').replace(/\|/g, '\\|');
10
+ const pkgVer = (s.version || '').replace(/\|/g, '\\|');
11
+ entries.push([
12
+ 'CEF:0',
13
+ 'npm-scan',
14
+ 'npm-scan',
15
+ process.env.npm_package_version || '0.4.0',
16
+ atkId,
17
+ desc,
18
+ String(sev),
19
+ `suser=${pkgName} ${pkgVer}`,
20
+ `msg=${desc}`,
21
+ `cs1=${atkId}`,
22
+ `cs1Label=atkId`,
23
+ `cs2=${f.severity}`,
24
+ `cs2Label=severity`,
25
+ `cs3=${pkgName}`,
26
+ `cs3Label=package`,
27
+ `cs4=${pkgVer}`,
28
+ `cs4Label=version`,
29
+ ].join('|'));
30
+ }
31
+ }
32
+ return entries.join('\n');
33
+ }
@@ -0,0 +1,10 @@
1
+ import { generateCEF } from './cef.js';
2
+
3
+ export function generateSIEM(scans, format = 'cef') {
4
+ switch (format) {
5
+ case 'cef':
6
+ return generateCEF(scans);
7
+ default:
8
+ throw new Error(`Unknown SIEM format: ${format}`);
9
+ }
10
+ }
package/cli/cli.js CHANGED
@@ -5,7 +5,7 @@ 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.5');
8
+ .version('0.4.0');
9
9
 
10
10
  program
11
11
  .command('scan')
@@ -47,8 +47,11 @@ program
47
47
  .description('Generate report')
48
48
  .option('-i, --id <id>', 'Scan ID')
49
49
  .option('--sbom [format]', 'SBOM format (json/xml/spdx)')
50
- .option('--html', 'HTML report')
50
+ .option('--html', 'HTML report')
51
51
  .option('--nist', 'NIST 800-161 compliance report')
52
+ .option('--cra', 'EU CRA compliance report')
53
+ .option('--siem <format>', 'SIEM format (cef)')
54
+ .option('-l, --license-key <key>', 'Premium license')
52
55
  .action(async (options) => {
53
56
  const { getRecentScans, getFindings, getScan } = await import('../backend/db.js');
54
57
  if (options.id) {
@@ -57,30 +60,38 @@ program
57
60
  const pkgName = scanInfo?.package_name || 'scan-' + options.id;
58
61
  const pkgVer = scanInfo?.version || 'unknown';
59
62
  const pkg = { name: pkgName, version: pkgVer };
63
+ const scan = findings.length ? { package_name: pkgName, version: pkgVer, findings } : null;
60
64
  if (options.sbom) {
61
65
  const { generateSBOM } = await import('../backend/sbom.js');
62
66
  const sbom = generateSBOM(pkg, findings, options.sbom === true ? 'json' : options.sbom);
63
67
  console.log(sbom);
68
+ } else if (options.siem) {
69
+ const { generateSIEM } = await import('../backend/siem/index.js');
70
+ console.log(generateSIEM(scan ? [scan] : [], options.siem));
71
+ } else if (options.cra) {
72
+ const { generateCRA } = await import('../backend/cra.js');
73
+ console.log(generateCRA(scan ? [scan] : []));
64
74
  } else if (options.html || options.nist) {
65
75
  const { generateHTML } = await import('../backend/report.js');
66
- const scan = findings.length ? { package_name: pkgName, version: pkgVer, findings } : null;
67
76
  const html = generateHTML(scan ? [scan] : []);
68
77
  console.log(html);
69
78
  } else {
70
79
  console.log(JSON.stringify(findings, null, 2));
71
80
  }
72
81
  } else {
73
- if (options.html || options.nist) {
74
- const scans = getRecentScans();
75
- const scansWithFindings = scans.map(s => ({
76
- ...s,
77
- findings: getFindings(s.id)
78
- }));
82
+ const scans = getRecentScans();
83
+ const scansWithFindings = scans.map(s => ({ ...s, findings: getFindings(s.id) }));
84
+ if (options.siem) {
85
+ const { generateSIEM } = await import('../backend/siem/index.js');
86
+ console.log(generateSIEM(scansWithFindings, options.siem));
87
+ } else if (options.cra) {
88
+ const { generateCRA } = await import('../backend/cra.js');
89
+ console.log(generateCRA(scansWithFindings));
90
+ } else if (options.html || options.nist) {
79
91
  const { generateHTML } = await import('../backend/report.js');
80
92
  const html = generateHTML(scansWithFindings);
81
93
  console.log(html);
82
94
  } else {
83
- const scans = getRecentScans();
84
95
  console.log('Recent scans:', JSON.stringify(scans, null, 2));
85
96
  }
86
97
  }
@@ -16,7 +16,7 @@ Versioned anchor for detectors, PRs, reports. Each entry: attack class, detectio
16
16
  | ATK-008 | Tarball tampering (tarball ≠ repo) | Static (diff) | Mirror repos | SR-8.1 | Phase 2 |
17
17
  | ATK-009 | Conditional triggers (CI/time) | Static+Dynamic | Env probes | SR-9.2 | Phase 2 |
18
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 |
19
+ | ATK-011 | Transitive propagation (worm) | Static+Dynamic | Peer deps | SR-11.4 | Phase 3 |
20
20
 
21
21
  ## Detailed Entries
22
22
 
@@ -41,6 +41,13 @@ Versioned anchor for detectors, PRs, reports. Each entry: attack class, detectio
41
41
  - **NIST mapping:** SR-10.3 (Anti-Evasion Detection)
42
42
  - **Example:** `if (os.hostname().includes('docker')) { process.exit(0) }`
43
43
 
44
+ ### ATK-011 — Transitive Propagation (Worm)
45
+ - **Description:** The package acts as a self-propagating worm by spreading itself through the dependency tree. Instead of (or in addition to) executing a standalone payload, it modifies peer/sibling packages' code or `package.json` to inject its own lifecycle hooks or entry points. This propagates the attack when the infected sibling is required by other packages upstream. Also covers "worm-drop" patterns where the package installs itself into other packages' `node_modules` via programmatic `npm install`/`npm link`.
46
+ - **Detection surface:** Static+Dynamic. Static analysis checks for `child_process.exec`/`execSync` with `npm install`/`npm link` of local packages, `fs.writeFile` targeting sibling `node_modules` directories, `package.json` script injection in other packages, `fs.symlink` for binary propagation, and self-name references used to locate and copy own code into peer packages. Dynamic sandbox monitors filesystem writes outside the package's own directory tree.
47
+ - **Evasion surface:** Peer dep confusion (installing a popular peer package with the same name as a benign transitive dep), delayed propagation (worm activates only after N runs or N days), piggybacking on legitimate `postinstall` chains, writing to `node_modules/.cache` or `node_modules/.bin` where detection is less likely.
48
+ - **NIST mapping:** SR-11.4 (Supply Chain Propagation Monitoring)
49
+ - **Example:** A package reads `process.env.npm_package_name`, locates its own directory in `node_modules`, then writes a malicious `postinstall` script to the `package.json` of a sibling package, ensuring the worm runs when any project dependency is installed.
50
+
44
51
  ## Governance
45
52
 
46
53
  New ATK requires PR with: PoC sample, detection rule, FP analysis, NIST map. Published at `docs/attack-taxonomy.md`. Referenced in all scan reports.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lateos/npm-scan",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
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": {
@@ -15,6 +15,9 @@ const MALS = [
15
15
  { name: 'mal-evasion-1', pkg: {}, js: 'if (os.hostname().includes("docker") || os.hostname().includes("sandbox")) { process.exit(0) }' },
16
16
  { name: 'mal-evasion-2', pkg: {}, js: 'if (process.argv.join(" ").includes("inspect")) { debugger; /* stop analysis */ }' },
17
17
  { name: 'mal-evasion-3', pkg: {}, js: 'try { throw new Error(); } catch(e) { if (e.stack.includes("sandbox")) { process.exit(0) } }' },
18
+ { name: 'mal-prop-1', pkg: { name: '@evil/worm' }, js: 'execSync("npm install ./worm-pkg"); execSync("npm link")' },
19
+ { name: 'mal-prop-2', pkg: {}, js: 'fs.writeFileSync("../lodash/node_modules/worm/index.js", "module.exports = { compromised: true }")' },
20
+ { name: 'mal-prop-3', pkg: { name: 'worm-pkg' }, js: `const pj = require('../express/package.json'); pj.scripts.install = 'node worm.js'; fs.writeFileSync('../express/package.json', JSON.stringify(pj))` },
18
21
  ];
19
22
 
20
23
  for (const mal of MALS) {