@lateos/npm-scan 0.7.6 → 0.8.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/pdf.js ADDED
@@ -0,0 +1,245 @@
1
+ import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
2
+
3
+ const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
4
+ const SEV_COLORS = { critical: rgb(0.8, 0.2, 0.2), high: rgb(0.75, 0.15, 0.15), medium: rgb(0.9, 0.5, 0.1), low: rgb(0.8, 0.7, 0.1) };
5
+
6
+ const NIST_SR_MAP = {
7
+ 'ATK-001': { control: 'SR-3.1', title: 'Malicious code detection' },
8
+ 'ATK-002': { control: 'SR-4.2', title: 'Code obfuscation analysis' },
9
+ 'ATK-003': { control: 'SR-5.3', title: 'Credential protection' },
10
+ 'ATK-004': { control: 'SR-6.4', title: 'Persistence monitoring' },
11
+ 'ATK-005': { control: 'SR-7.5', title: 'Data exfiltration prevention' },
12
+ 'ATK-006': { control: 'SR-2.2', title: 'Dependency validation' },
13
+ 'ATK-007': { control: 'SR-2.1', title: 'Typosquatting prevention' },
14
+ 'ATK-008': { control: 'SR-8.1', title: 'Integrity verification' },
15
+ 'ATK-009': { control: 'SR-9.2', title: 'Conditional behavior analysis' },
16
+ 'ATK-010': { control: 'SR-10.3', title: 'Anti-evasion detection' },
17
+ 'ATK-011': { control: 'SR-11.4', title: 'Supply chain propagation monitoring' },
18
+ };
19
+
20
+ const MARGIN = 50;
21
+ const PAGE_W = 612;
22
+ const PAGE_H = 792;
23
+ const CONTENT_W = PAGE_W - MARGIN * 2;
24
+
25
+ function wrapText(text, font, size, maxWidth) {
26
+ const words = text.split(' ');
27
+ const lines = [];
28
+ let current = '';
29
+ for (const word of words) {
30
+ const test = current ? current + ' ' + word : word;
31
+ if (font.widthOfTextAtSize(test, size) > maxWidth) {
32
+ if (current) lines.push(current);
33
+ current = word;
34
+ } else {
35
+ current = test;
36
+ }
37
+ }
38
+ if (current) lines.push(current);
39
+ return lines;
40
+ }
41
+
42
+ function drawTableRow(page, font, columns, y, colWidths, fontSize, isHeader) {
43
+ let x = MARGIN;
44
+ const rowH = fontSize + 6;
45
+ for (let i = 0; i < columns.length; i++) {
46
+ const text = columns[i];
47
+ const lines = wrapText(text, font, fontSize, colWidths[i] - 4);
48
+ for (let j = 0; j < lines.length; j++) {
49
+ page.drawText(lines[j], { x: x + 2, y: y - (j * fontSize) - 2, size: fontSize, font, color: rgb(0, 0, 0) });
50
+ }
51
+ x += colWidths[i];
52
+ }
53
+ return y - rowH;
54
+ }
55
+
56
+ function drawPageHeader(page, font, text, y) {
57
+ page.drawText(text, { x: MARGIN, y, size: 14, font, color: rgb(0.2, 0.2, 0.2) });
58
+ page.drawLine({ start: { x: MARGIN, y: y - 4 }, end: { x: PAGE_W - MARGIN, y: y - 4 }, thickness: 1, color: rgb(0.7, 0.7, 0.7) });
59
+ return y - 20;
60
+ }
61
+
62
+ export async function generatePDF(scans) {
63
+ const doc = await PDFDocument.create();
64
+ const font = await doc.embedFont(StandardFonts.Helvetica);
65
+ const boldFont = await doc.embedFont(StandardFonts.HelveticaBold);
66
+ const version = process.env.npm_package_version || '0.0.0';
67
+
68
+ const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 };
69
+ let totalFindings = 0;
70
+ for (const s of scans) {
71
+ for (const f of (s.findings || [])) {
72
+ if (sevCounts[f.severity] !== undefined) sevCounts[f.severity]++;
73
+ totalFindings++;
74
+ }
75
+ }
76
+
77
+ // ─── Page 1: Title ──────────────────────────────────────────────────
78
+ let page = doc.addPage([PAGE_W, PAGE_H]);
79
+ let y = PAGE_H - MARGIN;
80
+
81
+ page.drawText('npm-scan Report', { x: MARGIN, y, size: 24, font: boldFont, color: rgb(0, 0, 0) });
82
+ y -= 30;
83
+ page.drawText(`Generated: ${new Date().toISOString()}`, { x: MARGIN, y, size: 10, font, color: rgb(0.4, 0.4, 0.4) });
84
+ y -= 14;
85
+ page.drawText(`Version: ${version} | Packages scanned: ${scans.length} | Total findings: ${totalFindings}`, { x: MARGIN, y, size: 10, font, color: rgb(0.4, 0.4, 0.4) });
86
+ y -= 30;
87
+
88
+ // Severity summary
89
+ page.drawText('Severity Summary', { x: MARGIN, y, size: 14, font: boldFont, color: rgb(0, 0, 0) });
90
+ y -= 20;
91
+
92
+ for (const sev of SEV_ORDER) {
93
+ const count = sevCounts[sev] || 0;
94
+ const color = SEV_COLORS[sev] || rgb(0, 0, 0);
95
+ page.drawCircle({ x: MARGIN + 6, y: y - 4, size: 4, color });
96
+ page.drawText(`${sev}: ${count}`, { x: MARGIN + 16, y: y - 8, size: 11, font, color: rgb(0, 0, 0) });
97
+ y -= 18;
98
+ }
99
+
100
+ y -= 20;
101
+
102
+ // Per-package summary
103
+ for (const s of scans) {
104
+ const findings = s.findings || [];
105
+ if (y < MARGIN + 60) { page = doc.addPage([PAGE_W, PAGE_H]); y = PAGE_H - MARGIN; }
106
+
107
+ page.drawText(`${s.package_name}@${s.version || 'unknown'}`, { x: MARGIN, y, size: 12, font: boldFont, color: rgb(0, 0, 0) });
108
+ y -= 16;
109
+ page.drawText(` ${findings.length} findings`, { x: MARGIN, y, size: 10, font, color: rgb(0.4, 0.4, 0.4) });
110
+ y -= 14;
111
+
112
+ for (const f of findings) {
113
+ if (y < MARGIN + 20) { page = doc.addPage([PAGE_W, PAGE_H]); y = PAGE_H - MARGIN; }
114
+ const sevColor = SEV_COLORS[f.severity] || rgb(0, 0, 0);
115
+ page.drawCircle({ x: MARGIN + 3, y: y + 2, size: 3, color: sevColor });
116
+ const line = `${f.atk_id || f.id} ${f.severity} ${(f.description || f.title || '').slice(0, 70)}`;
117
+ page.drawText(line, { x: MARGIN + 12, y, size: 9, font, color: rgb(0.1, 0.1, 0.1) });
118
+ y -= 13;
119
+ }
120
+ }
121
+
122
+ // ─── Page: Findings Table ───────────────────────────────────────────
123
+ page = doc.addPage([PAGE_W, PAGE_H]);
124
+ y = PAGE_H - MARGIN;
125
+ y = drawPageHeader(page, boldFont, 'All Findings', y);
126
+ y -= 6;
127
+
128
+ const colWidths = [60, 55, 140, CONTENT_W - 255];
129
+ const headers = ['ATK ID', 'Severity', 'Title', 'Evidence'];
130
+
131
+ // Draw header
132
+ let x = MARGIN;
133
+ for (let i = 0; i < headers.length; i++) {
134
+ page.drawText(headers[i], { x: x + 2, y, size: 10, font: boldFont, color: rgb(0, 0, 0) });
135
+ x += colWidths[i];
136
+ }
137
+ y -= 16;
138
+
139
+ lineLoop: for (const s of scans) {
140
+ for (const f of (s.findings || [])) {
141
+ if (y < MARGIN + 20) {
142
+ page = doc.addPage([PAGE_W, PAGE_H]);
143
+ y = PAGE_H - MARGIN;
144
+ y = drawPageHeader(page, boldFont, 'All Findings (continued)', y);
145
+ y -= 6;
146
+ }
147
+
148
+ const rowData = [
149
+ f.atk_id || f.id || '',
150
+ f.severity || '',
151
+ (f.title || '').slice(0, 30),
152
+ (f.evidence || '').slice(0, 60),
153
+ ];
154
+
155
+ let rowY = y;
156
+ let maxLines = 1;
157
+ for (let i = 0; i < rowData.length; i++) {
158
+ const lines = wrapText(rowData[i], font, 9, colWidths[i] - 4);
159
+ if (lines.length > maxLines) maxLines = lines.length;
160
+ }
161
+
162
+ if (y - (maxLines * 11) < MARGIN) {
163
+ page = doc.addPage([PAGE_W, PAGE_H]);
164
+ y = PAGE_H - MARGIN;
165
+ y = drawPageHeader(page, boldFont, 'All Findings (continued)', y);
166
+ y -= 6;
167
+ rowY = y;
168
+ }
169
+
170
+ x = MARGIN;
171
+ for (let i = 0; i < rowData.length; i++) {
172
+ const lines = wrapText(rowData[i], font, 9, colWidths[i] - 4);
173
+ for (let j = 0; j < lines.length; j++) {
174
+ const color = i === 1 && SEV_COLORS[f.severity] ? SEV_COLORS[f.severity] : rgb(0, 0, 0);
175
+ page.drawText(lines[j], { x: x + 2, y: rowY - (j * 11) - 2, size: 9, font, color });
176
+ }
177
+ x += colWidths[i];
178
+ }
179
+
180
+ const lineY = rowY + 2;
181
+ page.drawLine({ start: { x: MARGIN, y: lineY }, end: { x: PAGE_W - MARGIN, y: lineY }, thickness: 0.5, color: rgb(0.85, 0.85, 0.85) });
182
+ y = rowY - (maxLines * 11) - 4;
183
+ }
184
+ }
185
+
186
+ // ─── Page: NIST 800-161 Compliance Matrix ───────────────────────────
187
+ page = doc.addPage([PAGE_W, PAGE_H]);
188
+ y = PAGE_H - MARGIN;
189
+ y = drawPageHeader(page, boldFont, 'NIST SP 800-161 Compliance Summary', y);
190
+ y -= 6;
191
+
192
+ const nistColWidths = [70, CONTENT_W - 180, 110];
193
+ const nistHeaders = ['Control', 'Control Title', 'Status'];
194
+ x = MARGIN;
195
+ for (let i = 0; i < nistHeaders.length; i++) {
196
+ page.drawText(nistHeaders[i], { x: x + 2, y, size: 10, font: boldFont, color: rgb(0, 0, 0) });
197
+ x += nistColWidths[i];
198
+ }
199
+ y -= 16;
200
+
201
+ const atkMap = {};
202
+ for (const s of scans) {
203
+ for (const f of (s.findings || [])) {
204
+ const key = f.atk_id || f.id;
205
+ if (!atkMap[key]) atkMap[key] = [];
206
+ atkMap[key].push(f);
207
+ }
208
+ }
209
+
210
+ for (const [atkId, { control, title }] of Object.entries(NIST_SR_MAP)) {
211
+ if (y < MARGIN + 20) {
212
+ page = doc.addPage([PAGE_W, PAGE_H]);
213
+ y = PAGE_H - MARGIN;
214
+ }
215
+
216
+ const count = (atkMap[atkId] || []).length;
217
+ const status = count > 0 ? `${count} finding(s)` : 'Pass';
218
+ const statusColor = count > 0 ? rgb(0.8, 0.2, 0.2) : rgb(0.2, 0.6, 0.2);
219
+
220
+ const rowData = [control, title, status];
221
+ const rowWidths = nistColWidths;
222
+
223
+ x = MARGIN;
224
+ for (let i = 0; i < rowData.length; i++) {
225
+ const color = i === 2 ? statusColor : rgb(0, 0, 0);
226
+ const fnt = i === 0 ? boldFont : font;
227
+ page.drawText(rowData[i], { x: x + 2, y: y - 2, size: 9, font: fnt, color });
228
+ x += rowWidths[i];
229
+ }
230
+
231
+ page.drawLine({ start: { x: MARGIN, y: y + 4 }, end: { x: PAGE_W - MARGIN, y: y + 4 }, thickness: 0.5, color: rgb(0.85, 0.85, 0.85) });
232
+ y -= 18;
233
+ }
234
+
235
+ // Footer
236
+ const pages = doc.getPages();
237
+ for (const p of pages) {
238
+ const { width } = p.getSize();
239
+ p.drawText(`npm-scan v${version} | Apache-2.0 + Commons Clause`, {
240
+ x: MARGIN, y: 20, size: 8, font, color: rgb(0.6, 0.6, 0.6),
241
+ });
242
+ }
243
+
244
+ return doc.save();
245
+ }
@@ -0,0 +1,111 @@
1
+ import { readFileSync } from 'fs';
2
+ import { load as yamlLoad } from 'js-yaml';
3
+
4
+ const SEVERITY_ORDER = ['none', 'low', 'medium', 'high', 'critical'];
5
+ const VALID_SEVERITIES = new Set(SEVERITY_ORDER);
6
+
7
+ function severityIndex(s) {
8
+ return SEVERITY_ORDER.indexOf(s);
9
+ }
10
+
11
+ function loadPolicy(path) {
12
+ const raw = readFileSync(path, 'utf8').trim();
13
+ let policy;
14
+
15
+ if (path.endsWith('.json')) {
16
+ policy = JSON.parse(raw);
17
+ } else {
18
+ policy = yamlLoad(raw);
19
+ }
20
+
21
+ if (!policy || typeof policy !== 'object') {
22
+ throw new Error('Policy file must contain a valid YAML/JSON object');
23
+ }
24
+
25
+ if (policy.severity_overrides) {
26
+ for (const [atkId, severity] of Object.entries(policy.severity_overrides)) {
27
+ if (!VALID_SEVERITIES.has(severity)) {
28
+ throw new Error(`Invalid severity "${severity}" for ${atkId} — must be one of: low, medium, high, critical`);
29
+ }
30
+ }
31
+ }
32
+
33
+ if (policy.fail_on && !VALID_SEVERITIES.has(policy.fail_on)) {
34
+ throw new Error(`Invalid fail_on "${policy.fail_on}" — must be one of: none, low, medium, high, critical`);
35
+ }
36
+
37
+ if (policy.suppress) {
38
+ if (!Array.isArray(policy.suppress)) {
39
+ throw new Error('suppress must be an array');
40
+ }
41
+ for (const rule of policy.suppress) {
42
+ if (!rule.atk_id) {
43
+ throw new Error('Each suppress rule must have an atk_id');
44
+ }
45
+ }
46
+ }
47
+
48
+ if (policy.allow) {
49
+ if (policy.allow.packages && !Array.isArray(policy.allow.packages)) {
50
+ throw new Error('allow.packages must be an array');
51
+ }
52
+ }
53
+
54
+ return sanitizePolicy(policy);
55
+ }
56
+
57
+ function sanitizePolicy(policy) {
58
+ return {
59
+ allow: { packages: policy.allow?.packages ?? [] },
60
+ severity_overrides: policy.severity_overrides ?? {},
61
+ fail_on: policy.fail_on ?? 'none',
62
+ suppress: (policy.suppress ?? []).map(r => ({
63
+ atk_id: r.atk_id,
64
+ package: r.package || '*',
65
+ reason: r.reason || '',
66
+ })),
67
+ };
68
+ }
69
+
70
+ function isAllowed(packageName, policy) {
71
+ if (!policy.allow.packages.length) return false;
72
+ const nameOnly = packageName.split('@')[0];
73
+ return policy.allow.packages.some(p => p === packageName || p === nameOnly);
74
+ }
75
+
76
+ function matchesSuppressRule(finding, pkgName, rule) {
77
+ if (rule.atk_id !== (finding.atk_id || finding.id)) return false;
78
+ if (rule.package === '*') return true;
79
+ return rule.package === pkgName;
80
+ }
81
+
82
+ function applyPolicy(findings, packageName, policy) {
83
+ let filtered = [...findings];
84
+
85
+ if (policy.suppress.length) {
86
+ filtered = filtered.filter(f =>
87
+ !policy.suppress.some(r => matchesSuppressRule(f, packageName, r))
88
+ );
89
+ }
90
+
91
+ filtered = filtered.map(f => {
92
+ const override = policy.severity_overrides[f.atk_id || f.id];
93
+ if (override) {
94
+ return { ...f, severity: override, _severityOverridden: true };
95
+ }
96
+ return f;
97
+ });
98
+
99
+ const blocked = checkFailOn(filtered, policy);
100
+
101
+ return { findings: filtered, blocked };
102
+ }
103
+
104
+ function checkFailOn(findings, policy) {
105
+ if (policy.fail_on === 'none') return false;
106
+
107
+ const threshold = severityIndex(policy.fail_on);
108
+ return findings.some(f => severityIndex(f.severity) >= threshold);
109
+ }
110
+
111
+ export { loadPolicy, applyPolicy, isAllowed };
package/backend/report.js CHANGED
@@ -96,6 +96,51 @@ const NIST_SR_MAP = {
96
96
  'ATK-011': { control: 'SR-11.4', title: 'Supply chain propagation monitoring' },
97
97
  };
