@lateos/npm-scan 0.18.2 → 1.0.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +265 -233
  2. package/LICENSING.md +19 -19
  3. package/README.de.md +708 -708
  4. package/README.fr.md +707 -707
  5. package/README.ja.md +704 -704
  6. package/README.md +861 -826
  7. package/README.zh.md +708 -708
  8. package/VALIDATION.md +92 -0
  9. package/backend/cra.js +68 -68
  10. package/backend/db/pg-schema.sql +155 -0
  11. package/backend/db/schema.sql +32 -32
  12. package/backend/db.js +88 -88
  13. package/backend/detectors/atk-001-lifecycle.js +17 -17
  14. package/backend/detectors/atk-002-obfusc.js +261 -261
  15. package/backend/detectors/atk-003-creds.js +13 -13
  16. package/backend/detectors/atk-004-persist.js +13 -13
  17. package/backend/detectors/atk-005-exfil.js +13 -13
  18. package/backend/detectors/atk-006-depconf.js +14 -14
  19. package/backend/detectors/atk-007-typosquat.js +34 -34
  20. package/backend/detectors/atk-008-tarball-tamper.js +91 -91
  21. package/backend/detectors/atk-009-dormant-trigger.js +62 -62
  22. package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
  23. package/backend/detectors/atk-011-transitive-prop.js +76 -76
  24. package/backend/detectors/config/thresholds.js +66 -0
  25. package/backend/detectors/config/whitelist.json +74 -0
  26. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
  27. package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
  28. package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
  29. package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
  30. package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
  31. package/backend/detectors/hf-impersonation/index.js +396 -396
  32. package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
  33. package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
  34. package/backend/detectors/hf-impersonation/simhash.js +46 -46
  35. package/backend/detectors/index.js +87 -81
  36. package/backend/detectors/lib/ast-patterns.js +21 -0
  37. package/backend/detectors/lib/entropy-analyzer.js +24 -0
  38. package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
  39. package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
  40. package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
  41. package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
  42. package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
  43. package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
  44. package/backend/detectors/megalodon/index.js +80 -80
  45. package/backend/detectors/megalodon/types.js +9 -9
  46. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
  47. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
  48. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
  49. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
  50. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
  51. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
  52. package/backend/detectors/mini-shai-hulud/index.js +118 -118
  53. package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
  54. package/backend/detectors/tier1-binary-embed.js +34 -5
  55. package/backend/detectors/tier1-obfuscation-heuristics.js +156 -0
  56. package/backend/detectors/tier1-slsa-attestation.js +12 -0
  57. package/backend/detectors/tier1-version-anomaly.js +187 -0
  58. package/backend/detectors.test.js +88 -0
  59. package/backend/fetch.js +175 -175
  60. package/backend/index.js +4 -4
  61. package/backend/license.js +89 -89
  62. package/backend/lockfile.js +379 -379
  63. package/backend/pdf.js +245 -245
  64. package/backend/policy.js +193 -193
  65. package/backend/report.js +254 -254
  66. package/backend/sbom.js +66 -66
  67. package/backend/scripts/analyze-false-positives.js +146 -0
  68. package/backend/scripts/analyze-validation.js +151 -0
  69. package/backend/scripts/detect-false-positives.js +93 -0
  70. package/backend/scripts/fetch-top-packages.js +129 -0
  71. package/backend/scripts/validate-detectors.js +142 -0
  72. package/backend/siem/cef.js +32 -32
  73. package/backend/siem/ecs.js +40 -40
  74. package/backend/siem/index.js +18 -18
  75. package/backend/siem/qradar.js +56 -56
  76. package/backend/siem/sentinel.js +27 -27
  77. package/backend/tests-d5-enhanced.test.js +46 -0
  78. package/backend/tests-d6-version-anomaly.test.js +58 -0
  79. package/backend/tests-d6.test.js +116 -0
  80. package/backend/tests-d6c.test.js +106 -0
  81. package/backend/tests-d7-obfuscation.test.js +91 -0
  82. package/backend/tests.test.js +898 -0
  83. package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
  84. package/backend/vsix-scan/detectors/burst-publish.js +52 -52
  85. package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
  86. package/backend/vsix-scan/detectors/known-ioc.js +105 -105
  87. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
  88. package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
  89. package/backend/vsix-scan/index.js +183 -183
  90. package/backend/vsix-scan/marketplace-client.js +145 -145
  91. package/backend/vsix-scan/vsix-iocs.json +31 -31
  92. package/cli/cli.js +458 -458
  93. package/package.json +74 -57
  94. package/.dockerignore +0 -20
  95. package/.husky/pre-commit +0 -1
  96. package/SECURITY.md +0 -73
  97. package/deploy/helm/npm-scan/Chart.yaml +0 -22
  98. package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
  99. package/deploy/helm/npm-scan/templates/api.yaml +0 -94
  100. package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
  101. package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
  102. package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
  103. package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
  104. package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
  105. package/deploy/helm/npm-scan/values.yaml +0 -103
  106. package/scripts/download-corpus.js +0 -30
  107. package/scripts/gen-mal-corpus.js +0 -35
  108. package/scripts/generate-campaign-fixtures.js +0 -170
  109. package/src/config/top-5000.json +0 -87
  110. package/test/fixtures/lockfiles/npm-lock.json +0 -69
  111. package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
  112. package/test/fixtures/lockfiles/yarn.lock +0 -104
  113. package/test/fixtures/mock-data.js +0 -69
