@lateos/npm-scan 0.7.4 → 0.7.6
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/README.md +2 -2
- package/action.yml +94 -0
- package/backend/siem/ecs.js +41 -0
- package/backend/siem/index.js +10 -1
- package/backend/siem/qradar.js +57 -0
- package/backend/siem/sentinel.js +28 -0
- package/backend/tests.test.js +294 -0
- package/cli/cli.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ npx @lateos/npm-scan scan lodash
|
|
|
21
21
|
- **SBOM Output** — CycloneDX 1.5 and SPDX 2.3 with findings mapped as vulnerabilities
|
|
22
22
|
- **NIST 800-161 Compliance** — HTML report includes control traceability matrix (SR-2.1 → SR-11.4)
|
|
23
23
|
- **EU CRA Compliance** — report maps findings to Cyber Resilience Act articles and Annex I requirements
|
|
24
|
-
- **SIEM Export** — CEF
|
|
24
|
+
- **SIEM Export** — Splunk CEF, Elastic ECS, Microsoft Sentinel, IBM QRadar formats (premium)
|
|
25
25
|
- **EU CRA Compliance** — report maps findings to Cyber Resilience Act articles (premium)
|
|
26
26
|
- **License Key Gating** — premium features locked behind signed license keys
|
|
27
27
|
- **REST API** — FastAPI-based API with webhooks, auth, scan management (premium)
|
|
@@ -47,7 +47,7 @@ npm-scan report -i <id> --sbom spdx Generate SPDX SBOM
|
|
|
47
47
|
npm-scan report -i <id> --html Generate HTML report (with NIST table)
|
|
48
48
|
npm-scan report -i <id> --nist Print NIST 800-161 compliance table
|
|
49
49
|
npm-scan report -i <id> --cra Print EU CRA compliance table
|
|
50
|
-
npm-scan report -i <id> --siem
|
|
50
|
+
npm-scan report -i <id> --siem <fmt> Generate SIEM output (cef|ecs|sentinel|qradar) (premium)
|
|
51
51
|
npm-scan report --html Generate HTML report for all scans
|
|
52
52
|
npm-scan report --nist Print NIST compliance for all scans
|
|
53
53
|
npm-scan report --cra Print EU CRA compliance for all scans (premium)
|
package/action.yml
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
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."
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function generateECS(scans) {
|
|
2
|
+
const events = [];
|
|
3
|
+
for (const s of scans) {
|
|
4
|
+
for (const f of (s.findings || [])) {
|
|
5
|
+
const atkId = f.atk_id || f.id;
|
|
6
|
+
const sevMap = { critical: 100, high: 80, medium: 50, low: 20 };
|
|
7
|
+
events.push({
|
|
8
|
+
'@timestamp': new Date().toISOString(),
|
|
9
|
+
event: {
|
|
10
|
+
kind: 'alert',
|
|
11
|
+
category: 'threat',
|
|
12
|
+
type: ['indicator', 'threat'],
|
|
13
|
+
action: 'npm-scan-detected',
|
|
14
|
+
severity: sevMap[f.severity] || 50,
|
|
15
|
+
},
|
|
16
|
+
message: `[${atkId}] ${f.severity.toUpperCase()}: ${f.description || f.title || 'Unknown finding'}`,
|
|
17
|
+
log: { level: f.severity },
|
|
18
|
+
observer: {
|
|
19
|
+
vendor: 'Lateos',
|
|
20
|
+
product: 'npm-scan',
|
|
21
|
+
version: process.env.npm_package_version || '0.7.0',
|
|
22
|
+
},
|
|
23
|
+
labels: {
|
|
24
|
+
package: s.package_name || 'unknown',
|
|
25
|
+
version: s.version || 'unknown',
|
|
26
|
+
atk_id: atkId,
|
|
27
|
+
severity: f.severity,
|
|
28
|
+
},
|
|
29
|
+
vulnerability: {
|
|
30
|
+
classification: 'npm-supply-chain',
|
|
31
|
+
reference: `https://npm-scan.io/atk/${atkId}`,
|
|
32
|
+
id: atkId,
|
|
33
|
+
description: f.description || f.title || null,
|
|
34
|
+
enumeration: 'ATK',
|
|
35
|
+
},
|
|
36
|
+
file: f.evidence ? { name: f.evidence } : undefined,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return events.map(e => JSON.stringify(e)).join('\n');
|
|
41
|
+
}
|
package/backend/siem/index.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { generateCEF } from './cef.js';
|
|
2
|
+
import { generateECS } from './ecs.js';
|
|
3
|
+
import { generateSentinel } from './sentinel.js';
|
|
4
|
+
import { generateQRadar } from './qradar.js';
|
|
2
5
|
|
|
3
6
|
export function generateSIEM(scans, format = 'cef') {
|
|
4
7
|
switch (format) {
|
|
5
8
|
case 'cef':
|
|
6
9
|
return generateCEF(scans);
|
|
10
|
+
case 'ecs':
|
|
11
|
+
return generateECS(scans);
|
|
12
|
+
case 'sentinel':
|
|
13
|
+
return generateSentinel(scans);
|
|
14
|
+
case 'qradar':
|
|
15
|
+
return generateQRadar(scans);
|
|
7
16
|
default:
|
|
8
|
-
throw new Error(`Unknown SIEM format: ${format}`);
|
|
17
|
+
throw new Error(`Unknown SIEM format: ${format}. Supported: cef, ecs, sentinel, qradar`);
|
|
9
18
|
}
|
|
10
19
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function generateQRadar(scans) {
|
|
2
|
+
const events = [];
|
|
3
|
+
for (const s of scans) {
|
|
4
|
+
for (const f of (s.findings || [])) {
|
|
5
|
+
const atkId = f.atk_id || f.id;
|
|
6
|
+
events.push({
|
|
7
|
+
source: 'npm-scan',
|
|
8
|
+
version: process.env.npm_package_version || '0.7.0',
|
|
9
|
+
devicetime: new Date().toISOString(),
|
|
10
|
+
devicepayload: [
|
|
11
|
+
s.package_name || 'unknown',
|
|
12
|
+
s.version || 'unknown',
|
|
13
|
+
atkId,
|
|
14
|
+
f.severity,
|
|
15
|
+
f.title || f.description || '',
|
|
16
|
+
f.evidence || '',
|
|
17
|
+
].join('\t'),
|
|
18
|
+
devicevendor: 'Lateos',
|
|
19
|
+
devicename: 'npm-scan',
|
|
20
|
+
deviceproduct: 'npm-scan',
|
|
21
|
+
atk_id: atkId,
|
|
22
|
+
severity: f.severity,
|
|
23
|
+
package_name: s.package_name || 'unknown',
|
|
24
|
+
package_version: s.version || 'unknown',
|
|
25
|
+
finding_title: f.title || f.description || '',
|
|
26
|
+
finding_description: f.description || f.title || '',
|
|
27
|
+
evidence: f.evidence || '',
|
|
28
|
+
mitigation: f.mitigation || '',
|
|
29
|
+
raw_category: 'NPM Supply Chain Threat',
|
|
30
|
+
qid: _qrQid(f.severity),
|
|
31
|
+
category: _qrCategory(f.severity),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return events.map(e => JSON.stringify(e)).join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const QID_MAP = {
|
|
39
|
+
critical: 90050001,
|
|
40
|
+
high: 90050002,
|
|
41
|
+
medium: 90050003,
|
|
42
|
+
low: 90050004,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function _qrQid(severity) {
|
|
46
|
+
return QID_MAP[severity] || 90050003;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function _qrCategory(severity) {
|
|
50
|
+
const map = {
|
|
51
|
+
critical: 'Critical Severity Malware',
|
|
52
|
+
high: 'High Severity Malware',
|
|
53
|
+
medium: 'Medium Severity Malware',
|
|
54
|
+
low: 'Low Severity Malware',
|
|
55
|
+
};
|
|
56
|
+
return map[severity] || 'Medium Severity Malware';
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function generateSentinel(scans) {
|
|
2
|
+
const events = [];
|
|
3
|
+
for (const s of scans) {
|
|
4
|
+
for (const f of (s.findings || [])) {
|
|
5
|
+
const atkId = f.atk_id || f.id;
|
|
6
|
+
events.push({
|
|
7
|
+
TimeGenerated: new Date().toISOString(),
|
|
8
|
+
Computer: process.env.COMPUTERNAME || process.env.HOSTNAME || 'npm-scan-host',
|
|
9
|
+
SourceSystem: 'npm-scan',
|
|
10
|
+
DeviceVendor: 'Lateos',
|
|
11
|
+
DeviceProduct: 'npm-scan',
|
|
12
|
+
DeviceVersion: process.env.npm_package_version || '0.7.0',
|
|
13
|
+
SeverityLevel: f.severity,
|
|
14
|
+
Severity: f.severity.toUpperCase(),
|
|
15
|
+
EventType: 'npm-supply-chain-threat',
|
|
16
|
+
ATKId: atkId,
|
|
17
|
+
FindingTitle: f.title || f.description || '',
|
|
18
|
+
FindingDescription: f.description || f.title || '',
|
|
19
|
+
Evidence: f.evidence || '',
|
|
20
|
+
PackageName: s.package_name || 'unknown',
|
|
21
|
+
PackageVersion: s.version || 'unknown',
|
|
22
|
+
Mitigation: f.mitigation || '',
|
|
23
|
+
ThreatClassification: 'npm-supply-chain',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return JSON.stringify(events, null, 2);
|
|
28
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'assert/strict';
|
|
3
|
+
|
|
4
|
+
// ─── SIEM Exporters ────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const MOCK_SCANS = [
|
|
7
|
+
{
|
|
8
|
+
package_name: 'lodash',
|
|
9
|
+
version: '4.17.21',
|
|
10
|
+
findings: [
|
|
11
|
+
{ id: 'ATK-003', atk_id: 'ATK-003', severity: 'high', title: 'Credential harvest', description: 'Scrapes env vars', evidence: 'process.env.NPM_TOKEN' },
|
|
12
|
+
{ id: 'ATK-009', severity: 'medium', title: 'Time trigger', description: 'Conditional trigger (time-based)', evidence: 'time-based trigger detected' },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
test('SIEM CEF output format', async () => {
|
|
18
|
+
const { generateCEF } = await import('./siem/cef.js');
|
|
19
|
+
const out = generateCEF(MOCK_SCANS);
|
|
20
|
+
assert(out.includes('CEF:0'), 'CEF header');
|
|
21
|
+
assert(out.includes('ATK-003'), 'ATK-003 in output');
|
|
22
|
+
assert(out.includes('ATK-009'), 'ATK-009 in output');
|
|
23
|
+
assert(out.includes('high'), 'severity in output');
|
|
24
|
+
assert(out.includes('medium'), 'severity in output');
|
|
25
|
+
const lines = out.split('\n').filter(Boolean);
|
|
26
|
+
assert.equal(lines.length, 2, '2 findings = 2 CEF lines');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('SIEM CEF with empty findings', async () => {
|
|
30
|
+
const { generateCEF } = await import('./siem/cef.js');
|
|
31
|
+
const out = generateCEF([{ package_name: 'empty', version: '1.0.0', findings: [] }]);
|
|
32
|
+
assert.equal(out, '', 'No output for empty findings');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('SIEM ECS output format', async () => {
|
|
36
|
+
const { generateECS } = await import('./siem/ecs.js');
|
|
37
|
+
const out = generateECS(MOCK_SCANS);
|
|
38
|
+
const events = out.split('\n').filter(Boolean);
|
|
39
|
+
assert.equal(events.length, 2, '2 findings = 2 JSON lines');
|
|
40
|
+
for (const line of events) {
|
|
41
|
+
const e = JSON.parse(line);
|
|
42
|
+
assert.equal(e.event.kind, 'alert', 'ECS event kind');
|
|
43
|
+
assert.equal(e.observer.vendor, 'Lateos', 'ECS observer');
|
|
44
|
+
assert(['high', 'medium'].includes(e.log.level), 'ECS log level');
|
|
45
|
+
assert(e.vulnerability.enumeration === 'ATK', 'ECS enumeration');
|
|
46
|
+
assert(e['@timestamp'], 'ECS timestamp');
|
|
47
|
+
assert(['high', 'medium'].includes(e.log.level), 'ECS log level');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('SIEM Sentinel output format', async () => {
|
|
52
|
+
const { generateSentinel } = await import('./siem/sentinel.js');
|
|
53
|
+
const out = JSON.parse(generateSentinel(MOCK_SCANS));
|
|
54
|
+
assert(Array.isArray(out));
|
|
55
|
+
assert.equal(out.length, 2, '2 findings');
|
|
56
|
+
assert(out[0].TimeGenerated, 'TimeGenerated field');
|
|
57
|
+
assert.equal(out[0].DeviceVendor, 'Lateos');
|
|
58
|
+
assert.equal(out[0].SourceSystem, 'npm-scan');
|
|
59
|
+
assert(out[0].ATKId, 'ATK-003');
|
|
60
|
+
assert(out[0].PackageName, 'lodash');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('SIEM QRadar output format', async () => {
|
|
64
|
+
const { generateQRadar } = await import('./siem/qradar.js');
|
|
65
|
+
const out = generateQRadar(MOCK_SCANS);
|
|
66
|
+
const events = out.split('\n').filter(Boolean);
|
|
67
|
+
assert.equal(events.length, 2, '2 findings');
|
|
68
|
+
const e = JSON.parse(events[0]);
|
|
69
|
+
assert(e.source, 'npm-scan');
|
|
70
|
+
assert(e.devicetime);
|
|
71
|
+
assert(e.devicepayload.includes('lodash\t4.17.21'));
|
|
72
|
+
assert(e.qid === 90050002, 'high severity QID');
|
|
73
|
+
assert(e.category.includes('High Severity'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('SIEM QRadar severity QID mapping', async () => {
|
|
77
|
+
const { generateQRadar } = await import('./siem/qradar.js');
|
|
78
|
+
const scans = [
|
|
79
|
+
{ package_name: 't', version: '1', findings: [
|
|
80
|
+
{ id: 'ATK-001', severity: 'critical', title: 'c' },
|
|
81
|
+
{ id: 'ATK-002', severity: 'low', title: 'l' },
|
|
82
|
+
]},
|
|
83
|
+
];
|
|
84
|
+
const out = generateQRadar(scans).split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
85
|
+
assert.equal(out[0].qid, 90050001, 'critical QID');
|
|
86
|
+
assert.equal(out[1].qid, 90050004, 'low QID');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('SIEM index.js routes all formats', async () => {
|
|
90
|
+
const { generateSIEM } = await import('./siem/index.js');
|
|
91
|
+
const outCef = generateSIEM(MOCK_SCANS, 'cef');
|
|
92
|
+
assert(outCef.includes('CEF:0'), 'cef routing');
|
|
93
|
+
const outEcs = generateSIEM(MOCK_SCANS, 'ecs');
|
|
94
|
+
assert(outEcs.includes('"kind":"alert"'), 'ecs routing');
|
|
95
|
+
const outSentinel = generateSIEM(MOCK_SCANS, 'sentinel');
|
|
96
|
+
assert(outSentinel.includes('TimeGenerated'), 'sentinel routing');
|
|
97
|
+
const outQradar = generateSIEM(MOCK_SCANS, 'qradar');
|
|
98
|
+
assert(outQradar.includes('qid'), 'qradar routing');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('SIEM index.js throws on unknown format', async () => {
|
|
102
|
+
const { generateSIEM } = await import('./siem/index.js');
|
|
103
|
+
assert.throws(() => generateSIEM(MOCK_SCANS, 'unknown'), /unknown.*format/i);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ─── EU CRA Compliance Report ──────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
test('EU CRA report generates HTML table', async () => {
|
|
109
|
+
const { generateCRA } = await import('./cra.js');
|
|
110
|
+
const out = generateCRA(MOCK_SCANS);
|
|
111
|
+
assert(out.includes('<h2>EU CRA Compliance Summary</h2>'), 'CRA heading');
|
|
112
|
+
assert(out.includes('<table>'), 'HTML table');
|
|
113
|
+
assert(out.includes('ATK-003'), 'ATK-003 mapped');
|
|
114
|
+
assert(out.includes('ATK-009'), 'ATK-009 mapped');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('EU CRA report full HTML wrapper', async () => {
|
|
118
|
+
const { generateCRAHTML } = await import('./cra.js');
|
|
119
|
+
const out = generateCRAHTML(MOCK_SCANS);
|
|
120
|
+
assert(out.includes('<!DOCTYPE html>'), 'DOCTYPE');
|
|
121
|
+
assert(out.includes('EU CRA Compliance Report'), 'title');
|
|
122
|
+
assert(out.includes('Cyber Resilience Act'), 'CRA reference');
|
|
123
|
+
assert(out.includes('ATK-003'), 'ATK finding');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('EU CRA report empty scans', async () => {
|
|
127
|
+
const { generateCRA } = await import('./cra.js');
|
|
128
|
+
const out = generateCRA([]);
|
|
129
|
+
assert(out.includes('No findings'), 'Empty scans show no findings');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ─── SBOM Generation ───────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
test('SBOM CycloneDX output', async () => {
|
|
135
|
+
const { generateSBOM } = await import('./sbom.js');
|
|
136
|
+
const pkg = { name: 'test-pkg', version: '1.0.0' };
|
|
137
|
+
const findings = [
|
|
138
|
+
{ id: 'ATK-001', atk_id: 'ATK-001', severity: 'high', title: 'Lifecycle script', description: 'preinstall hook' },
|
|
139
|
+
];
|
|
140
|
+
const out = JSON.parse(generateSBOM(pkg, findings, 'json'));
|
|
141
|
+
assert.equal(out.bomFormat, 'CycloneDX');
|
|
142
|
+
assert.equal(out.specVersion, '1.5');
|
|
143
|
+
assert.equal(out.metadata.component.name, 'test-pkg');
|
|
144
|
+
assert.equal(out.vulnerabilities.length, 1);
|
|
145
|
+
assert.equal(out.vulnerabilities[0].id, 'ATK-001');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('SBOM SPDX output', async () => {
|
|
149
|
+
const { generateSBOM } = await import('./sbom.js');
|
|
150
|
+
const pkg = { name: 'spdx-pkg', version: '2.0.0' };
|
|
151
|
+
const findings = [
|
|
152
|
+
{ id: 'ATK-002', atk_id: 'ATK-002', severity: 'medium', title: 'Obfuscation', description: 'eval detected' },
|
|
153
|
+
];
|
|
154
|
+
const out = JSON.parse(generateSBOM(pkg, findings, 'spdx'));
|
|
155
|
+
assert.equal(out.spdxVersion, 'SPDX-2.3');
|
|
156
|
+
assert.equal(out.dataLicense, 'CC0-1.0');
|
|
157
|
+
assert(out.name.includes('spdx-pkg'));
|
|
158
|
+
assert.equal(out.packages.length, 1);
|
|
159
|
+
assert.equal(out.packages[0].name, 'spdx-pkg');
|
|
160
|
+
assert.equal(out.annotations.length, 1);
|
|
161
|
+
assert(out.annotations[0].comment.includes('ATK-002'));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('SBOM with no findings', async () => {
|
|
165
|
+
const { generateSBOM } = await import('./sbom.js');
|
|
166
|
+
const pkg = { name: 'clean', version: '1.0.0' };
|
|
167
|
+
const jsonOut = JSON.parse(generateSBOM(pkg, [], 'json'));
|
|
168
|
+
assert.equal(jsonOut.vulnerabilities.length, 0);
|
|
169
|
+
const spdxOut = JSON.parse(generateSBOM(pkg, [], 'spdx'));
|
|
170
|
+
assert.equal(spdxOut.annotations.length, 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ─── License Key Validation ────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
test('license generateKey produces valid format', async () => {
|
|
176
|
+
const m = await import('./license.js');
|
|
177
|
+
const key = m.generateKey('premium');
|
|
178
|
+
assert(key.startsWith('npm-scan-premium-'), 'premium key prefix');
|
|
179
|
+
assert(key.includes('.'), 'contains signature separator');
|
|
180
|
+
const parts = key.split('.');
|
|
181
|
+
assert.equal(parts.length, 2, 'payload.signature');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('license validateLicense community features', async () => {
|
|
185
|
+
const m = await import('./license.js');
|
|
186
|
+
const result = m.validateLicense('any-key', 'scan');
|
|
187
|
+
assert.equal(result.edition, 'community');
|
|
188
|
+
assert(Array.isArray(result.features));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('license validateLicense with valid premium key', async () => {
|
|
192
|
+
const m = await import('./license.js');
|
|
193
|
+
const key = m.generateKey('premium');
|
|
194
|
+
const result = m.validateLicense(key, 'siem');
|
|
195
|
+
assert.equal(result.edition, 'premium');
|
|
196
|
+
assert(result.features.includes('siem'));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('license validateLicense enterprise key', async () => {
|
|
200
|
+
const m = await import('./license.js');
|
|
201
|
+
const key = m.generateKey('enterprise', { org: 'test-corp', seats: 10 });
|
|
202
|
+
const result = m.validateLicense(key, 'sso');
|
|
203
|
+
assert.equal(result.edition, 'enterprise');
|
|
204
|
+
assert.equal(result.org, 'test-corp');
|
|
205
|
+
assert.equal(result.seats, 10);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('license isFeatureEnabled with valid key', async () => {
|
|
209
|
+
const m = await import('./license.js');
|
|
210
|
+
const key = m.generateKey('premium');
|
|
211
|
+
assert.equal(m.isFeatureEnabled('siem', key), true);
|
|
212
|
+
assert.equal(m.isFeatureEnabled('cra', key), true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('license isFeatureEnabled enterprise-only feature blocked on premium', async () => {
|
|
216
|
+
const m = await import('./license.js');
|
|
217
|
+
const premiumKey = m.generateKey('premium');
|
|
218
|
+
assert.equal(m.isFeatureEnabled('sso', premiumKey), false);
|
|
219
|
+
const enterpriseKey = m.generateKey('enterprise');
|
|
220
|
+
assert.equal(m.isFeatureEnabled('sso', enterpriseKey), true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('license reject invalid key format', async () => {
|
|
224
|
+
const m = await import('./license.js');
|
|
225
|
+
assert.throws(() => m.validateLicense('not-a-valid-key'), /invalid.*format/i);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('license reject tampered key', async () => {
|
|
229
|
+
const m = await import('./license.js');
|
|
230
|
+
const key = m.generateKey('premium');
|
|
231
|
+
const parts = key.split('.');
|
|
232
|
+
const tampered = 'npm-scan-community-AAAA.' + parts[1];
|
|
233
|
+
assert.throws(() => m.validateLicense(tampered, 'siem'), /invalid/i);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('license reject expired key', async () => {
|
|
237
|
+
const m = await import('./license.js');
|
|
238
|
+
const key = m.generateKey('premium', { expiresAt: '2020-01-01T00:00:00Z' });
|
|
239
|
+
assert.throws(() => m.validateLicense(key, 'siem'), /expired/i);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ─── Report / NIST Compliance ──────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
test('generateHTML produces valid HTML', async () => {
|
|
245
|
+
const { generateHTML } = await import('./report.js');
|
|
246
|
+
const html = generateHTML(MOCK_SCANS);
|
|
247
|
+
assert(html.includes('<!DOCTYPE html>'), 'DOCTYPE');
|
|
248
|
+
assert(html.includes('npm-scan Report'), 'title');
|
|
249
|
+
assert(html.includes('ATK-003'), 'finding in HTML');
|
|
250
|
+
assert(html.includes('ATK-009'), 'finding in HTML');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('generateHTML NIST compliance table', async () => {
|
|
254
|
+
const { generateHTML } = await import('./report.js');
|
|
255
|
+
const html = generateHTML(MOCK_SCANS);
|
|
256
|
+
assert(html.includes('NIST SP 800-161'), 'NIST section');
|
|
257
|
+
assert(html.includes('SR-3.1'), 'NIST SR-3.1 for ATK-001');
|
|
258
|
+
assert(html.includes('SR-5.3'), 'NIST SR-5.3 for ATK-003');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('generateHTML summary badges', async () => {
|
|
262
|
+
const { generateHTML } = await import('./report.js');
|
|
263
|
+
const html = generateHTML(MOCK_SCANS);
|
|
264
|
+
assert(html.includes('class="badge high"'), 'high badge');
|
|
265
|
+
assert(html.includes('class="badge medium"'), 'medium badge');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('report with no findings shows clean', async () => {
|
|
269
|
+
const { generateHTML } = await import('./report.js');
|
|
270
|
+
const html = generateHTML([{ package_name: 'clean-pkg', version: '1.0.0', findings: [] }]);
|
|
271
|
+
assert(html.includes('clean-pkg'));
|
|
272
|
+
assert(html.includes('clean'));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('NIST table maps all ATK-001 through ATK-011', async () => {
|
|
276
|
+
const { generateHTML } = await import('./report.js');
|
|
277
|
+
const allAtkScans = [
|
|
278
|
+
{ package_name: 'p', version: '1', findings: [
|
|
279
|
+
...Array.from({ length: 11 }, (_, i) => ({
|
|
280
|
+
id: `ATK-${String(i + 1).padStart(3, '0')}`,
|
|
281
|
+
atk_id: `ATK-${String(i + 1).padStart(3, '0')}`,
|
|
282
|
+
severity: 'medium',
|
|
283
|
+
title: `ATK-${i + 1}`,
|
|
284
|
+
})),
|
|
285
|
+
]},
|
|
286
|
+
];
|
|
287
|
+
const html = generateHTML(allAtkScans);
|
|
288
|
+
for (let i = 1; i <= 11; i++) {
|
|
289
|
+
const id = `ATK-${String(i).padStart(3, '0')}`;
|
|
290
|
+
assert(html.includes(id), `${id} in NIST table`);
|
|
291
|
+
}
|
|
292
|
+
assert(html.includes('SR-3.1'), 'SR-3.1 for ATK-001');
|
|
293
|
+
assert(html.includes('SR-11.4'), 'SR-11.4 for ATK-011');
|
|
294
|
+
});
|
package/cli/cli.js
CHANGED
|
@@ -60,7 +60,7 @@ program
|
|
|
60
60
|
.option('--html', 'HTML report')
|
|
61
61
|
.option('--nist', 'NIST 800-161 compliance report')
|
|
62
62
|
.option('--cra', 'EU CRA compliance report')
|
|
63
|
-
.option('--siem <format>', 'SIEM format (cef)')
|
|
63
|
+
.option('--siem <format>', 'SIEM format (cef|ecs|sentinel|qradar)')
|
|
64
64
|
.option('-l, --license-key <key>', 'Premium license')
|
|
65
65
|
.action(async (options) => {
|
|
66
66
|
const licenseKey = options.licenseKey || process.env.NPM_SCAN_LICENSE_KEY;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lateos/npm-scan",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.6",
|
|
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": {
|