98
98
 
99
+ export function generateText(scans) {
100
+ const lines = [];
101
+ lines.push('npm-scan Report');
102
+ lines.push('================');
103
+ lines.push(`Generated: ${new Date().toISOString()}`);
104
+ lines.push(`Packages scanned: ${scans.length}`);
105
+ lines.push('');
106
+
107
+ let totalFindings = 0;
108
+ const sevMap = { critical: 5, high: 4, medium: 3, low: 2, info: 1 };
109
+
110
+ const sevCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
111
+ const sevLabel = ['', 'info', 'low', 'medium', 'high', 'critical'];
112
+
113
+ for (const s of scans) {
114
+ const findings = s.findings || [];
115
+ totalFindings += findings.length;
116
+
117
+ const worst = findings.reduce((m, f) => Math.max(m, sevMap[f.severity] || 0), 0);
118
+ const worstLabel = sevLabel[worst] || 'clean';
119
+
120
+ lines.push(`${s.package_name}@${s.version || 'unknown'} \u2500\u2500 ${findings.length} findings (worst: ${worstLabel})`);
121
+
122
+ for (const f of findings) {
123
+ const desc = (f.description || f.title || '').slice(0, 80);
124
+ sevCounts[f.severity] = (sevCounts[f.severity] || 0) + 1;
125
+ lines.push(` ${f.atk_id || f.id} ${f.severity.padEnd(8)} ${desc}`);
126
+ }
127
+
128
+ if (!findings.length) {
129
+ lines.push(` (clean \u2014 no findings)`);
130
+ }
131
+ lines.push('');
132
+ }
133
+
134
+ lines.push('--- Severity Summary ---');
135
+ for (const sev of ['critical', 'high', 'medium', 'low']) {
136
+ lines.push(` ${sev}: ${sevCounts[sev] || 0}`);
137
+ }
138
+ lines.push(` total: ${totalFindings} findings across ${scans.length} packages`);
139
+ lines.push('');
140
+
141
+ return lines.join('\n');
142
+ }
143
+
99
144
  function generateNistTable(scans) {
100
145
  const atkMap = getAtkFindings(scans);
101
146
  let rows = '';
package/cli/cli.js CHANGED
@@ -15,7 +15,7 @@ function requirePremium(feature, licenseKey) {
15
15
  const program = new Command()
16
16
  .name('npm-scan')
17
17
  .description('npm supply chain security scanner')
18
- .version('0.5.0');
18
+ .version('0.8.0');
19
19
 
20
20
  program
21
21
  .command('scan')
@@ -23,24 +23,54 @@ program
23
23
  .argument('<target>', 'package name')
24
24
  .option('-l, --license-key <key>', 'Premium license')
25
25
  .option('--sbom [format]', 'Generate SBOM (json/xml/spdx)')
26
+ .option('-p, --policy <path>', 'Policy file (YAML/JSON)')
26
27
  .action(async (target, options) => {
27
28
  try {
29
+ const policy = options.policy
30
+ ? await import('../backend/policy.js').then(m => m.loadPolicy(options.policy))
31
+ : null;
32
+
33
+ if (policy) {
34
+ const { isAllowed } = await import('../backend/policy.js');
35
+ if (isAllowed(target, policy)) {
36
+ console.log(JSON.stringify({ scanId: null, findings: [], skipped: true, reason: `Package '${target}' is in policy allowlist` }));
37
+ return;
38
+ }
39
+ }
40
+
28
41
  const { pkgJson, jsFiles, tmpDir } = await import('../backend/fetch.js').then(m => m.fetchPackage(target));
29
42
  const findings = await import('../backend/detectors/index.js').then(m => m.runAll(pkgJson, jsFiles));
30
43
  const { saveScan } = await import('../backend/db.js');
31
44
  const scanId = saveScan(target, 'latest', findings);
32
45
 
46
+ let outputFindings = findings;
47
+ let blocked = false;
48
+
49
+ if (policy) {
50
+ const { applyPolicy } = await import('../backend/policy.js');
51
+ const result = applyPolicy(findings, target, policy);
52
+ outputFindings = result.findings;
53
+ blocked = result.blocked;
54
+ }
55
+
33
56
  if (options.sbom) {
34
57
  const { generateSBOM } = await import('../backend/sbom.js');
35
- const sbom = generateSBOM(pkgJson, findings, options.sbom === true ? 'json' : options.sbom);
58
+ const pkg = { name: target, version: pkgJson.version || 'latest' };
59
+ const sbom = generateSBOM(pkg, outputFindings, options.sbom === true ? 'json' : options.sbom);
36
60
  console.log(sbom);
37
61
  } else {
38
- console.log(JSON.stringify({scanId, findings}, null, 2));
62
+ console.log(JSON.stringify({scanId, findings: outputFindings, blocked}, null, 2));
63
+ }
64
+
65
+ if (blocked) {
66
+ console.error('Policy: scan blocked due to fail_on threshold');
67
+ process.exit(1);
39
68
  }
40
69
 
41
70
  import('../backend/fetch.js').then(m => m.cleanup(tmpDir));
42
71
  } catch (e) {
43
72
  console.error(e.message);
73
+ process.exit(1);
44
74
  }
45
75
  });
46
76
 
@@ -53,14 +83,17 @@ program
53
83
  });