package/VALIDATION.md ADDED
@@ -0,0 +1,92 @@
1
+ # npm-scan Validation & Calibration Report
2
+ **Date**: 2026-06-03
3
+ **Detectors Validated**: TIER1-VERSION-ANOMALY, TIER1-OBFUSCATION-HEURISTICS, TIER1-LIFECYCLE-HOOK, TIER1-BINARY-EMBED, TIER1-TYPOSQUAT, TIER1-INFOSTEALER
4
+ **Campaigns Tested**: 3 real May 2026 attack vectors
5
+ **Packages Analyzed**: 7 (validation) + 1,000 (calibration)
6
+
7
+ ## Campaign Detection Rates
8
+
9
+ | Campaign | Total | Detected | Rate | Expected | Matched | Match% |
10
+ |---|---|---|---|---|---|---|
11
+ | 176-Package Dependency Confusion | 3 | 3 | 100.0% | 7 | 5 | 71.4% |
12
+ | Mini Shai-Hulud (Obfuscated) | 2 | 2 | 100.0% | 5 | 3 | 60.0% |
13
+ | Bitwarden CLI Impersonation | 2 | 2 | 100.0% | 5 | 3 | 60.0% |
14
+
15
+ Every campaign package triggered at least one expected detector. Expected-match rate accounts for detectors that require file content (binary embed, infostealer exact patterns) not present in fixture metadata.
16
+
17
+ ## Detector Performance (Validation)
18
+
19
+ | Detector | Hits | Expected | Precision | Avg Confidence |
20
+ |---|---|---|---|---|
21
+ | TIER1-LIFECYCLE-HOOK | 4 | 4 | 100.0% | 92.5 |
22
+ | TIER1-VERSION-ANOMALY | 3 | 3 | 100.0% | 92.0 |
23
+ | TIER1-OBFUSCATION-HEURISTICS | 2 | 2 | 100.0% | 80.0 |
24
+ | TIER1-TYPOSQUAT | 4 | 2 | 50.0% | 68.8 |
25
+
26
+ ## Threshold Calibration
27
+
28
+ **Pre-calibration**: Global confidence threshold at 70
29
+ **Post-calibration**: Per-detector thresholds from analysis:
30
+
31
+ | Detector | Flag | Warn | Calibration Basis |
32
+ |---|---|---|---|
33
+ | TIER1-TYPOSQUAT | 85 | 70 | 46 edit-distance=1 FPs on scoped sub-packages eliminated at 85 |
34
+ | TIER1-OBFUSCATION-HEURISTICS | 75 | 60 | Bundlers/transpilers exempt via whitelist |
35
+ | TIER1-VERSION-ANOMALY | 72 | 60 | Sentinel patterns always flag at 92 |
36
+ | TIER1-BINARY-EMBED | 80 | 65 | Cross-platform binary sets rare in legit packages |
37
+ | TIER1-LIFECYCLE-HOOK | 65 | 50 | Moderate threshold for hooks |
38
+ | TIER1-INFOSTEALER | 72 | 55 | Pattern-based C2 signatures |
39
+ | TIER1-METADATA-SPOOF | 70 | 55 | Namespace/repo URL spoofing |
40
+ | TIER1-VERSION-CONFUSION | 75 | 60 | High-version heuristics |
41
+ | TIER1-CLOUD-IMDS | 80 | 65 | IMDS targeting rarely legitimate |
42
+ | TIER1-MULTISTAGE-POSTINSTALL | 75 | 60 | Two-stage download+exec |
43
+ | TIER1-SLSA-ATTESTATION | 85 | 70 | Placeholder |
44
+
45
+ **False Positive Calibration on Top 1,000 npm Packages**:
46
+ - Threshold 70: 47 FPs (4.7%) — all TIER1-TYPOSQUAT edit-distance=1 on scoped sub-packages
47
+ - Threshold 76: 2 FPs (0.2%) — @commitlint/read + preact (both whitelisted)
48
+ - Threshold 85: **0 FPs (0.0%)** — well under 2% target
49
+
50
+ **Whitelist Additions** (10 packages, 4 detectors):
51
+ - Bundlers/minifiers (webpack, terser, uglify-js, browserify, rollup, esbuild) → TIER1-OBFUSCATION-HEURISTICS
52
+ - Transpilers (typescript, @babel/core) → TIER1-OBFUSCATION-HEURISTICS
53
+ - Utility libs (lodash, underscore, crypto-js) → TIER1-OBFUSCATION-HEURISTICS
54
+ - Date lib (moment) → TIER1-BINARY-EMBED
55
+ - Scoped packages (preact, @commitlint/read) → TYPOSQUAT_VPMDHAJ / TIER1-TYPOSQUAT
56
+
57
+ ## Campaign Coverage Analysis
58
+
59
+ ### Campaign 1: Dependency Confusion (sentinel versions)
60
+ - TIER1-VERSION-ANOMALY catches all three (99.99.99/11.11.11/10.10.10) at 92% confidence
61
+ - TIER1-LIFECYCLE-HOOK fires on postinstall/preinstall scripts at 70-100%
62
+ - TIER1-BINARY-EMBED does not fire (no binary files in fixture data)
63
+ - Additional: TIER1-VERSION-CONFUSION fires at 85/65/65 (enhanced coverage)
64
+
65
+ ### Campaign 2: Mini Shai-Hulud (obfuscation)
66
+ - TIER1-OBFUSCATION-HEURISTICS fires on both packages at 90% and 70%
67
+ - TIER1-LIFECYCLE-HOOK fires on @antv/core at 100%
68
+ - TIER1-INFOSTEALER does not fire (fixture scripts lack exact pattern signatures)
69
+ - Additional: TIER1-TYPOSQUAT fires at 75-100%, MINI_SHAI_HULUD campaign detector fires
70
+
71
+ ### Campaign 3: Bitwarden Impersonation
72
+ - TIER1-LIFECYCLE-HOOK fires on second wave at 100%
73
+ - TIER1-TYPOSQUAT fires at 50% (below flag threshold of 85)
74
+ - TIER1-OBFUSCATION-HEURISTICS does not fire on first wave (script not sufficiently obfuscated)
75
+ - Additional: TRAPDOOR and TYPOSQUAT_VPMDHAJ detectors fire on second wave
76
+
77
+ ## Test Suite
78
+ - 690 total tests (671 pass, 0 fail, 19 skip)
79
+ - Existing corpus tests (33 malicious + 50 clean) all pass with no regressions
80
+ - 15 new validation tests added (D5: 3, D6: 6, D7: 6)
81
+
82
+ ## Recommendations
83
+
84
+ 1. **Ship D6 + D7 as production Tier 1**: Detection rates and false positive rates justify GA
85
+ 2. **Implement D8 (SLSA) when npm registry API stabilizes** (~Q4 2026)
86
+ 3. **Add dynamic whitelist refresh**: Fetch top 1,000 packages monthly; re-calibrate annually
87
+ 4. **Monitor typosquat FP rate**: 46 FPs eliminated at threshold 85; lower threshold increases FP risk
88
+
89
+ **Validation Artifacts**:
90
+ - `detection-rates.json`: Per-campaign, per-detector metrics
91
+ - `false-positives.jsonl`: Flagged packages from top 1K npm (0.0% FP rate at threshold 85)
92
+ - `fp-analysis.json`: Detector-level FP analysis and recommendations
package/backend/cra.js CHANGED
@@ -1,69 +1,69 @@
1
- export function generateCRA(scans) {
2
- const atkMap = {};
3
- for (const s of scans) {
4
- for (const f of (s.findings || [])) {
5
- const key = f.atk_id || f.id;
6
- if (!atkMap[key]) atkMap[key] = [];
7
- atkMap[key].push({ ...f, package_name: s.package_name, version: s.version });
8
- }
9
- }
10
-
11
- const CRA_ARTICLES = [
12
- { article: 'Art. 7', title: 'Secure by default configuration', atkId: 'ATK-001', desc: 'Lifecycle hooks used for insecure defaults' },
13
- { article: 'Art. 7', title: 'Secure by default configuration', atkId: 'ATK-010', desc: 'Anti-analysis in default state' },
14
- { article: 'Art. 10(1)', title: 'Vulnerability disclosure', atkId: 'ATK-008', desc: 'Tarball integrity prevents disclosure accuracy' },
15
- { article: 'Art. 10(2)', title: 'Known vulnerability reporting', atkId: 'ATK-006', desc: 'Dependency confusion undermines visibility' },
16
- { article: 'Art. 11', title: 'Software Bill of Materials', atkId: 'ATK-008', desc: 'Integrity of SBOM entries must be verified' },
17
- { article: 'Art. 11', title: 'Software Bill of Materials', atkId: 'ATK-006', desc: 'SBOM must reflect actual dependency graph' },
18
- { article: 'Annex I(1.1)', title: 'No known exploitable vulnerabilities', atkId: 'ATK-009', desc: 'Conditional triggers may activate known vulns' },
19
- { article: 'Annex I(1.3)', title: 'Least privilege', atkId: 'ATK-003', desc: 'Credential harvesting violates least privilege' },
20
- { article: 'Annex I(1.5)', title: 'Limited attack surface', atkId: 'ATK-002', desc: 'Obfuscation increases attack surface' },
21
- { article: 'Annex I(1.5)', title: 'Limited attack surface', atkId: 'ATK-004', desc: 'Persistence mechanisms expand attack surface' },
22
- { article: 'Annex I(1.5)', title: 'Limited attack surface', atkId: 'ATK-005', desc: 'Network exfiltration expands attack surface' },
23
- { article: 'Annex I(2.1)', title: 'Protection against unauthorized access', atkId: 'ATK-003', desc: 'Credential harvesting enables unauthorized access' },
24
- { article: 'Annex I(2.3)', title: 'Data integrity', atkId: 'ATK-008', desc: 'Tarball tampering violates data integrity' },
25
- { article: 'Annex I(2.3)', title: 'Data integrity', atkId: 'ATK-011', desc: 'Propagation attacks compromise data integrity' },
26
- { article: 'Annex I(3.2)', title: 'Incident detection and reporting', atkId: 'ATK-009', desc: 'Conditional triggers evade incident detection' },
27
- { article: 'Annex I(3.2)', title: 'Incident detection and reporting', atkId: 'ATK-010', desc: 'Sandbox evasion defeats incident detection' },
28
- { article: 'Annex I(3.3)', title: 'Supply chain security monitoring', atkId: 'ATK-011', desc: 'Propagation requires SC monitoring' },
29
- { article: 'Annex I(3.3)', title: 'Supply chain security monitoring', atkId: 'ATK-007', desc: 'Typosquatting undermines SC trust' },
30
- ];
31
-
32
- let rows = '';
33
- for (const { article, title, atkId, desc } of CRA_ARTICLES) {
34
- const findings = atkMap[atkId] || [];
35
- const status = findings.length > 0 ? 'fail' : 'pass';
36
- const colorClass = status === 'pass' ? 'pass' : 'fail';
37
- const label = findings.length > 0 ? `${findings.length} finding(s)` : 'No findings';
38
- rows += `<tr><td>${article}</td><td>${title}</td><td>${desc}</td><td class="nist-${colorClass}">${label}</td><td>${atkId}</td></tr>`;
39
- }
40
-
41
- return `<h2>EU CRA Compliance Summary</h2>
42
- <table>
43
- <thead><tr><th>CRA Article</th><th>Requirement</th><th>Relevance</th><th>Status</th><th>ATK</th></tr></thead>
44
- <tbody>${rows}</tbody>
45
- </table>`;
46
- }
47
-
48
- export function generateCRAHTML(scans) {
49
- const body = generateCRA(scans);
50
- return `<!DOCTYPE html>
51
- <html lang="en">
52
- <head><meta charset="UTF-8"><title>EU CRA Compliance Report</title>
53
- <style>
54
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 960px; margin: 0 auto; padding: 20px; background: #0d1117; color: #c9d1d9; }
55
- h1, h2 { color: #58a6ff; }
56
- table { width: 100%; border-collapse: collapse; margin: 12px 0; }
57
- th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #30363d; }
58
- th { background: #161b22; }
59
- .nist-pass { background: #1b3a1b; color: #7ee787; }
60
- .nist-fail { background: #3a1b1b; color: #ff7b72; }
61
- .meta { color: #8b949e; font-size: 13px; margin-top: 30px; }
62
- </style></head>
63
- <body>
64
- <h1>npm-scan EU CRA Compliance Report</h1>
65
- <p>Generated ${new Date().toISOString()} | npm-scan v${process.env.npm_package_version || '0.4.0'}</p>
66
- ${body}
67
- <p class="meta">EU Cyber Resilience Act (Regulation 2023/2841) mapped to ATK findings.</p>
68
- </body></html>`;
1
+ export function generateCRA(scans) {
2
+ const atkMap = {};
3
+ for (const s of scans) {
4
+ for (const f of (s.findings || [])) {
5
+ const key = f.atk_id || f.id;
6
+ if (!atkMap[key]) atkMap[key] = [];
7
+ atkMap[key].push({ ...f, package_name: s.package_name, version: s.version });
8
+ }
9
+ }
10
+
11
+ const CRA_ARTICLES = [
12
+ { article: 'Art. 7', title: 'Secure by default configuration', atkId: 'ATK-001', desc: 'Lifecycle hooks used for insecure defaults' },
13
+ { article: 'Art. 7', title: 'Secure by default configuration', atkId: 'ATK-010', desc: 'Anti-analysis in default state' },
14
+ { article: 'Art. 10(1)', title: 'Vulnerability disclosure', atkId: 'ATK-008', desc: 'Tarball integrity prevents disclosure accuracy' },
15
+ { article: 'Art. 10(2)', title: 'Known vulnerability reporting', atkId: 'ATK-006', desc: 'Dependency confusion undermines visibility' },
16
+ { article: 'Art. 11', title: 'Software Bill of Materials', atkId: 'ATK-008', desc: 'Integrity of SBOM entries must be verified' },
17
+ { article: 'Art. 11', title: 'Software Bill of Materials', atkId: 'ATK-006', desc: 'SBOM must reflect actual dependency graph' },
18
+ { article: 'Annex I(1.1)', title: 'No known exploitable vulnerabilities', atkId: 'ATK-009', desc: 'Conditional triggers may activate known vulns' },
19
+ { article: 'Annex I(1.3)', title: 'Least privilege', atkId: 'ATK-003', desc: 'Credential harvesting violates least privilege' },
20
+ { article: 'Annex I(1.5)', title: 'Limited attack surface', atkId: 'ATK-002', desc: 'Obfuscation increases attack surface' },
21
+ { article: 'Annex I(1.5)', title: 'Limited attack surface', atkId: 'ATK-004', desc: 'Persistence mechanisms expand attack surface' },
22
+ { article: 'Annex I(1.5)', title: 'Limited attack surface', atkId: 'ATK-005', desc: 'Network exfiltration expands attack surface' },
23
+ { article: 'Annex I(2.1)', title: 'Protection against unauthorized access', atkId: 'ATK-003', desc: 'Credential harvesting enables unauthorized access' },
24
+ { article: 'Annex I(2.3)', title: 'Data integrity', atkId: 'ATK-008', desc: 'Tarball tampering violates data integrity' },
25
+ { article: 'Annex I(2.3)', title: 'Data integrity', atkId: 'ATK-011', desc: 'Propagation attacks compromise data integrity' },
26
+ { article: 'Annex I(3.2)', title: 'Incident detection and reporting', atkId: 'ATK-009', desc: 'Conditional triggers evade incident detection' },
27
+ { article: 'Annex I(3.2)', title: 'Incident detection and reporting', atkId: 'ATK-010', desc: 'Sandbox evasion defeats incident detection' },
28
+ { article: 'Annex I(3.3)', title: 'Supply chain security monitoring', atkId: 'ATK-011', desc: 'Propagation requires SC monitoring' },
29
+ { article: 'Annex I(3.3)', title: 'Supply chain security monitoring', atkId: 'ATK-007', desc: 'Typosquatting undermines SC trust' },
30
+ ];
31
+
32
+ let rows = '';
33
+ for (const { article, title, atkId, desc } of CRA_ARTICLES) {
34
+ const findings = atkMap[atkId] || [];
35
+ const status = findings.length > 0 ? 'fail' : 'pass';
36
+ const colorClass = status === 'pass' ? 'pass' : 'fail';
37
+ const label = findings.length > 0 ? `${findings.length} finding(s)` : 'No findings';
38
+ rows += `<tr><td>${article}</td><td>${title}</td><td>${desc}</td><td class="nist-${colorClass}">${label}</td><td>${atkId}</td></tr>`;
39
+ }
40
+
41
+ return `<h2>EU CRA Compliance Summary</h2>
42
+ <table>
43
+ <thead><tr><th>CRA Article</th><th>Requirement</th><th>Relevance</th><th>Status</th><th>ATK</th></tr></thead>
44
+ <tbody>${rows}</tbody>
45
+ </table>`;
46
+ }
47
+
48
+ export function generateCRAHTML(scans) {
49
+ const body = generateCRA(scans);
50
+ return `<!DOCTYPE html>
51
+ <html lang="en">
52
+ <head><meta charset="UTF-8"><title>EU CRA Compliance Report</title>
53
+ <style>
54
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 960px; margin: 0 auto; padding: 20px; background: #0d1117; color: #c9d1d9; }
55
+ h1, h2 { color: #58a6ff; }
56
+ table { width: 100%; border-collapse: collapse; margin: 12px 0; }
57
+ th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #30363d; }
58
+ th { background: #161b22; }
59
+ .nist-pass { background: #1b3a1b; color: #7ee787; }
60
+ .nist-fail { background: #3a1b1b; color: #ff7b72; }
61
+ .meta { color: #8b949e; font-size: 13px; margin-top: 30px; }
62
+ </style></head>
63
+ <body>
64
+ <h1>npm-scan EU CRA Compliance Report</h1>
65
+ <p>Generated ${new Date().toISOString()} | npm-scan v${process.env.npm_package_version || '0.4.0'}</p>
66
+ ${body}
67
+ <p class="meta">EU Cyber Resilience Act (Regulation 2023/2841) mapped to ATK findings.</p>
68
+ </body></html>`;
69
69
  }
@@ -0,0 +1,155 @@
1
+ -- PostgreSQL schema for hosted/team tier (premium)
2
+ -- Extends the SQLite schema with teams, users, RBAC, audit logs, webhooks
3
+
4
+ -- Extensions
5
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
6
+ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
7
+
8
+ -- Teams / Organizations
9
+ CREATE TABLE IF NOT EXISTS teams (
10
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
11
+ name TEXT NOT NULL,
12
+ slug TEXT UNIQUE NOT NULL,
13
+ license_edition TEXT NOT NULL DEFAULT 'community',
14
+ license_key TEXT,
15
+ license_expires_at TIMESTAMPTZ,
16
+ max_seats INTEGER NOT NULL DEFAULT 5,
17
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
18
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
19
+ );
20
+
21
+ -- Users
22
+ CREATE TABLE IF NOT EXISTS users (
23
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
24
+ email TEXT UNIQUE NOT NULL,
25
+ name TEXT NOT NULL,
26
+ password_hash TEXT NOT NULL,
27
+ team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
28
+ role TEXT NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')) DEFAULT 'viewer',
29
+ last_login_at TIMESTAMPTZ,
30
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
31
+ );
32
+
33
+ -- Scans (extends SQLite scans with team ownership)
34
+ CREATE TABLE IF NOT EXISTS scans (
35
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
36
+ team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
37
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
38
+ package_name TEXT NOT NULL,
39
+ version TEXT,
40
+ status TEXT NOT NULL DEFAULT 'pending'
41
+ CHECK (status IN ('pending', 'fetching', 'analyzing', 'completed', 'failed')),
42
+ sbom_json JSONB,
43
+ findings_summary JSONB,
44
+ duration_ms INTEGER,
45
+ scanned_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
46
+ );
47
+
48
+ -- Findings
49
+ CREATE TABLE IF NOT EXISTS findings (
50
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
51
+ scan_id UUID NOT NULL REFERENCES scans(id) ON DELETE CASCADE,
52
+ atk_id TEXT NOT NULL,
53
+ severity TEXT NOT NULL CHECK (severity IN ('info', 'low', 'medium', 'high', 'critical')),
54
+ title TEXT,
55
+ description TEXT,
56
+ evidence TEXT,
57
+ mitigation TEXT,
58
+ file_path TEXT,
59
+ line_number INTEGER
60
+ );
61
+
62
+ -- Indexes
63
+ CREATE INDEX IF NOT EXISTS idx_scans_team ON scans(team_id);
64
+ CREATE INDEX IF NOT EXISTS idx_scans_package ON scans(package_name);
65
+ CREATE INDEX IF NOT EXISTS idx_scans_status ON scans(status);
66
+ CREATE INDEX IF NOT EXISTS idx_scans_created ON scans(scanned_at DESC);
67
+ CREATE INDEX IF NOT EXISTS idx_findings_scan ON findings(scan_id);
68
+ CREATE INDEX IF NOT EXISTS idx_findings_atk ON findings(atk_id);
69
+ CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity);
70
+ CREATE INDEX IF NOT EXISTS idx_users_team ON users(team_id);
71
+
72
+ -- Audit log
73
+ CREATE TABLE IF NOT EXISTS audit_log (
74
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
75
+ team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
76
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
77
+ action TEXT NOT NULL,
78
+ resource_type TEXT NOT NULL,
79
+ resource_id TEXT,
80
+ details JSONB,
81
+ ip_address INET,
82
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
83
+ );
84
+
85
+ CREATE INDEX IF NOT EXISTS idx_audit_team ON audit_log(team_id, created_at DESC);
86
+
87
+ -- Webhooks
88
+ CREATE TABLE IF NOT EXISTS webhooks (
89
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
90
+ team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
91
+ url TEXT NOT NULL,
92
+ secret TEXT NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
93
+ events TEXT[] NOT NULL DEFAULT '{}',
94
+ active BOOLEAN NOT NULL DEFAULT true,
95
+ last_triggered_at TIMESTAMPTZ,
96
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
97
+ );
98
+
99
+ CREATE INDEX IF NOT EXISTS idx_webhooks_team ON webhooks(team_id);
100
+
101
+ -- API keys
102
+ CREATE TABLE IF NOT EXISTS api_keys (
103
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
104
+ team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
105
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
106
+ name TEXT NOT NULL,
107
+ key_hash TEXT NOT NULL,
108
+ scopes TEXT[] NOT NULL DEFAULT '{}',
109
+ last_used_at TIMESTAMPTZ,
110
+ expires_at TIMESTAMPTZ,
111
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
112
+ );
113
+
114
+ CREATE INDEX IF NOT EXISTS idx_api_keys_team ON api_keys(team_id);
115
+
116
+ -- Session tokens
117
+ CREATE TABLE IF NOT EXISTS sessions (
118
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
119
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
120
+ token_hash TEXT NOT NULL,
121
+ expires_at TIMESTAMPTZ NOT NULL,
122
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
123
+ );
124
+
125
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
126
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
127
+
128
+ -- Materialized view: package risk aggregation
129
+ CREATE MATERIALIZED VIEW IF NOT EXISTS package_risk AS
130
+ SELECT
131
+ s.package_name,
132
+ s.version,
133
+ COUNT(DISTINCT f.id) AS finding_count,
134
+ COUNT(DISTINCT f.id) FILTER (WHERE f.severity IN ('high', 'critical')) AS high_crit_count,
135
+ ARRAY_AGG(DISTINCT f.atk_id) AS atk_ids,
136
+ MAX(s.scanned_at) AS last_scanned
137
+ FROM scans s
138
+ JOIN findings f ON f.scan_id = s.id
139
+ WHERE s.status = 'completed'
140
+ GROUP BY s.package_name, s.version;
141
+
142
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_package_risk_pkg ON package_risk(package_name, version);
143
+
144
+ -- Function: touch updated_at
145
+ CREATE OR REPLACE FUNCTION touch_updated_at()
146
+ RETURNS TRIGGER AS $$
147
+ BEGIN
148
+ NEW.updated_at = NOW();
149
+ RETURN NEW;
150
+ END;
151
+ $$ LANGUAGE plpgsql;
152
+
153
+ CREATE TRIGGER trg_teams_updated_at
154
+ BEFORE UPDATE ON teams
155
+ FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
@@ -1,32 +1,32 @@
1
- -- SQLite schema for local CLI mode (free tier)
2
- -- Tables: scans, findings (ATK-linked)
3
-
4
- CREATE TABLE IF NOT EXISTS scans (
5
- id INTEGER PRIMARY KEY AUTOINCREMENT,
6
- package_name TEXT NOT NULL,
7
- version TEXT,
8
- scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
9
- status TEXT DEFAULT 'completed',
10
- sbom_json TEXT
11
- );
12
-
13
- CREATE TABLE IF NOT EXISTS findings (
14
- id INTEGER PRIMARY KEY AUTOINCREMENT,
15
- scan_id INTEGER NOT NULL,
16
- atk_id TEXT NOT NULL,
17
- severity TEXT CHECK (severity IN ('info', 'low', 'medium', 'high', 'critical')),
18
- description TEXT,
19
- evidence TEXT,
20
- mitigation TEXT,
21
- FOREIGN KEY (scan_id) REFERENCES scans(id) ON DELETE CASCADE
22
- );
23
-
24
- -- View for reports
25
- CREATE VIEW IF NOT EXISTS scan_findings AS
26
- SELECT s.*, f.* FROM scans s
27
- JOIN findings f ON s.id = f.scan_id;
28
-
29
- -- Indexes
30
- CREATE INDEX IF NOT EXISTS idx_scans_package ON scans(package_name);
31
- CREATE INDEX IF NOT EXISTS idx_findings_atk ON findings(atk_id);
32
- CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity);
1
+ -- SQLite schema for local CLI mode (free tier)
2
+ -- Tables: scans, findings (ATK-linked)
3
+
4
+ CREATE TABLE IF NOT EXISTS scans (
5
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ package_name TEXT NOT NULL,
7
+ version TEXT,
8
+ scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
9
+ status TEXT DEFAULT 'completed',
10
+ sbom_json TEXT
11
+ );
12
+
13
+ CREATE TABLE IF NOT EXISTS findings (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ scan_id INTEGER NOT NULL,
16
+ atk_id TEXT NOT NULL,
17
+ severity TEXT CHECK (severity IN ('info', 'low', 'medium', 'high', 'critical')),
18
+ description TEXT,
19
+ evidence TEXT,
20
+ mitigation TEXT,
21
+ FOREIGN KEY (scan_id) REFERENCES scans(id) ON DELETE CASCADE
22
+ );
23
+
24
+ -- View for reports
25
+ CREATE VIEW IF NOT EXISTS scan_findings AS
26
+ SELECT s.*, f.* FROM scans s
27
+ JOIN findings f ON s.id = f.scan_id;
28
+
29
+ -- Indexes
30
+ CREATE INDEX IF NOT EXISTS idx_scans_package ON scans(package_name);
31
+ CREATE INDEX IF NOT EXISTS idx_findings_atk ON findings(atk_id);
32
+ CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity);
package/backend/db.js CHANGED
@@ -1,89 +1,89 @@
1
- import initSqlJs from 'sql.js';
2
- import fs from 'fs';
3
- import path from 'path';
4
- import { fileURLToPath } from 'url';
5
-
6
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- const DB_PATH = path.join(process.cwd(), 'npm-scan.db');
8
- const SCHEMA_PATH = path.join(__dirname, 'db', 'schema.sql');
9
-
10
- let db = null;
11
- let initPromise = null;
12
-
13
- async function ensureInit() {
14
- if (db) return;
15
- if (initPromise) return initPromise;
16
- initPromise = (async () => {
17
- const SQL = await initSqlJs();
18
- if (fs.existsSync(DB_PATH)) {
19
- db = new SQL.Database(fs.readFileSync(DB_PATH));
20
- } else {
21
- db = new SQL.Database();
22
- }
23
- if (fs.existsSync(SCHEMA_PATH)) {
24
- db.run(fs.readFileSync(SCHEMA_PATH, 'utf8'));
25
- }
26
- })();
27
- return initPromise;
28
- }
29
-
30
- function queryAll(sql, params = []) {
31
- const stmt = db.prepare(sql);
32
- if (params.length) stmt.bind(params);
33
- const rows = [];
34
- while (stmt.step()) {
35
- rows.push(stmt.getAsObject());
36
- }
37
- stmt.free();
38
- return rows;
39
- }
40
-
41
- function queryOne(sql, params = []) {
42
- return queryAll(sql, params)[0] || null;
43
- }
44
-
45
- function lastId() {
46
- const r = db.exec("SELECT last_insert_rowid()");
47
- return Number(r[0].values[0][0]);
48
- }
49
-
50
- function persist() {
51
- fs.writeFileSync(DB_PATH, Buffer.from(db.export()));
52
- }
53
-
54
- export async function saveScan(pkgName, version = 'latest', findings = []) {
55
- await ensureInit();
56
- db.run("INSERT INTO scans (package_name, version) VALUES (?, ?)", [pkgName, version]);
57
- const scanId = lastId();
58
- const stmt = db.prepare("INSERT INTO findings (scan_id, atk_id, severity, description, evidence) VALUES (?, ?, ?, ?, ?)");
59
- for (const f of findings) {
60
- stmt.run([scanId, f.id, f.severity, f.title || f.description, f.evidence || '']);
61
- }
62
- stmt.free();
63
- persist();
64
- return scanId;
65
- }
66
-
67
- export async function getRecentScans(limit = 10) {
68
- await ensureInit();
69
- return queryAll("SELECT * FROM scans ORDER BY scanned_at DESC LIMIT ?", [limit]);
70
- }
71
-
72
- export async function getFindings(scanId) {
73
- await ensureInit();
74
- return queryAll("SELECT * FROM findings WHERE scan_id = ?", [scanId]);
75
- }
76
-
77
- export async function getScan(scanId) {
78
- await ensureInit();
79
- return queryOne("SELECT * FROM scans WHERE id = ?", [scanId]);
80
- }
81
-
82
- export async function close() {
83
- if (db) {
84
- persist();
85
- db.close();
86
- db = null;
87
- initPromise = null;
88
- }
1
+ import initSqlJs from 'sql.js';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const DB_PATH = path.join(process.cwd(), 'npm-scan.db');
8
+ const SCHEMA_PATH = path.join(__dirname, 'db', 'schema.sql');
9
+
10
+ let db = null;
11
+ let initPromise = null;
12
+
13
+ async function ensureInit() {
14
+ if (db) return;
15
+ if (initPromise) return initPromise;
16
+ initPromise = (async () => {
17
+ const SQL = await initSqlJs();
18
+ if (fs.existsSync(DB_PATH)) {
19
+ db = new SQL.Database(fs.readFileSync(DB_PATH));
20
+ } else {
21
+ db = new SQL.Database();
22
+ }
23
+ if (fs.existsSync(SCHEMA_PATH)) {
24
+ db.run(fs.readFileSync(SCHEMA_PATH, 'utf8'));
25
+ }
26
+ })();
27
+ return initPromise;
28
+ }
29
+
30
+ function queryAll(sql, params = []) {
31
+ const stmt = db.prepare(sql);
32
+ if (params.length) stmt.bind(params);
33
+ const rows = [];
34
+ while (stmt.step()) {
35
+ rows.push(stmt.getAsObject());
36
+ }
37
+ stmt.free();
38
+ return rows;
39
+ }
40
+
41
+ function queryOne(sql, params = []) {
42
+ return queryAll(sql, params)[0] || null;
43
+ }
44
+
45
+ function lastId() {
46
+ const r = db.exec("SELECT last_insert_rowid()");
47
+ return Number(r[0].values[0][0]);
48
+ }
49
+
50
+ function persist() {
51
+ fs.writeFileSync(DB_PATH, Buffer.from(db.export()));
52
+ }
53
+
54
+ export async function saveScan(pkgName, version = 'latest', findings = []) {
55
+ await ensureInit();
56
+ db.run("INSERT INTO scans (package_name, version) VALUES (?, ?)", [pkgName, version]);
57
+ const scanId = lastId();
58
+ const stmt = db.prepare("INSERT INTO findings (scan_id, atk_id, severity, description, evidence) VALUES (?, ?, ?, ?, ?)");
59
+ for (const f of findings) {
60
+ stmt.run([scanId, f.id, f.severity, f.title || f.description, f.evidence || '']);
61
+ }
62
+ stmt.free();
63
+ persist();
64
+ return scanId;
65
+ }
66
+
67
+ export async function getRecentScans(limit = 10) {
68
+ await ensureInit();
69
+ return queryAll("SELECT * FROM scans ORDER BY scanned_at DESC LIMIT ?", [limit]);
70
+ }
71
+
72
+ export async function getFindings(scanId) {
73
+ await ensureInit();
74
+ return queryAll("SELECT * FROM findings WHERE scan_id = ?", [scanId]);
75
+ }
76
+
77
+ export async function getScan(scanId) {
78
+ await ensureInit();
79
+ return queryOne("SELECT * FROM scans WHERE id = ?", [scanId]);
80
+ }
81
+
82
+ export async function close() {
83
+ if (db) {
84
+ persist();
85
+ db.close();
86
+ db = null;
87
+ initPromise = null;
88
+ }
89
89
  }
@@ -1,18 +1,18 @@
1
- export async function scan(pkgJson, files = []) {
2
- const findings = [];
3
- const scripts = pkgJson.scripts || {};
4
- const suspicious = Object.keys(scripts).filter(s => /pre|post|install/i.test(s));
5
- if (suspicious.length) {
6
- const content = suspicious.map(s => scripts[s]).join(' ');
7
- if (/curl|wget|sh |bash |\.sh|exfil|steal|pwn|c2|pastebin/i.test(content)) {
8
- findings.push({
9
- id: 'ATK-001',
10
- severity: 'high',
11
- title: 'Malicious lifecycle scripts',
12
- description: 'Suspicious install hooks',
13
- evidence: suspicious.join(', ')
14
- });
15
- }
16
- }
17
- return findings;
1
+ export async function scan(pkgJson, files = []) {
2
+ const findings = [];
3
+ const scripts = pkgJson.scripts || {};
4
+ const suspicious = Object.keys(scripts).filter(s => /pre|post|install/i.test(s));
5
+ if (suspicious.length) {
6
+ const content = suspicious.map(s => scripts[s]).join(' ');
7
+ if (/curl|wget|sh |bash |\.sh|exfil|steal|pwn|c2|pastebin/i.test(content)) {
8
+ findings.push({
9
+ id: 'ATK-001',
10
+ severity: 'high',
11
+ title: 'Malicious lifecycle scripts',
12
+ description: 'Suspicious install hooks',
13
+ evidence: suspicious.join(', ')
14
+ });
15
+ }
16
+ }
17
+ return findings;
18
18
  }