@lateos/npm-scan 0.18.1 → 0.18.3

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 CHANGED
@@ -6,6 +6,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+
11
+ ## v0.18.2 — June 2, 2026
12
+
13
+ ### New Detectors
14
+ - **D6a** `tier1-version-confusion.js` — Detects dependency confusion via sentinel
15
+ versions (99.99.99 family → HIGH) and high-version heuristic (major≥9 → MEDIUM).
16
+ Covers Sonatype-2026-003429 and Microsoft scope confusion campaigns.
17
+ - **D6b** `tier1-multistage-postinstall.js` — Detects two-stage remote download +
18
+ binary execution and detached background persistence in lifecycle scripts.
19
+ Covers Gen-2 stager patterns from the OpenSearch/ES typosquatting wave.
20
+ - **D6c** `tier1-cloud-imds.js` — Detects GCP metadata server and Azure IMDS endpoint
21
+ targeting in scripts and JS files. Covers the Miasma @redhat-cloud-services campaign.
22
+
23
+ ### Detector Enhancements
24
+ - **D2** `tier1-infostealer.js` — Added NAMED_SIGNATURES array with early-return
25
+ CRITICAL detection for confirmed malware campaign strings. First entry: Miasma
26
+ campaign identifier (June 2026).
27
+
28
+ ### Bug Fixes
29
+ - **D6b** `tier1-multistage-postinstall.js`
30
+ - Removed /g flag from REMOTE_FETCH_RE, BINARY_EXEC_RE, DETACHED_RE —
31
+ eliminated fragile lastIndex state between hook iterations
32
+ - Added critical severity tier to severityLabel — Signal A+B findings
33
+ now consistently report severity: critical / confidence: CRITICAL
34
+ - Fixed hardcoded "postinstall" in finding message — now reflects
35
+ whichever hook fired and the subtype string
36
+
37
+ ### Infrastructure
38
+ - Added Detector Registry section to AGENTS.md with calibration notes.
39
+
40
+ ### Test Suite
41
+ - 656 passing, 0 failing, 19 skipping.
42
+
9
43
  ### Added
10
44
  - `scan --file <path>` flag to analyze local `.tgz` tarballs without fetching from npm registry
11
45
  - `scan --fail-on <level>` flag to exit with code 1 when findings >= severity (CI/CD integration)
@@ -23,6 +23,9 @@ import { scan as tier1InfostealerScan } from './tier1-infostealer.js';
23
23
  import { scan as tier1LifecycleHookScan } from './tier1-lifecycle-hook.js';
24
24
  import { scan as tier1BinaryEmbedScan } from './tier1-binary-embed.js';
25
25
  import { scan as tier1MetadataSpoofScan } from './tier1-metadata-spoof.js';
26
+ import { scan as tier1VersionConfusionScan } from './tier1-version-confusion.js';
27
+ import { scan as tier1CloudImdsScan } from './tier1-cloud-imds.js';
28
+ import { scan as tier1MultistagePostinstallScan } from './tier1-multistage-postinstall.js';
26
29
 
27
30
  function timeout(ms) {
28
31
  return new Promise((_, reject) => setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms));
@@ -72,5 +75,8 @@ export async function runAll(pkgJson, files = [], registryMeta = null, allFiles
72
75
  findings.push(...await runTier1('tier1-lifecycle-hook', tier1LifecycleHookScan, pkgJson, files, registryMeta, allFiles || files));
73
76
  findings.push(...await runTier1('tier1-binary-embed', tier1BinaryEmbedScan, pkgJson, files, registryMeta, allFiles || files));
74
77
  findings.push(...await runTier1('tier1-metadata-spoof', tier1MetadataSpoofScan, pkgJson, files, registryMeta, allFiles || files));
78
+ findings.push(...await runTier1('tier1-version-confusion', tier1VersionConfusionScan, pkgJson, files, registryMeta, allFiles || files));
79
+ findings.push(...await runTier1('tier1-cloud-imds', tier1CloudImdsScan, pkgJson, files, registryMeta, allFiles || files));
80
+ findings.push(...await runTier1('tier1-multistage-postinstall', tier1MultistagePostinstallScan, pkgJson, files, registryMeta, allFiles || files));
75
81
  return findings.sort((a, b) => b.severity.localeCompare(a.severity));
76
82
  }