54
84
 
55
85
  program
56
- .command('report')
86
+ .command('report')
57
87
  .description('Generate report')
58
88
  .option('-i, --id <id>', 'Scan ID')
59
89
  .option('--sbom [format]', 'SBOM format (json/xml/spdx)')
60
90
  .option('--html', 'HTML report')
91
+ .option('--text', 'Plain text report')
61
92
  .option('--nist', 'NIST 800-161 compliance report')
62
93
  .option('--cra', 'EU CRA compliance report')
63
94
  .option('--siem <format>', 'SIEM format (cef|ecs|sentinel|qradar)')
95
+ .option('--pdf', 'PDF report (premium)')
96
+ .option('-o, --output <path>', 'Output file path')
64
97
  .option('-l, --license-key <key>', 'Premium license')
65
98
  .action(async (options) => {
66
99
  const licenseKey = options.licenseKey || process.env.NPM_SCAN_LICENSE_KEY;
@@ -82,6 +115,16 @@ program
82
115
  requirePremium('cra', licenseKey);
83
116
  const { generateCRA } = await import('../backend/cra.js');
84
117
  console.log(generateCRA(scan ? [scan] : []));
118
+ } else if (options.pdf) {
119
+ requirePremium('nist-pdf', licenseKey);
120
+ const { generatePDF } = await import('../backend/pdf.js');
121
+ const pdfBytes = await generatePDF(scan ? [scan] : []);
122
+ const outPath = options.output || `${pkgName}-${options.id}-report.pdf`;
123
+ await import('fs').then(m => m.writeFileSync(outPath, pdfBytes));
124
+ console.log(`PDF report written to ${outPath}`);
125
+ } else if (options.text) {
126
+ const { generateText } = await import('../backend/report.js');
127
+ console.log(generateText(scan ? [scan] : []));
85
128
  } else if (options.sbom) {
86
129
  const { generateSBOM } = await import('../backend/sbom.js');
87
130
  const sbom = generateSBOM(pkg, findings, options.sbom === true ? 'json' : options.sbom);
@@ -105,6 +148,17 @@ program
105
148
  requirePremium('cra', licenseKey);
106
149
  const { generateCRA } = await import('../backend/cra.js');
107
150
  console.log(generateCRA(scansWithFindings));
151
+ } else if (options.pdf) {
152
+ requirePremium('nist-pdf', licenseKey);
153
+ const { generatePDF } = await import('../backend/pdf.js');
154
+ const pdfBytes = await generatePDF(scansWithFindings);
155
+ const date = new Date().toISOString().slice(0, 10);
156
+ const outPath = options.output || `npm-scan-report-${date}.pdf`;
157
+ await import('fs').then(m => m.writeFileSync(outPath, pdfBytes));
158
+ console.log(`PDF report written to ${outPath}`);
159
+ } else if (options.text) {
160
+ const { generateText } = await import('../backend/report.js');
161
+ console.log(generateText(scansWithFindings));
108
162
  } else if (options.html || options.nist) {
109
163
  const { generateHTML } = await import('../backend/report.js');
110
164
  const html = generateHTML(scansWithFindings);
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lateos/npm-scan",
3
- "version": "0.7.6",
4
- "description": "Powerful npm supply chain security scanner - detects malicious packages (Shai-Hulud style), behavioral analysis, SBOM, and compliance reporting.",
3
+ "version": "0.8.0",
4
+ "description": "Modern npm supply chain security scanner detects obfuscated payloads, credential stealers, conditional triggers, sandbox evasion, and worm-like propagation. 11 attack types, SBOM, NIST/EU CRA compliance reporting.",
5
5
  "main": "backend/index.js",
6
6
  "bin": {
7
7
  "npm-scan": "cli/cli.js"
@@ -10,7 +10,7 @@
10
10
  "license": "Apache-2.0",
11
11
  "repository": {
12
12
  "type": "git",
13
- "url": "https://github.com/YOUR_GITHUB_USERNAME/npm-scan.git"
13
+ "url": "https://github.com/lateos/npm-scan.git"
14
14
  },
15
15
  "keywords": [
16
16
  "npm",
@@ -37,7 +37,9 @@
37
37
  "better-sqlite3": "^11.10.0",
38
38
  "commander": "^14.0.3",
39
39
  "glob": "^13.0.6",
40
+ "js-yaml": "^4.1.1",
40
41
  "node-fetch": "^3.3.2",
42
+ "pdf-lib": "^1.17.1",
41
43
  "tar": "^7.5.15"
42
44
  }
43
45
  }
@@ -1 +0,0 @@
1
- name: CI\n\non:\n push:\n branches: [ main ]\n pull_request:\n branches: [ main ]\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: '20'\n cache: 'npm'\n - run: npm ci\n - run: npm run lint\n - run: npm run test\n - run: npm run build\n # Self-scan stub\n - run: echo 'Self-scan: npm run scan package.json' # Phase 1+
@@ -1 +0,0 @@
1
- name: npm-scan\n\non: [pull_request]\n\njobs:\n scan:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 20\n - run: npm ci\n - run: npx @lateos/npm-scan@latest scan-lockfile\n # Or: npm-scan scan-lockfile (if global)\n # Fail PR on high/critical
package/AGENTS.md DELETED
@@ -1 +0,0 @@
1
- # AGENTS.md\n\n## Project\nESM Node.js CLI monorepo for npm-scan supply chain scanner.\n\n## Verification\nNo lint/test deps yet. Scripts stubbed in package.json.\nRun `npm run lint test build`.\n\n## Architecture\n- `cli/`: Commander.js entrypoints (Phase 1)\n- `backend/`: Core logic, license.js, db/schema.sql\n- `docker/`: Multi-arch images (cli, pipeline)\n- `docs/`: project-plan.md, attack-taxonomy.md\n\nFollow project-plan.md phases/ATK.\n\n## Conventions\n- No deps—verify package.json before libs.\n- License: Apache-2.0 + Commons Clause (LICENSING.md).\n- Local git (no remote).\n- Phase 0 complete: foundation stubs ready for Phase 1 detectors.
package/CONTRIBUTING.md DELETED
@@ -1 +0,0 @@
1
- # CONTRIBUTING.md\n\nThank you for contributing to npm-scan!\n\n## Development Workflow\n\n1. Fork repo, create feature branch `feat/atk-xxx-description`.\n2. Run `npm run lint test`.\n3. Update CHANGELOG.md.\n4. PR with self-review.\n\n## New ATK Entry / Detector\n\nATK changes require:\n- PoC malicious package sample.\n- Detection rule/code.\n- False positive analysis (test corpus).\n- NIST 800-161 mapping.\n\nUpdate docs/attack-taxonomy.md; bump version.\n\n## Licensing\n\nSee [LICENSING.md](LICENSING.md). Core Apache-2.0; premium Commons Clause.\n\n## No-Go\n- ML/ telemetry until Phase 4.\n- Secrets/keys in code/PR.\n- Sandbox changes without threat model update.\n\n## Test Corpus\n\nAdd to `tests/corpus/clean/` and `malicious/` for CI.\n\nPRs reviewed in 48h.
package/action.yml DELETED
@@ -1,94 +0,0 @@
1
- name: 'npm-scan'
2
- description: 'Scan npm dependencies for supply chain threats using npm-scan'
3
- author: 'Lateos'
4
-
5
- inputs:
6
- scan-type:
7
- description: 'Scan mode: lockfile to scan package-lock.json, package to scan a specific package'
8
- default: 'lockfile'
9
- required: false
10
- package:
11
- description: 'Package name to scan (required when scan-type=package)'
12
- required: false
13
- fail-on:
14
- description: 'Fail the workflow on findings at this severity or higher (none, low, medium, high, critical)'
15
- default: 'high'
16
- required: false
17
- license-key:
18
- description: 'Premium license key for advanced features'
19
- required: false
20
- siem-format:
21
- description: 'SIEM output format (cef, ecs, sentinel, qradar)'
22
- required: false
23
- sbom-format:
24
- description: 'SBOM output format (json, xml, spdx)'
25
- required: false
26
-
27
- outputs:
28
- findings-count:
29
- description: 'Number of findings detected'
30
- value: ${{ steps.run.outputs.findings_count }}
31
- scan-id:
32
- description: 'Scan ID for later reference'
33
- value: ${{ steps.run.outputs.scan_id }}
34
-
35
- runs:
36
- using: 'composite'
37
- steps:
38
- - uses: actions/setup-node@v4
39
- with:
40
- node-version: '20'
41
-
42
- - name: Install npm-scan
43
- shell: bash
44
- run: npm install -g @lateos/npm-scan@latest
45
-
46
- - name: Run npm-scan scan
47
- id: run
48
- shell: bash
49
- env:
50
- NPM_SCAN_LICENSE_KEY: ${{ inputs.license-key || '' }}
51
- run: |
52
- set -euo pipefail
53
- if [[ "${{ inputs.scan-type }}" == "package" && -n "${{ inputs.package }}" ]]; then
54
- output=$(npm-scan scan "${{ inputs.package }}" 2>&1)
55
- else
56
- output=$(npm-scan scan-lockfile 2>&1)
57
- fi
58
- echo "$output"
59
-
60
- # Extract scan ID if present
61
- scan_id=$(echo "$output" | grep -oP '"scanId"\s*:\s*"\K[^"]+' || echo "")
62
- if [[ -n "$scan_id" ]]; then
63
- echo "scan_id=$scan_id" >> "$GITHUB_OUTPUT"
64
- # Count findings
65
- findings_count=$(echo "$output" | grep -oP '"severity"' | wc -l | tr -d ' ')
66
- echo "findings_count=$findings_count" >> "$GITHUB_OUTPUT"
67
-
68
- # Generate additional outputs
69
- if [[ -n "${{ inputs.siem-format }}" ]]; then
70
- echo "--- SIEM (${{ inputs.siem-format }}) ---"
71
- npm-scan report -i "$scan_id" --siem "${{ inputs.siem-format }}" 2>&1 || true
72
- fi
73
- if [[ -n "${{ inputs.sbom-format }}" ]]; then
74
- echo "--- SBOM (${{ inputs.sbom-format }}) ---"
75
- npm-scan report -i "$scan_id" --sbom "${{ inputs.sbom-format }}" 2>&1 || true
76
- fi
77
- fi
78
-
79
- - name: Check severity threshold
80
- shell: bash
81
- if: ${{ inputs.fail-on != 'none' }}
82
- env:
83
- FAIL_SEVERITY: ${{ inputs.fail-on }}
84
- run: |
85
- SEVERITY_ORDER="none low medium high critical"
86
- FAIL_IDX=-1
87
- for sev in $SEVERITY_ORDER; do
88
- FAIL_IDX=$((FAIL_IDX + 1))
89
- [[ "$sev" == "$FAIL_SEVERITY" ]] && break
90
- done
91
- # In a real scan, we'd parse the output for severity levels.
92
- # For now, the action logs results and defers to the scan output.
93
- echo "Fail-on threshold: $FAIL_SEVERITY (index $FAIL_IDX)"
94
- echo "Review the scan output above for any findings at or above this severity."