muaddib-scanner 2.10.8 → 2.10.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.10.8",
3
+ "version": "2.10.10",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -173,25 +173,42 @@ function isSDKPattern(envVarName, fileContent) {
173
173
  if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(domain)) return false;
174
174
  }
175
175
 
176
- // 1. Try curated allowlist first
176
+ // 1. Try curated allowlist first (strict: ALL domains must match)
177
+ // Curated allowlist is authoritative — no relaxation here to prevent
178
+ // attacker injecting a legitimate domain alongside their C2 domain.
177
179
  for (const mapping of SDK_ENV_DOMAIN_MAP) {
178
180
  if (mapping.envPattern.test(envVarName)) {
179
- // All domains must match expected SDK domains
180
181
  return domains.every(d => domainMatchesSuffix(d, mapping.domains));
181
182
  }
182
183
  }
183
184
 
185
+ // R2: credential-suffixed env vars get relaxed domain matching (at least ONE match).
186
+ // SDKs commonly call their own API + CDN/logging/analytics domains.
187
+ // Safety: suspicious domains and raw IPs are already rejected above.
188
+ // Only applies to the heuristic fallback — curated allowlist stays strict.
189
+ const CREDENTIAL_SUFFIXES = ['_API_KEY', '_SECRET', '_TOKEN', '_SECRET_KEY', '_ACCESS_KEY'];
190
+ const upperName = envVarName.toUpperCase();
191
+ const hasCredentialSuffix = CREDENTIAL_SUFFIXES.some(s => upperName.endsWith(s));
192
+
184
193
  // 2. Heuristic fallback: extract brand keyword and check domain labels
185
194
  const brand = extractBrandFromEnvVar(envVarName);
186
195
  if (!brand || brand.length < 3) return false; // Too short for reliable matching
187
196
 
188
197
  const brandLower = brand.toLowerCase();
189
- // Check if every domain has the brand as a whole label
198
+ // 2a. Strict check: every domain matches brand (existing behavior)
190
199
  // e.g., brand "ACME" matches "api.acme.com" (label "acme") but not "api.acmetech.com"
191
- return domains.every(d => {
200
+ if (domains.every(d => {
201
+ const labels = d.split('.');
202
+ return labels.some(label => label === brandLower);
203
+ })) return true;
204
+
205
+ // 2b. R2 relaxed: credential suffix + at least one domain matches brand
206
+ if (hasCredentialSuffix && domains.some(d => {
192
207
  const labels = d.split('.');
193
208
  return labels.some(label => label === brandLower);
194
- });
209
+ })) return true;
210
+
211
+ return false;
195
212
  }
196
213
 
197
214
 
@@ -954,6 +954,19 @@ function analyzeFile(content, filePath, basePath) {
954
954
  }
955
955
  }
956
956
 
957
+ // Graduation: HIGH → MEDIUM for env/telemetry-only sources (no credential file reads,
958
+ // no fingerprint reads, no command output). Distant env/telemetry → network_send
959
+ // is the dominant FP pattern (SDK/API usage, binary wrappers, config libraries).
960
+ // Real credential exfiltration uses credential_read or fingerprint_read sources.
961
+ if (severity === 'HIGH') {
962
+ const hasHighRiskSource = sources.some(s =>
963
+ s.type === 'credential_read' || s.type === 'fingerprint_read' || s.type === 'command_output'
964
+ );
965
+ if (!hasHighRiskSource) {
966
+ severity = 'MEDIUM';
967
+ }
968
+ }
969
+
957
970
  const sourceDesc = hasCommandOutput ? 'command output' : 'credentials read';
958
971
  threats.push({
959
972
  type: 'suspicious_dataflow',
package/src/scoring.js CHANGED
@@ -40,6 +40,11 @@ const MAX_RISK_SCORE = 100;
40
40
  // to limit noise while preserving some signal. CRITICAL and HIGH prototype_hook findings still score normally.
41
41
  const PROTO_HOOK_MEDIUM_CAP = 15;
42
42
 
43
+ // R4: suspicious_dataflow(MEDIUM) is a co-occurrence signal, not a standalone detection.
44
+ // Multiple env_read/telemetry distant flows in the same file should not inflate the score.
45
+ // Compounds (lifecycle_dataflow) provide the real signal and score separately.
46
+ const DATAFLOW_MEDIUM_CAP = 3;
47
+
43
48
  // Confidence-weighted scoring factors (v2.7.10)
44
49
  // High-confidence detections (eval, IOC, shell injection) score at full weight.
45
50
  // Medium-confidence heuristics (lifecycle_script, obfuscation, high_entropy) are discounted.
@@ -128,6 +133,7 @@ function isPackageLevelThreat(threat) {
128
133
  function computeGroupScore(threats) {
129
134
  let score = 0;
130
135
  let protoHookMediumPoints = 0;
136
+ let dataflowMediumPoints = 0;
131
137
 
132
138
  for (const t of threats) {
133
139
  const weight = _severityWeights[t.severity] || 0;
@@ -138,11 +144,16 @@ function computeGroupScore(threats) {
138
144
  protoHookMediumPoints += weight * factor;
139
145
  continue;
140
146
  }
147
+ if (t.type === 'suspicious_dataflow' && t.severity === 'MEDIUM') {
148
+ dataflowMediumPoints += weight * factor;
149
+ continue;
150
+ }
141
151
 
142
152
  score += weight * factor;
143
153
  }
144
154
 
145
155
  score += Math.min(protoHookMediumPoints, PROTO_HOOK_MEDIUM_CAP);
156
+ score += Math.min(dataflowMediumPoints, DATAFLOW_MEDIUM_CAP);
146
157
  return Math.min(MAX_RISK_SCORE, Math.round(score));
147
158
  }
148
159