@lateos/npm-scan 0.9.7 → 0.10.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/CHANGELOG.md +200 -0
- package/README.de.md +52 -0
- package/README.fr.md +52 -0
- package/README.ja.md +48 -0
- package/README.md +88 -0
- package/README.zh.md +52 -0
- package/SECURITY.md +73 -0
- package/backend/fetch.js +100 -1
- package/backend/report.js +97 -0
- package/cli/cli.js +165 -9
- package/deploy/helm/npm-scan/Chart.yaml +11 -5
- package/deploy/helm/npm-scan/templates/api.yaml +29 -1
- package/deploy/helm/npm-scan/values.byoc.yaml +75 -0
- package/deploy/helm/npm-scan/values.yaml +32 -2
- package/package.json +1 -1
package/SECURITY.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
Only the **latest published minor version** on npm receives security patches. Keep `@lateos/npm-scan` up to date:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm update -g @lateos/npm-scan
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| Version | Supported |
|
|
12
|
+
|---------|-----------|
|
|
13
|
+
| 0.9.x | ✅ Active |
|
|
14
|
+
| < 0.9 | ❌ |
|
|
15
|
+
|
|
16
|
+
## Reporting a Vulnerability
|
|
17
|
+
|
|
18
|
+
Use **GitHub Private Vulnerability Reporting**:
|
|
19
|
+
|
|
20
|
+
1. Go to [github.com/lateos-ai/npm-scan/security/advisories/new](https://github.com/lateos-ai/npm-scan/security/advisories/new)
|
|
21
|
+
2. Describe the vulnerability in detail (ideally with a proof of concept)
|
|
22
|
+
3. Allow **72 hours** for an initial acknowledgment
|
|
23
|
+
|
|
24
|
+
For encrypted follow-up outside of GitHub, use our PGP key:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
Fingerprint: 1BC6 998B 879B BDE0 D778 629E D9CF F5EF 1F7C 557B
|
|
28
|
+
Key ID: 1F7C557B
|
|
29
|
+
Email: leo@lateos.ai
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Scope
|
|
33
|
+
|
|
34
|
+
**In scope:**
|
|
35
|
+
- Detector logic (ATK-001 through ATK-011)
|
|
36
|
+
- Code execution in the scanner engine (`backend/fetch.js`, `cli/cli.js`)
|
|
37
|
+
- CI/CD pipeline and publish process (provenance bypass, supply chain)
|
|
38
|
+
- Configuration injection via `policy.yaml` or command-line flags
|
|
39
|
+
|
|
40
|
+
**Out of scope:**
|
|
41
|
+
- CVEs in third-party dependencies — report upstream
|
|
42
|
+
- Vulnerabilities in the npm registry itself — report to npm
|
|
43
|
+
- Malicious packages detected by the scanner (that's working as designed)
|
|
44
|
+
|
|
45
|
+
## Security Practices
|
|
46
|
+
|
|
47
|
+
`@lateos/npm-scan` follows these practices to protect its own supply chain:
|
|
48
|
+
|
|
49
|
+
- **Sigstore provenance** on every npm publish — verifiable via `npm view @lateos/npm-scan provenance`
|
|
50
|
+
- **Self-scanning in CI** — every commit scans the project's own `package-lock.json` for the full ATK taxonomy
|
|
51
|
+
- **SBOM per release** — CycloneDX and SPDX 2.3 Bill of Materials published with every version
|
|
52
|
+
- **2FA** enforced on the npm publisher account
|
|
53
|
+
- **Docker multi-arch images** signed and pushed via CI, not manually
|
|
54
|
+
- **All code public** — no security-by-obscurity
|
|
55
|
+
|
|
56
|
+
## Self-Scanning
|
|
57
|
+
|
|
58
|
+
As a supply chain security scanner, `@lateos/npm-scan` dogfoods its own detectors. Every CI run executes:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx @lateos/npm-scan scan-lockfile --fail-on medium
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
If a future update to a dependency triggers one of our detectors (e.g., typosquat, obfuscated lifecycle script), the build **fails** before the change reaches npm.
|
|
65
|
+
|
|
66
|
+
## Safe Harbor
|
|
67
|
+
|
|
68
|
+
We consider security research conducted under this policy as authorized and will not pursue legal action against researchers who:
|
|
69
|
+
|
|
70
|
+
- Report vulnerabilities through GitHub Private Vulnerability Reporting
|
|
71
|
+
- Do not access or modify user data beyond what's necessary to demonstrate the vulnerability
|
|
72
|
+
- Do not exploit the vulnerability beyond demonstrating it
|
|
73
|
+
- Act in good faith to improve the security of the project
|
package/backend/fetch.js
CHANGED
|
@@ -6,7 +6,18 @@ import zlib from 'zlib';
|
|
|
6
6
|
import { Readable } from 'stream';
|
|
7
7
|
import { pipeline } from 'stream/promises';
|
|
8
8
|
|
|
9
|
-
export async function fetchPackage(target) {
|
|
9
|
+
export async function fetchPackage(target, options = {}) {
|
|
10
|
+
const { cacheDir, cacheTTL = 604800, cacheMaxSize = 1000000000 } = options;
|
|
11
|
+
|
|
12
|
+
// Check cache if enabled
|
|
13
|
+
if (cacheDir) {
|
|
14
|
+
const cached = getFromCache(cacheDir, target, cacheTTL);
|
|
15
|
+
if (cached) {
|
|
16
|
+
const tmpDir = path.join(os.tmpdir(), 'npm-scan-cache-' + Date.now());
|
|
17
|
+
return { ...(await extractTarball(cached, tmpDir)), meta: null };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
const metaRes = await fetch(`https://registry.npmjs.org/${target}/latest`);
|
|
11
22
|
const meta = await metaRes.json();
|
|
12
23
|
|
|
@@ -19,7 +30,95 @@ export async function fetchPackage(target) {
|
|
|
19
30
|
const buffer = Buffer.from(await tarRes.arrayBuffer());
|
|
20
31
|
if (buffer.length > 500 * 1024 * 1024) throw new Error('Tarball too large');
|
|
21
32
|
|
|
33
|
+
// Save to cache if enabled
|
|
34
|
+
if (cacheDir) {
|
|
35
|
+
saveToCache(cacheDir, target, buffer, cacheTTL, cacheMaxSize);
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
const tmpDir = path.join(os.tmpdir(), 'npm-scan-' + Date.now());
|
|
39
|
+
return { ...(await extractTarball(buffer, tmpDir)), meta };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getFromCache(cacheDir, target, ttl) {
|
|
43
|
+
const cachePath = path.join(cacheDir, `${target.replace('/', '-')}.tgz`);
|
|
44
|
+
const metaPath = path.join(cacheDir, `${target.replace('/', '-')}.meta.json`);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.existsSync(cachePath) || !fs.existsSync(metaPath)) return null;
|
|
48
|
+
|
|
49
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
50
|
+
const age = (Date.now() - meta.timestamp) / 1000;
|
|
51
|
+
|
|
52
|
+
if (age > ttl) {
|
|
53
|
+
fs.unlinkSync(cachePath);
|
|
54
|
+
fs.unlinkSync(metaPath);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return fs.readFileSync(cachePath);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveToCache(cacheDir, target, buffer, ttl, maxSize) {
|
|
65
|
+
try {
|
|
66
|
+
if (!fs.existsSync(cacheDir)) {
|
|
67
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Prune if needed
|
|
71
|
+
pruneCache(cacheDir, maxSize);
|
|
72
|
+
|
|
73
|
+
const safeName = target.replace('/', '-');
|
|
74
|
+
const cachePath = path.join(cacheDir, `${safeName}.tgz`);
|
|
75
|
+
const metaPath = path.join(cacheDir, `${safeName}.meta.json`);
|
|
76
|
+
|
|
77
|
+
fs.writeFileSync(cachePath, buffer);
|
|
78
|
+
fs.writeFileSync(metaPath, JSON.stringify({ timestamp: Date.now(), size: buffer.length }));
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// Cache write failure - continue without caching
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pruneCache(cacheDir, maxSize) {
|
|
85
|
+
try {
|
|
86
|
+
const files = fs.readdirSync(cacheDir).filter(f => f.endsWith('.meta.json'));
|
|
87
|
+
let totalSize = 0;
|
|
88
|
+
const fileInfos = [];
|
|
89
|
+
|
|
90
|
+
for (const f of files) {
|
|
91
|
+
const meta = JSON.parse(fs.readFileSync(path.join(cacheDir, f), 'utf8'));
|
|
92
|
+
const tarFile = f.replace('.meta.json', '.tgz');
|
|
93
|
+
const size = meta.size || 0;
|
|
94
|
+
totalSize += size;
|
|
95
|
+
fileInfos.push({ tarFile, metaFile: f, timestamp: meta.timestamp, size });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (totalSize > maxSize) {
|
|
99
|
+
// Sort by oldest first and remove until under limit
|
|
100
|
+
fileInfos.sort((a, b) => a.timestamp - b.timestamp);
|
|
101
|
+
for (const info of fileInfos) {
|
|
102
|
+
if (totalSize <= maxSize * 0.8) break; // Leave 20% margin
|
|
103
|
+
try {
|
|
104
|
+
fs.unlinkSync(path.join(cacheDir, info.tarFile));
|
|
105
|
+
fs.unlinkSync(path.join(cacheDir, info.metaFile));
|
|
106
|
+
totalSize -= info.size;
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Prune failure - ignore
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function scanLocalTarball(filePath) {
|
|
116
|
+
const buffer = fs.readFileSync(filePath);
|
|
117
|
+
const tmpDir = path.join(os.tmpdir(), 'npm-scan-local-' + Date.now());
|
|
118
|
+
return await extractTarball(buffer, tmpDir);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function extractTarball(buffer, tmpDir) {
|
|
23
122
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
24
123
|
|
|
25
124
|
const stream = Readable.from(buffer);
|
package/backend/report.js
CHANGED
|
@@ -155,4 +155,101 @@ function generateNistTable(scans) {
|
|
|
155
155
|
<thead><tr><th>NIST Control</th><th>Control Title</th><th>Status</th><th>ATK ID</th></tr></thead>
|
|
156
156
|
<tbody>${rows}</tbody>
|
|
157
157
|
</table>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function generateSARIF(scan, format = 'json') {
|
|
161
|
+
const findings = scan.findings || [];
|
|
162
|
+
const runs = [{
|
|
163
|
+
tool: {
|
|
164
|
+
driver: {
|
|
165
|
+
name: 'npm-scan',
|
|
166
|
+
version: '0.9.7',
|
|
167
|
+
informationUri: 'https://github.com/lateos-ai/npm-scan',
|
|
168
|
+
rules: Array.from(new Set(findings.map(f => f.id))).map(id => ({
|
|
169
|
+
id,
|
|
170
|
+
name: `ATK-${id.replace('ATK-', '')}`,
|
|
171
|
+
shortDescription: { text: findings.find(f => f.id === id)?.title || id },
|
|
172
|
+
fullDescription: { text: findings.find(f => f.id === id)?.description || '' },
|
|
173
|
+
defaultConfiguration: { enabled: true }
|
|
174
|
+
}))
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
results: findings.map(f => {
|
|
178
|
+
const severityMap = { critical: 'error', high: 'error', medium: 'warning', low: 'note' };
|
|
179
|
+
return {
|
|
180
|
+
ruleId: f.id,
|
|
181
|
+
level: severityMap[f.severity] || 'note',
|
|
182
|
+
message: { text: f.description || f.title },
|
|
183
|
+
locations: [{
|
|
184
|
+
physicalLocation: {
|
|
185
|
+
artifactLocation: { uri: f.evidence || 'unknown' },
|
|
186
|
+
region: { startLine: 1, startColumn: 1 }
|
|
187
|
+
}
|
|
188
|
+
}]
|
|
189
|
+
};
|
|
190
|
+
})
|
|
191
|
+
}];
|
|
192
|
+
|
|
193
|
+
const sarif = {
|
|
194
|
+
version: '2.1.0',
|
|
195
|
+
schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
196
|
+
runs
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return format === 'pretty' ? JSON.stringify(sarif, null, 2) : JSON.stringify(sarif);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function generateCSV(scans) {
|
|
203
|
+
const headers = 'id,severity,title,description,evidence,package_name,version\n';
|
|
204
|
+
const rows = (scans || []).flatMap(s =>
|
|
205
|
+
(s.findings || []).map(f => [
|
|
206
|
+
f.id,
|
|
207
|
+
f.severity || '',
|
|
208
|
+
(f.title || '').replace(/,/g, ';'),
|
|
209
|
+
(f.description || '').replace(/,/g, ';'),
|
|
210
|
+
(f.evidence || '').replace(/,/g, ';'),
|
|
211
|
+
s.package_name || '',
|
|
212
|
+
s.version || ''
|
|
213
|
+
].join(','))
|
|
214
|
+
).join('\n');
|
|
215
|
+
return headers + rows;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function calculateRiskScore(findings, totalPackages = 1) {
|
|
219
|
+
const weights = { low: 1, medium: 3, high: 7, critical: 10 };
|
|
220
|
+
const rawScore = findings.reduce((sum, f) => sum + (weights[f.severity] || 0), 0) / totalPackages;
|
|
221
|
+
return Math.min(rawScore, 10).toFixed(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const STIG_MAP = {
|
|
225
|
+
'SRG-APP-000141': { title: 'Application Malware Detection', atk: 'ATK-001', desc: 'Lifecycle script detection' },
|
|
226
|
+
'SRG-APP-000142': { title: 'Application Code Obfuscation', atk: 'ATK-002', desc: 'Obfuscated payload detection' },
|
|
227
|
+
'SRG-APP-000143': { title: 'Credential Harvesting', atk: 'ATK-003', desc: 'Credential exfiltration detection' },
|
|
228
|
+
'SRG-APP-000144': { title: 'Persistence Mechanisms', atk: 'ATK-004', desc: 'Malicious persistence detection' },
|
|
229
|
+
'SRG-APP-000145': { title: 'Data Exfiltration', atk: 'ATK-005', desc: 'Network exfiltration detection' },
|
|
230
|
+
'SRG-APP-000146': { title: 'Dependency Confusion', atk: 'ATK-006', desc: 'Internal package detection' },
|
|
231
|
+
'SRG-APP-000147': { title: 'Typosquatting', atk: 'ATK-007', desc: 'Malicious package name detection' },
|
|
232
|
+
'SRG-APP-000148': { title: 'Tarball Tampering', atk: 'ATK-008', desc: 'Modified package detection' },
|
|
233
|
+
'SRG-APP-000149': { title: 'Dormant Triggers', atk: 'ATK-009', desc: 'Conditional execution detection' },
|
|
234
|
+
'SRG-APP-000150': { title: 'Sandbox Evasion', atk: 'ATK-010', desc: 'Environment detection evasion' },
|
|
235
|
+
'SRG-APP-000151': { title: 'Transitive Propagation', atk: 'ATK-011', desc: 'Dependency chain attacks' }
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export function generateSTIG(scans) {
|
|
239
|
+
const rows = [];
|
|
240
|
+
for (const [stigId, info] of Object.entries(STIG_MAP)) {
|
|
241
|
+
const findings = scans.flatMap(s => (s.findings || []).filter(f => f.id === info.atk));
|
|
242
|
+
const status = findings.length > 0 ? 'NOT APPLICABLE' : 'COMPLETE';
|
|
243
|
+
const findingsList = findings.map(f => `${f.severity.toUpperCase()}: ${f.title}`).join('; ') || 'None';
|
|
244
|
+
rows.push(`| ${stigId} | ${info.title} | ${status} | ${findingsList} |`);
|
|
245
|
+
}
|
|
246
|
+
return `# STIG Compliance Report
|
|
247
|
+
Generated: ${new Date().toISOString()}
|
|
248
|
+
|
|
249
|
+
| STIG ID | Control Title | Status | Findings |
|
|
250
|
+
|---------|--------------|--------|----------|
|
|
251
|
+
${rows.join('\n')}
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
*This report maps application security controls to DISA STIG requirements.*`;
|
|
158
255
|
}
|
package/cli/cli.js
CHANGED
|
@@ -15,51 +15,124 @@ 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.9.
|
|
18
|
+
.version('0.9.7');
|
|
19
19
|
|
|
20
20
|
program
|
|
21
21
|
.command('scan')
|
|
22
22
|
.description('Scan a package')
|
|
23
|
-
.argument('
|
|
23
|
+
.argument('[target]', 'package name')
|
|
24
|
+
.option('-f, --file <path>', 'local tarball path')
|
|
24
25
|
.option('-l, --license-key <key>', 'Premium license')
|
|
25
26
|
.option('--sbom [format]', 'Generate SBOM (json/xml/spdx)')
|
|
26
27
|
.option('-p, --policy <path>', 'Policy file (YAML/JSON)')
|
|
28
|
+
.option('--fail-on <level>', 'Exit with code 1 if findings >= level (low|medium|high|critical)', 'none')
|
|
29
|
+
.option('--sarif [file]', 'Output SARIF v2.1 format to file or stdout')
|
|
30
|
+
.option('--csv [file]', 'Output CSV format to file or stdout')
|
|
31
|
+
.option('--score-only', 'Output only the risk score (0-10)')
|
|
32
|
+
.option('--audit-log <file>', 'Append scan record to immutable audit log (JSONL format)')
|
|
33
|
+
.option('--fips', 'Enable FIPS 140-2/3 crypto mode (requires FIPS-enabled Node.js)')
|
|
34
|
+
.option('--cache-dir <path>', 'Cache directory for offline/air-gapped scans')
|
|
35
|
+
.option('--cache-ttl <seconds>', 'Cache TTL in seconds (default: 604800 = 7 days)', '604800')
|
|
36
|
+
.option('--cache-size <bytes>', 'Max cache size in bytes (default: 1GB)', '1000000000')
|
|
27
37
|
.action(async (target, options) => {
|
|
28
38
|
try {
|
|
39
|
+
if (options.fips) {
|
|
40
|
+
process.env.NODE_OPTIONS = (process.env.NODE_OPTIONS || '') + ' --enable-fips';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fetchOptions = {
|
|
44
|
+
cacheDir: options.cacheDir,
|
|
45
|
+
cacheTTL: parseInt(options.cacheTtl || '604800'),
|
|
46
|
+
cacheMaxSize: parseInt(options.cacheSize || '1000000000')
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (!target && !options.file) {
|
|
50
|
+
console.error('Error: specify a package name or --file <path>');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
const policy = options.policy
|
|
30
55
|
? await import('../backend/policy.js').then(m => m.loadPolicy(options.policy))
|
|
31
56
|
: null;
|
|
32
57
|
|
|
33
58
|
if (policy) {
|
|
34
59
|
const { isAllowed } = await import('../backend/policy.js');
|
|
35
|
-
if (isAllowed(target, policy)) {
|
|
60
|
+
if (target && isAllowed(target, policy)) {
|
|
36
61
|
console.log(JSON.stringify({ scanId: null, findings: [], skipped: true, reason: `Package '${target}' is in policy allowlist` }));
|
|
37
62
|
return;
|
|
38
63
|
}
|
|
39
64
|
}
|
|
40
65
|
|
|
41
|
-
const { pkgJson, jsFiles, tmpDir } =
|
|
66
|
+
const { pkgJson, jsFiles, tmpDir } = options.file
|
|
67
|
+
? await import('../backend/fetch.js').then(m => m.scanLocalTarball(options.file))
|
|
68
|
+
: await import('../backend/fetch.js').then(m => m.fetchPackage(target, fetchOptions));
|
|
69
|
+
const pkgName = target || pkgJson.name || 'unknown';
|
|
42
70
|
const findings = await import('../backend/detectors/index.js').then(m => m.runAll(pkgJson, jsFiles));
|
|
43
71
|
const { saveScan } = await import('../backend/db.js');
|
|
44
|
-
const scanId = await saveScan(
|
|
72
|
+
const scanId = await saveScan(pkgName, 'latest', findings);
|
|
45
73
|
|
|
46
74
|
let outputFindings = findings;
|
|
47
75
|
let blocked = false;
|
|
48
76
|
|
|
49
77
|
if (policy) {
|
|
50
78
|
const { applyPolicy } = await import('../backend/policy.js');
|
|
51
|
-
const result = applyPolicy(findings,
|
|
79
|
+
const result = applyPolicy(findings, pkgName, policy);
|
|
52
80
|
outputFindings = result.findings;
|
|
53
81
|
blocked = result.blocked;
|
|
54
82
|
}
|
|
55
83
|
|
|
56
|
-
|
|
84
|
+
const { calculateRiskScore } = await import('../backend/report.js');
|
|
85
|
+
const riskScore = calculateRiskScore(outputFindings);
|
|
86
|
+
|
|
87
|
+
if (options.scoreOnly) {
|
|
88
|
+
console.log(riskScore);
|
|
89
|
+
import('../backend/fetch.js').then(m => m.cleanup(tmpDir));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (options.sarif) {
|
|
94
|
+
const { generateSARIF } = await import('../backend/report.js');
|
|
95
|
+
const scan = { package_name: pkgName, version: pkgJson.version || 'latest', findings: outputFindings };
|
|
96
|
+
const sarifOutput = generateSARIF(scan);
|
|
97
|
+
if (options.sarif === true || !options.sarif) {
|
|
98
|
+
console.log(sarifOutput);
|
|
99
|
+
} else {
|
|
100
|
+
const { writeFileSync } = await import('fs');
|
|
101
|
+
writeFileSync(options.sarif, sarifOutput);
|
|
102
|
+
console.log(`SARIF output written to ${options.sarif}`);
|
|
103
|
+
}
|
|
104
|
+
} else if (options.csv) {
|
|
105
|
+
const { generateCSV } = await import('../backend/report.js');
|
|
106
|
+
const scan = { package_name: pkgName, version: pkgJson.version || 'latest', findings: outputFindings };
|
|
107
|
+
const csvOutput = generateCSV([scan]);
|
|
108
|
+
if (options.csv === true || !options.csv) {
|
|
109
|
+
console.log(csvOutput);
|
|
110
|
+
} else {
|
|
111
|
+
const { writeFileSync } = await import('fs');
|
|
112
|
+
writeFileSync(options.csv, csvOutput);
|
|
113
|
+
console.log(`CSV output written to ${options.csv}`);
|
|
114
|
+
}
|
|
115
|
+
} else if (options.sbom) {
|
|
57
116
|
const { generateSBOM } = await import('../backend/sbom.js');
|
|
58
|
-
const pkg = { name:
|
|
117
|
+
const pkg = { name: pkgName, version: pkgJson.version || 'latest' };
|
|
59
118
|
const sbom = generateSBOM(pkg, outputFindings, options.sbom === true ? 'json' : options.sbom);
|
|
60
119
|
console.log(sbom);
|
|
61
120
|
} else {
|
|
62
|
-
console.log(JSON.stringify({scanId, findings: outputFindings, blocked}, null, 2));
|
|
121
|
+
console.log(JSON.stringify({scanId, findings: outputFindings, blocked, riskScore}, null, 2));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (options.auditLog) {
|
|
125
|
+
const { writeFileSync, appendFileSync } = await import('fs');
|
|
126
|
+
const entry = {
|
|
127
|
+
timestamp: new Date().toISOString(),
|
|
128
|
+
command: `scan ${target || options.file}`,
|
|
129
|
+
package: pkgName,
|
|
130
|
+
version: pkgJson.version || 'latest',
|
|
131
|
+
riskScore,
|
|
132
|
+
findingsCount: outputFindings.length,
|
|
133
|
+
exitCode: 0
|
|
134
|
+
};
|
|
135
|
+
appendFileSync(options.auditLog, JSON.stringify(entry) + '\n');
|
|
63
136
|
}
|
|
64
137
|
|
|
65
138
|
if (blocked) {
|
|
@@ -67,6 +140,16 @@ program
|
|
|
67
140
|
process.exit(1);
|
|
68
141
|
}
|
|
69
142
|
|
|
143
|
+
if (options.failOn !== 'none') {
|
|
144
|
+
const severityLevels = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
145
|
+
const failLevel = severityLevels[options.failOn] || 0;
|
|
146
|
+
const hasBlockingFindings = outputFindings.some(f => (severityLevels[f.severity] || 0) >= failLevel);
|
|
147
|
+
if (hasBlockingFindings) {
|
|
148
|
+
console.error(`Fail: findings with severity >= ${options.failOn} detected`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
70
153
|
import('../backend/fetch.js').then(m => m.cleanup(tmpDir));
|
|
71
154
|
} catch (e) {
|
|
72
155
|
console.error(e.message);
|
|
@@ -78,6 +161,9 @@ program
|
|
|
78
161
|
.command('scan-lockfile')
|
|
79
162
|
.description('Scan package-lock.json')
|
|
80
163
|
.option('-f, --file <path>', 'lockfile path', 'package-lock.json')
|
|
164
|
+
.option('--fail-on <level>', 'Exit with code 1 if findings >= level (low|medium|high|critical)', 'none')
|
|
165
|
+
.option('--csv [file]', 'Output CSV format to file or stdout')
|
|
166
|
+
.option('--sarif [file]', 'Output SARIF v2.1 format to file or stdout')
|
|
81
167
|
.action((options) => {
|
|
82
168
|
console.log('Scanning lockfile:', options.file);
|
|
83
169
|
});
|
|
@@ -89,8 +175,10 @@ program
|
|
|
89
175
|
.option('--sbom [format]', 'SBOM format (json/xml/spdx)')
|
|
90
176
|
.option('--html', 'HTML report')
|
|
91
177
|
.option('--text', 'Plain text report')
|
|
178
|
+
.option('--csv [file]', 'CSV export to file or stdout')
|
|
92
179
|
.option('--nist', 'NIST 800-161 compliance report')
|
|
93
180
|
.option('--cra', 'EU CRA compliance report')
|
|
181
|
+
.option('--stig', 'STIG compliance report (DISA SRG-APP)')
|
|
94
182
|
.option('--siem <format>', 'SIEM format (cef|ecs|sentinel|qradar)')
|
|
95
183
|
.option('--pdf', 'PDF report (premium)')
|
|
96
184
|
.option('-o, --output <path>', 'Output file path')
|
|
@@ -133,6 +221,10 @@ program
|
|
|
133
221
|
const { generateHTML } = await import('../backend/report.js');
|
|
134
222
|
const html = generateHTML(scan ? [scan] : []);
|
|
135
223
|
console.log(html);
|
|
224
|
+
} else if (options.stig) {
|
|
225
|
+
const { generateSTIG } = await import('../backend/report.js');
|
|
226
|
+
const stig = generateSTIG(scan ? [scan] : []);
|
|
227
|
+
console.log(stig);
|
|
136
228
|
} else {
|
|
137
229
|
console.log(JSON.stringify(findings, null, 2));
|
|
138
230
|
}
|
|
@@ -163,10 +255,74 @@ program
|
|
|
163
255
|
const { generateHTML } = await import('../backend/report.js');
|
|
164
256
|
const html = generateHTML(scansWithFindings);
|
|
165
257
|
console.log(html);
|
|
258
|
+
} else if (options.stig) {
|
|
259
|
+
const { generateSTIG } = await import('../backend/report.js');
|
|
260
|
+
const stig = generateSTIG(scansWithFindings);
|
|
261
|
+
console.log(stig);
|
|
166
262
|
} else {
|
|
167
263
|
console.log('Recent scans:', JSON.stringify(scans, null, 2));
|
|
168
264
|
}
|
|
169
265
|
}
|
|
170
266
|
});
|
|
171
267
|
|
|
268
|
+
program
|
|
269
|
+
.command('serve')
|
|
270
|
+
.description('Start API server (premium feature)')
|
|
271
|
+
.option('-p, --port <port>', 'Port', '8000')
|
|
272
|
+
.option('-h, --host <host>', 'Host', '0.0.0.0')
|
|
273
|
+
.action(async (options) => {
|
|
274
|
+
const licenseKey = process.env.NPM_SCAN_LICENSE_KEY || options.licenseKey;
|
|
275
|
+
requirePremium('rest-api', licenseKey);
|
|
276
|
+
|
|
277
|
+
const { createServer } = await import('http');
|
|
278
|
+
const server = createServer(async (req, res) => {
|
|
279
|
+
const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' };
|
|
280
|
+
|
|
281
|
+
if (req.url === '/health') {
|
|
282
|
+
res.writeHead(200, headers);
|
|
283
|
+
res.end(JSON.stringify({ status: 'ok', version: program.version() }));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (req.url === '/scan' && req.method === 'POST') {
|
|
288
|
+
let body = '';
|
|
289
|
+
req.on('data', chunk => body += chunk);
|
|
290
|
+
req.on('end', async () => {
|
|
291
|
+
try {
|
|
292
|
+
const { package: pkg, options: scanOpts } = JSON.parse(body);
|
|
293
|
+
const { scan } = await import('../backend/fetch.js');
|
|
294
|
+
const results = await scan(pkg, { ...scanOpts, licenseKey });
|
|
295
|
+
res.writeHead(200, headers);
|
|
296
|
+
res.end(JSON.stringify({ results }));
|
|
297
|
+
} catch (e) {
|
|
298
|
+
res.writeHead(500, headers);
|
|
299
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (req.url.startsWith('/siem') && options.siemEnabled) {
|
|
306
|
+
requirePremium('siem', licenseKey);
|
|
307
|
+
res.writeHead(200, headers);
|
|
308
|
+
res.end(JSON.stringify({ siem: 'enabled', endpoint: process.env.SIEM_ENDPOINT }));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (req.url.startsWith('/pdf') && options.pdfEnabled) {
|
|
313
|
+
requirePremium('nist-pdf', licenseKey);
|
|
314
|
+
res.writeHead(200, headers);
|
|
315
|
+
res.end(JSON.stringify({ pdf: 'enabled' }));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
res.writeHead(404, headers);
|
|
320
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
server.listen(options.port, options.host, () => {
|
|
324
|
+
console.log(`npm-scan API server running on http://${options.host}:${options.port}`);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
172
328
|
program.parse();
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
apiVersion: v2
|
|
2
2
|
name: npm-scan
|
|
3
|
-
description: npm supply chain security scanner — Helm chart for
|
|
3
|
+
description: npm supply chain security scanner — BYOC Helm chart for enterprise/government deployments
|
|
4
4
|
type: application
|
|
5
|
-
version: 0.
|
|
6
|
-
appVersion: "0.
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
appVersion: "1.0.0"
|
|
7
7
|
keywords:
|
|
8
8
|
- npm
|
|
9
9
|
- security
|
|
10
10
|
- supply-chain
|
|
11
11
|
- scanner
|
|
12
|
+
- byoc
|
|
13
|
+
- stig
|
|
14
|
+
- fips
|
|
15
|
+
- soc2
|
|
16
|
+
- fedramp
|
|
12
17
|
sources:
|
|
13
|
-
- https://github.com/
|
|
18
|
+
- https://github.com/lateos-ai/npm-scan
|
|
14
19
|
maintainers:
|
|
15
20
|
- name: Lateos
|
|
16
|
-
email: hello@lateos.ai
|
|
21
|
+
email: hello@lateos.ai
|
|
22
|
+
dependencies: []
|
|
@@ -5,6 +5,8 @@ metadata:
|
|
|
5
5
|
labels:
|
|
6
6
|
app: {{ include "npm-scan.name" . }}-api
|
|
7
7
|
{{- include "npm-scan.labels" . | nindent 4 }}
|
|
8
|
+
annotations:
|
|
9
|
+
stig: "SRG-APP-000141"
|
|
8
10
|
spec:
|
|
9
11
|
replicas: {{ .Values.api.replicas }}
|
|
10
12
|
selector:
|
|
@@ -19,7 +21,7 @@ spec:
|
|
|
19
21
|
- name: api
|
|
20
22
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
|
21
23
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
|
22
|
-
command: ["
|
|
24
|
+
command: ["node", "cli/cli.js", "serve"]
|
|
23
25
|
ports:
|
|
24
26
|
- containerPort: {{ .Values.api.port }}
|
|
25
27
|
env:
|
|
@@ -33,6 +35,32 @@ spec:
|
|
|
33
35
|
name: {{ include "npm-scan.name" . }}-license
|
|
34
36
|
key: key
|
|
35
37
|
optional: true
|
|
38
|
+
- name: NPM_SCAN_PREMIUM
|
|
39
|
+
value: "{{ .Values.premium.enabled }}"
|
|
40
|
+
{{- if .Values.premium.byoc.enabled }}
|
|
41
|
+
- name: NPM_SCAN_BYOC
|
|
42
|
+
value: "true"
|
|
43
|
+
- name: NPM_SCAN_CLOUD_PROVIDER
|
|
44
|
+
value: "{{ .Values.premium.byoc.cloudProvider }}"
|
|
45
|
+
{{- end }}
|
|
46
|
+
{{- if .Values.siem.enabled }}
|
|
47
|
+
- name: SIEM_ENABLED
|
|
48
|
+
value: "true"
|
|
49
|
+
- name: SIEM_TYPE
|
|
50
|
+
value: "{{ .Values.siem.type }}"
|
|
51
|
+
- name: SIEM_ENDPOINT
|
|
52
|
+
value: "{{ .Values.siem.endpoint }}"
|
|
53
|
+
- name: SIEM_PORT
|
|
54
|
+
value: "{{ .Values.siem.port }}"
|
|
55
|
+
{{- end }}
|
|
56
|
+
{{- if .Values.sso.enabled }}
|
|
57
|
+
- name: SSO_ENABLED
|
|
58
|
+
value: "true"
|
|
59
|
+
- name: SSO_PROVIDER
|
|
60
|
+
value: "{{ .Values.sso.provider }}"
|
|
61
|
+
- name: SSO_ISSUER_URL
|
|
62
|
+
value: "{{ .Values.sso.issuerUrl }}"
|
|
63
|
+
{{- end }}
|
|
36
64
|
{{- if .Values.postgresql.enabled }}
|
|
37
65
|
- name: PG_HOST
|
|
38
66
|
value: "{{ .Values.postgresql.host }}"
|