@lateos/npm-scan 0.7.6 → 0.9.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/.dockerignore +20 -0
- package/README.md +342 -81
- package/backend/db.js +68 -22
- package/backend/pdf.js +245 -0
- package/backend/policy.js +111 -0
- package/backend/report.js +45 -0
- package/cli/cli.js +63 -9
- package/package.json +6 -4
- package/.github/workflows/ci.yml +0 -1
- package/.github/workflows/scan.yml +0 -1
- package/AGENTS.md +0 -1
- package/CONTRIBUTING.md +0 -1
- package/action.yml +0 -94
- package/api/README.md +0 -80
- package/api/__init__.py +0 -0
- package/api/api_keys.py +0 -55
- package/api/deps.py +0 -164
- package/api/main.py +0 -44
- package/api/requirements.txt +0 -9
- package/api/routers/__init__.py +0 -0
- package/api/routers/auth.py +0 -80
- package/api/routers/health.py +0 -10
- package/api/routers/scans.py +0 -66
- package/api/routers/sso.py +0 -385
- package/api/routers/webhooks.py +0 -78
- package/api/saml-config.yaml +0 -58
- package/api/saml.py +0 -184
- package/backend/db/pg-schema.sql +0 -155
- package/backend/detectors.test.js +0 -88
- package/backend/tests.test.js +0 -294
- package/docker/Dockerfile.cli +0 -1
- package/docker/docker-compose.yml +0 -1
- package/docs/attack-taxonomy.md +0 -53
- package/docs/project-plan.md +0 -372
- package/docs/sandbox-threat-model.md +0 -91
- package/tests/corpus/run.js +0 -93
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.
|
|
18
|
+
.version('0.9.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
|
-
const scanId = saveScan(target, 'latest', findings);
|
|
44
|
+
const scanId = await saveScan(target, 'latest', findings);
|
|
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
|
+
}
|
|
32
55
|
|
|
33
56
|
if (options.sbom) {
|
|
34
57
|
const { generateSBOM } = await import('../backend/sbom.js');
|
|
35
|
-
const
|
|
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,22 +83,25 @@ program
|
|
|
53
83
|
});
|
|
54
84
|
|
|
55
85
|
program
|
|
56
|
-
|
|
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;
|
|
67
100
|
const { getRecentScans, getFindings, getScan } = await import('../backend/db.js');
|
|
68
101
|
|
|
69
102
|
if (options.id) {
|
|
70
|
-
const findings = getFindings(options.id);
|
|
71
|
-
const scanInfo = getScan(options.id);
|
|
103
|
+
const findings = await getFindings(options.id);
|
|
104
|
+
const scanInfo = await getScan(options.id);
|
|
72
105
|
const pkgName = scanInfo?.package_name || 'scan-' + options.id;
|
|
73
106
|
const pkgVer = scanInfo?.version || 'unknown';
|
|
74
107
|
const pkg = { name: pkgName, version: pkgVer };
|
|
@@ -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);
|
|
@@ -94,8 +137,8 @@ program
|
|
|
94
137
|
console.log(JSON.stringify(findings, null, 2));
|
|
95
138
|
}
|
|
96
139
|
} else {
|
|
97
|
-
const scans = getRecentScans();
|
|
98
|
-
const scansWithFindings = scans.map(s => ({ ...s, findings: getFindings(s.id) }));
|
|
140
|
+
const scans = await getRecentScans();
|
|
141
|
+
const scansWithFindings = await Promise.all(scans.map(async s => ({ ...s, findings: await getFindings(s.id) })));
|
|
99
142
|
|
|
100
143
|
if (options.siem) {
|
|
101
144
|
requirePremium('siem', licenseKey);
|
|
@@ -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);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lateos/npm-scan",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.9.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/
|
|
13
|
+
"url": "https://github.com/lateos/npm-scan.git"
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
16
|
"npm",
|
|
@@ -34,10 +34,12 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"acorn": "^8.16.0",
|
|
37
|
-
"better-sqlite3": "^11.10.0",
|
|
38
37
|
"commander": "^14.0.3",
|
|
39
38
|
"glob": "^13.0.6",
|
|
39
|
+
"js-yaml": "^4.1.1",
|
|
40
40
|
"node-fetch": "^3.3.2",
|
|
41
|
+
"pdf-lib": "^1.17.1",
|
|
42
|
+
"sql.js": "^1.11.0",
|
|
41
43
|
"tar": "^7.5.15"
|
|
42
44
|
}
|
|
43
45
|
}
|
package/.github/workflows/ci.yml
DELETED
|
@@ -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.
|