@@ -0,0 +1,124 @@
1
+ const GCP_PATTERNS = [
2
+ 'metadata.google.internal',
3
+ 'computeMetadata/v1',
4
+ 'metadata.google.internal/computeMetadata',
5
+ ];
6
+
7
+ const AZURE_PATTERNS = [
8
+ '169.254.169.254/metadata/instance',
9
+ '169.254.169.254/metadata/identity',
10
+ ];
11
+
12
+ const AZURE_IP = '169.254.169.254';
13
+ const METADATA_HEADER_RE = /Metadata\s*:\s*true/i;
14
+
15
+ function severityLabel(score) {
16
+ if (score >= 80) return 'high';
17
+ return 'medium';
18
+ }
19
+
20
+ function confidenceLabel(score) {
21
+ if (score >= 80) return 'HIGH';
22
+ if (score >= 60) return 'MEDIUM';
23
+ return 'LOW';
24
+ }
25
+
26
+ function hasGcpPattern(text) {
27
+ return GCP_PATTERNS.some(p => text.includes(p));
28
+ }
29
+
30
+ function hasAzurePath(text) {
31
+ return AZURE_PATTERNS.some(p => text.includes(p));
32
+ }
33
+
34
+ function hasAzureHeaderPattern(text) {
35
+ const lines = text.split('\n');
36
+ for (let i = 0; i < lines.length; i++) {
37
+ if (!lines[i].includes(AZURE_IP)) continue;
38
+ const start = Math.max(0, i - 5);
39
+ const end = Math.min(lines.length, i + 6);
40
+ for (let j = start; j < end; j++) {
41
+ if (METADATA_HEADER_RE.test(lines[j])) return true;
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+
47
+ function hasAzurePattern(text) {
48
+ return hasAzurePath(text) || hasAzureHeaderPattern(text);
49
+ }
50
+
51
+ function collectTexts(pkgJson, jsFiles) {
52
+ const texts = [];
53
+
54
+ if (pkgJson?.scripts && typeof pkgJson.scripts === 'object') {
55
+ for (const value of Object.values(pkgJson.scripts)) {
56
+ if (typeof value === 'string') {
57
+ texts.push(value);
58
+ }
59
+ }
60
+ }
61
+
62
+ if (jsFiles && Array.isArray(jsFiles)) {
63
+ for (const file of jsFiles) {
64
+ if (file?.content && typeof file.content === 'string') {
65
+ texts.push(file.content);
66
+ }
67
+ }
68
+ }
69
+
70
+ return texts;
71
+ }
72
+
73
+ export const name = 'tier1-cloud-imds';
74
+
75
+ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
76
+ const texts = collectTexts(pkgJson, jsFiles);
77
+ if (texts.length === 0) return [];
78
+
79
+ let hasGcp = false;
80
+ let hasAzure = false;
81
+
82
+ for (const text of texts) {
83
+ if (!hasGcp && hasGcpPattern(text)) hasGcp = true;
84
+ if (!hasAzure && hasAzurePattern(text)) hasAzure = true;
85
+ if (hasGcp && hasAzure) break;
86
+ }
87
+
88
+ if (!hasGcp && !hasAzure) return [];
89
+
90
+ let confidenceScore;
91
+ let subtype;
92
+
93
+ if (hasGcp && hasAzure) {
94
+ confidenceScore = 92;
95
+ subtype = 'multi_cloud_imds';
96
+ } else if (hasGcp) {
97
+ confidenceScore = 82;
98
+ subtype = 'gcp_metadata';
99
+ } else {
100
+ confidenceScore = 82;
101
+ subtype = 'azure_imds';
102
+ }
103
+
104
+ return [{
105
+ detector: 'tier1-cloud-imds',
106
+ id: 'TIER1-CLOUD-IMDS',
107
+ severity: severityLabel(confidenceScore),
108
+ confidence: confidenceLabel(confidenceScore),
109
+ confidenceScore,
110
+ subtype,
111
+ message: hasGcp && hasAzure
112
+ ? `Package references both GCP metadata and Azure IMDS endpoints — cloud credential harvesting`
113
+ : hasGcp
114
+ ? `Package references GCP metadata server endpoint — cloud credential harvesting`
115
+ : `Package references Azure IMDS endpoint — cloud credential harvesting`,
116
+ evidence: [
117
+ ...(hasGcp ? ['gcp: metadata.google.internal / computeMetadata/v1 pattern detected'] : []),
118
+ ...(hasAzure ? ['azure: 169.254.169.254/metadata pattern detected'] : []),
119
+ ],
120
+ crossFiles: [],
121
+ locations: [{ file: '', line: 0 }],
122
+ reference: 'Miasma Cloud IMDS',
123
+ }];
124
+ }
@@ -21,6 +21,11 @@ const EVAL_RE = /\beval\s*\(/g;
21
21
  const FUNCTION_CTOR_RE = /\bFunction\s*\(/g;
22
22
  const B64_STRING_RE = /['"`]([A-Za-z0-9+/]{40,}={0,2})['"`]/g;
23
23
 
24
+ // Named malware signatures — zero-FP string literals for confirmed campaigns
25
+ const NAMED_SIGNATURES = [
26
+ 'Miasma: The Spreading Blight', // Miasma campaign, June 2026, @redhat-cloud-services compromise
27
+ ];
28
+
24
29
  function shannonEntropy(s) {
25
30
  const len = s.length;
26
31
  if (len === 0) return 0;
@@ -171,6 +176,37 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
171
176
  if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
172
177
 
173
178
  const files = jsFiles || [];
179
+
180
+ // Named malware signature check — zero-FP string literals, early return
181
+ const sigTexts = [];
182
+ if (pkgJson?.scripts && typeof pkgJson.scripts === 'object') {
183
+ for (const value of Object.values(pkgJson.scripts)) {
184
+ if (typeof value === 'string') sigTexts.push(value);
185
+ }
186
+ }
187
+ for (const f of files) {
188
+ if (f?.content) sigTexts.push(f.content);
189
+ }
190
+ for (const sig of NAMED_SIGNATURES) {
191
+ for (const text of sigTexts) {
192
+ if (text.includes(sig)) {
193
+ return [{
194
+ detector: 'tier1-infostealer',
195
+ id: 'TIER1-INFOSTEALER',
196
+ severity: 'critical',
197
+ confidence: 'CRITICAL',
198
+ confidenceScore: 98,
199
+ subtype: 'named_signature_miasma',
200
+ message: `Named malware signature detected: "${sig}"`,
201
+ evidence: [sig],
202
+ locations: [{ file: '', line: 0 }],
203
+ crossFiles: [],
204
+ reference: 'Campaign 2 & 3',
205
+ }];
206
+ }
207
+ }
208
+ }
209
+
174
210
  if (files.length === 0) return [];
175
211
 
176
212
  let parseFailCount = 0;
@@ -0,0 +1,81 @@
1
+ const SCAN_HOOKS = ['preinstall', 'install', 'postinstall', 'prepare'];
2
+
3
+ const REMOTE_FETCH_RE = /\b(?:fetch|axios\.get|axios\.post|http\.get|https\.get)\(|\b(?:curl|wget)\s/;
4
+ const BINARY_EXEC_RE = /\b(?:execFile|execFileSync|execSync|exec|spawnSync|spawn)\s*\(/;
5
+ const DETACHED_RE = /detached\s*:\s*true/;
6
+
7
+ function severityLabel(score) {
8
+ if (score >= 95) return 'critical';
9
+ if (score >= 80) return 'high';
10
+ if (score >= 60) return 'medium';
11
+ return 'low';
12
+ }
13
+
14
+ function confidenceLabel(score) {
15
+ if (score >= 95) return 'CRITICAL';
16
+ if (score >= 80) return 'HIGH';
17
+ if (score >= 60) return 'MEDIUM';
18
+ return 'LOW';
19
+ }
20
+
21
+ export const name = 'tier1-multistage-postinstall';
22
+
23
+ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
24
+ const scripts = pkgJson?.scripts;
25
+ if (!scripts || typeof scripts !== 'object') return [];
26
+
27
+ const findings = [];
28
+
29
+ for (const hookName of SCAN_HOOKS) {
30
+ const content = scripts[hookName];
31
+ if (!content || typeof content !== 'string') continue;
32
+
33
+ const hasRemoteFetch = REMOTE_FETCH_RE.test(content);
34
+ const hasBinaryExec = BINARY_EXEC_RE.test(content);
35
+ const hasDetached = DETACHED_RE.test(content);
36
+
37
+ const signalA = hasRemoteFetch && hasBinaryExec;
38
+ const signalB = hasDetached;
39
+
40
+ if (!signalA && !signalB) continue;
41
+
42
+ let confidenceScore;
43
+ let subtype;
44
+
45
+ if (signalA && signalB) {
46
+ confidenceScore = 95;
47
+ subtype = 'two_stage_plus_detached';
48
+ } else if (signalA) {
49
+ confidenceScore = 82;
50
+ subtype = 'two_stage_download_exec';
51
+ } else {
52
+ confidenceScore = 78;
53
+ subtype = 'detached_background_process';
54
+ }
55
+
56
+ const evidence = [`hook: ${hookName}`];
57
+ if (hasRemoteFetch) evidence.push('pattern: remote fetch call');
58
+ if (hasBinaryExec) evidence.push('pattern: binary execution call');
59
+ if (hasDetached) evidence.push('pattern: detached background process');
60
+
61
+ findings.push({
62
+ detector: 'tier1-multistage-postinstall',
63
+ id: 'TIER1-MULTISTAGE-POSTINSTALL',
64
+ severity: severityLabel(confidenceScore),
65
+ confidence: confidenceLabel(confidenceScore),
66
+ confidenceScore,
67
+ subtype,
68
+ message: `Multi-stage install hook detected in "${hookName}" — ${subtype}`,
69
+ evidence,
70
+ locations: [{
71
+ file: 'package.json',
72
+ field: `scripts.${hookName}`,
73
+ value: content.length > 200 ? `${content.slice(0, 200)}...` : content,
74
+ }],
75
+ crossFiles: [],
76
+ reference: 'Sonatype-2026-003429',
77
+ });
78
+ }
79
+
80
+ return findings;
81
+ }
@@ -0,0 +1,107 @@
1
+ import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
2
+
3
+ const SENTINEL_EXACT = ['99.99.99'];
4
+ const SENTINEL_FAMILY = ['9.9.9', '9.9.10', '10.10.10', '11.11.11'];
5
+
6
+ function severityLabel(score) {
7
+ if (score >= 80) return 'high';
8
+ if (score >= 60) return 'medium';
9
+ return 'low';
10
+ }
11
+
12
+ function confidenceLabel(score) {
13
+ if (score >= 80) return 'HIGH';
14
+ if (score >= 60) return 'MEDIUM';
15
+ return 'LOW';
16
+ }
17
+
18
+ function parseVersion(version) {
19
+ if (!version || typeof version !== 'string') return null;
20
+ const parts = version.split('.');
21
+ if (parts.length !== 3) return null;
22
+ const [major, minor, patch] = parts.map(Number);
23
+ if (isNaN(major) || isNaN(minor) || isNaN(patch)) return null;
24
+ return { major, minor, patch };
25
+ }
26
+
27
+ function matchesHeuristic(parsed) {
28
+ return parsed.major >= 9 && parsed.minor >= 5 && parsed.patch >= 5 && parsed.major !== 1;
29
+ }
30
+
31
+ export const name = 'tier1-version-confusion';
32
+
33
+ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
34
+ const pkgName = pkgJson?.name;
35
+ const version = pkgJson?.version;
36
+
37
+ if (!pkgName || !version) return [];
38
+ if (KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
39
+
40
+ const parsed = parseVersion(version);
41
+ if (!parsed) return [];
42
+
43
+ const vStr = version;
44
+
45
+ // Priority: SENTINEL_EXACT > SENTINEL_FAMILY > HEURISTIC
46
+ if (SENTINEL_EXACT.includes(vStr)) {
47
+ const score = 85;
48
+ return [{
49
+ detector: 'tier1-version-confusion',
50
+ id: 'TIER1-VERSION-CONFUSION',
51
+ severity: severityLabel(score),
52
+ confidence: confidenceLabel(score),
53
+ confidenceScore: score,
54
+ subtype: 'sentinel_exact',
55
+ message: `Package "${pkgName}" uses exact sentinel version ${vStr} — dependency confusion indicator`,
56
+ evidence: [
57
+ `version: ${vStr}`,
58
+ `sentinel: exact match`,
59
+ ],
60
+ crossFiles: [],
61
+ locations: [{ file: 'package.json', line: 3, column: 10 }],
62
+ reference: 'Sonatype-2026-003429',
63
+ }];
64
+ }
65
+
66
+ if (SENTINEL_FAMILY.includes(vStr)) {
67
+ const score = 65;
68
+ return [{
69
+ detector: 'tier1-version-confusion',
70
+ id: 'TIER1-VERSION-CONFUSION',
71
+ severity: severityLabel(score),
72
+ confidence: confidenceLabel(score),
73
+ confidenceScore: score,
74
+ subtype: 'sentinel_family',
75
+ message: `Package "${pkgName}" uses sentinel family version ${vStr} — dependency confusion indicator`,
76
+ evidence: [
77
+ `version: ${vStr}`,
78
+ `sentinel: family match`,
79
+ ],
80
+ crossFiles: [],
81
+ locations: [{ file: 'package.json', line: 3, column: 10 }],
82
+ reference: 'Sonatype-2026-003429',
83
+ }];
84
+ }
85
+
86
+ if (matchesHeuristic(parsed)) {
87
+ const score = 62;
88
+ return [{
89
+ detector: 'tier1-version-confusion',
90
+ id: 'TIER1-VERSION-CONFUSION',
91
+ severity: severityLabel(score),
92
+ confidence: confidenceLabel(score),
93
+ confidenceScore: score,
94
+ subtype: 'high_version_heuristic',
95
+ message: `Package "${pkgName}" version ${vStr} matches high-version heuristic — possible dependency confusion`,
96
+ evidence: [
97
+ `version: ${vStr}`,
98
+ `major: ${parsed.major}, minor: ${parsed.minor}, patch: ${parsed.patch}`,
99
+ ],
100
+ crossFiles: [],
101
+ locations: [{ file: 'package.json', line: 3, column: 10 }],
102
+ reference: 'Microsoft Scope Confusion',
103
+ }];
104
+ }
105
+
106
+ return [];
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lateos/npm-scan",
3
- "version": "0.18.1",
3
+ "version": "0.18.3",
4
4
  "description": "Modern npm supply chain security scanner — detects obfuscated payloads, credential stealers, conditional triggers, sandbox evasion, and worm-like propagation. 11 attack types, SBOM, NIST/EU CRA compliance reporting.",
5
5
  "main": "backend/index.js",
6
6
  "bin": {