muaddib-scanner 2.11.18 → 2.11.19

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.11.18",
3
+ "version": "2.11.19",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -831,7 +831,7 @@ function analyzeFile(content, filePath, basePath) {
831
831
  const envVar = node.property?.name || '';
832
832
  if (isSensitiveEnv(envVar)) {
833
833
  sources.push({
834
- type: 'env_read',
834
+ type: isCredentialEnv(envVar) ? 'credential_env_read' : 'env_read',
835
835
  name: envVar,
836
836
  line: node.loc?.start?.line,
837
837
  taint_tracked: true
@@ -857,7 +857,7 @@ function analyzeFile(content, filePath, basePath) {
857
857
  const envVar = node.property?.name || '';
858
858
  if (isSensitiveEnv(envVar)) {
859
859
  sources.push({
860
- type: 'env_read',
860
+ type: isCredentialEnv(envVar) ? 'credential_env_read' : 'env_read',
861
861
  name: envVar,
862
862
  line: node.loc?.start?.line
863
863
  });
@@ -948,7 +948,7 @@ function analyzeFile(content, filePath, basePath) {
948
948
  if (taint && EXFIL_PRONE_MODULES.has(taint.source)) exfilProneInScope.push(taint.source);
949
949
  }
950
950
  if (exfilProneInScope.length > 0 &&
951
- sources.some(s => s.type === 'env_read' || s.type === 'credential_read')) {
951
+ sources.some(s => s.type === 'env_read' || s.type === 'credential_env_read' || s.type === 'credential_read')) {
952
952
  const moduleList = [...new Set(exfilProneInScope)].join(', ');
953
953
  const firstSourceLine = sources.find(s => s.line)?.line || 0;
954
954
  threats.push({
@@ -1028,12 +1028,16 @@ function analyzeFile(content, filePath, basePath) {
1028
1028
  }
1029
1029
 
1030
1030
  // Graduation: HIGH → MEDIUM for env/telemetry-only sources (no credential file reads,
1031
- // no fingerprint reads, no command output). Distant env/telemetry network_send
1032
- // is the dominant FP pattern (SDK/API usage, binary wrappers, config libraries).
1033
- // Real credential exfiltration uses credential_read or fingerprint_read sources.
1031
+ // no fingerprint reads, no command output, no credential-tier env vars). Distant
1032
+ // env/telemetry → network_send is the dominant FP pattern (SDK/API usage, binary
1033
+ // wrappers, config libraries). Real credential exfiltration uses credential_read,
1034
+ // fingerprint_read, or credential_env_read sources (audit 2026-05 DF-C4: NPM_TOKEN,
1035
+ // GITHUB_TOKEN, AWS_SECRET_* now classified as credential_env_read upstream and
1036
+ // retained at HIGH instead of downgrading to MEDIUM with the rest of env_read).
1034
1037
  if (severity === 'HIGH') {
1035
1038
  const hasHighRiskSource = sources.some(s =>
1036
- s.type === 'credential_read' || s.type === 'fingerprint_read' || s.type === 'command_output'
1039
+ s.type === 'credential_read' || s.type === 'fingerprint_read' ||
1040
+ s.type === 'command_output' || s.type === 'credential_env_read'
1037
1041
  );
1038
1042
  if (!hasHighRiskSource) {
1039
1043
  severity = 'MEDIUM';
@@ -1169,4 +1173,29 @@ function isSensitiveEnv(name) {
1169
1173
  return sensitive.some(s => upper.includes(s));
1170
1174
  }
1171
1175
 
1176
+ // Audit 2026-05 DF-C4: credential-tier env vars distinguished from generic env_read.
1177
+ // These represent authentication material (NPM_TOKEN, GITHUB_TOKEN, AWS_SECRET_ACCESS_KEY,
1178
+ // STRIPE_API_KEY etc.) — strictly narrower than isSensitiveEnv. Sources of this type
1179
+ // participate in hasHighRiskSource so credential exfil patterns are NOT downgraded by the
1180
+ // HIGH→MEDIUM graduation. System identity vars (HOME, USER) remain plain env_read since
1181
+ // they are fingerprinting signals, not credentials.
1182
+ const KNOWN_CREDENTIAL_ENV_VARS = new Set([
1183
+ 'NPM_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN', 'NODE_AUTH_TOKEN',
1184
+ 'CIRCLE_TOKEN', 'GITLAB_TOKEN', 'CARGO_REGISTRY_TOKEN', 'PYPI_TOKEN',
1185
+ 'GOOGLE_APPLICATION_CREDENTIALS', 'AZURE_CLIENT_SECRET',
1186
+ 'SENTRY_AUTH_TOKEN', 'NPM_AUTH_TOKEN', 'NPM_CONFIG_AUTHTOKEN'
1187
+ ]);
1188
+
1189
+ const CREDENTIAL_ENV_SUFFIX_RE = /(?:^|_)(?:TOKEN|SECRET|PASSWORD|PASSPHRASE|CREDENTIAL|CREDENTIALS|API_KEY|ACCESS_KEY|ACCESS_KEY_ID|SECRET_KEY|PRIVATE_KEY|SIGNING_KEY|SESSION_TOKEN|REFRESH_TOKEN|AUTH_TOKEN)$/;
1190
+
1191
+ function isCredentialEnv(name) {
1192
+ const upper = name.toUpperCase();
1193
+ // System identity vars are fingerprinting, not credentials
1194
+ if (SYSTEM_IDENTITY_ENVS.has(upper)) return false;
1195
+ // Public keys are not credentials (e.g., SSH_PUBLIC_KEY, GPG_PUBLIC_KEY)
1196
+ if (upper.includes('PUBLIC_KEY') || upper.includes('PUBKEY')) return false;
1197
+ if (KNOWN_CREDENTIAL_ENV_VARS.has(upper)) return true;
1198
+ return CREDENTIAL_ENV_SUFFIX_RE.test(upper);
1199
+ }
1200
+
1172
1201
  module.exports = { analyzeDataFlow };
package/src/scoring.js CHANGED
@@ -183,13 +183,11 @@ function isPackageLevelThreat(threat) {
183
183
  * @param {Array} threats - array of threat objects (after FP reductions)
184
184
  * @returns {number} score 0-100
185
185
  */
186
- // Hybrid v3 Phase 3: when enabled, threats tagged with replacedByCompound
187
- // (their compound has fired and represents their score) contribute 0 to the
188
- // group score. Avoids the additive double-count of compound + constituents.
189
- const _COMPOUND_REPLACE_ENABLED = () => process.env.MUADDIB_COMPOUND_REPLACE === '1';
190
- function _isReplacedByCompound(t) {
191
- return _COMPOUND_REPLACE_ENABLED() && t.replacedByCompound;
192
- }
186
+ // Hybrid v3 Phase 3: threats tagged with replacedByCompound (their compound has
187
+ // fired and represents their score) contribute 0 to the group score. Avoids the
188
+ // additive double-count of compound + constituents. Audit 2026-05 SC-C1: the
189
+ // previous MUADDIB_COMPOUND_REPLACE env-var gate is removed — the tag posed by
190
+ // applyCompoundBoosts is now honored unconditionally.
193
191
 
194
192
  function computeGroupScore(threats) {
195
193
  // Score decay default ON since v2.11.9 (FPR plan Chantier 1). Opt-out: MUADDIB_DECAY=0.
@@ -199,7 +197,7 @@ function computeGroupScore(threats) {
199
197
  let dataflowMediumPoints = 0;
200
198
 
201
199
  for (const t of threats) {
202
- if (_isReplacedByCompound(t)) continue;
200
+ if (t.replacedByCompound) continue;
203
201
  const weight = _severityWeights[t.severity] || 0;
204
202
  const rule = getRule(t.type);
205
203
  const factor = CONFIDENCE_FACTORS[rule.confidence] || 1.0;
@@ -257,7 +255,7 @@ function computeGroupScoreDecay(threats) {
257
255
  const typeBuckets = new Map();
258
256
 
259
257
  for (const t of threats) {
260
- if (_isReplacedByCompound(t)) continue;
258
+ if (t.replacedByCompound) continue;
261
259
  const weight = _severityWeights[t.severity] || 0;
262
260
  const rule = getRule(t.type);
263
261
  const factor = CONFIDENCE_FACTORS[rule.confidence] || 1.0;
@@ -303,7 +301,11 @@ const FP_COUNT_THRESHOLDS = {
303
301
  // Real malware uses 1-2 targeted Function() calls.
304
302
  dangerous_call_function: { maxCount: 5, to: 'LOW' },
305
303
  require_cache_poison: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
306
- suspicious_dataflow: { maxCount: 3, to: 'LOW' },
304
+ // Audit 2026-05 SC-C2: floorEligible: true opts suspicious_dataflow into the
305
+ // dilution floor without adding a `from` constraint (which would block MEDIUM
306
+ // count-threshold downgrades). Restores 1 instance at original severity so an
307
+ // attacker can't dilute real exfil flows by injecting benign data flows.
308
+ suspicious_dataflow: { maxCount: 3, to: 'LOW', floorEligible: true },
307
309
  obfuscation_detected: { maxCount: 3, to: 'LOW' },
308
310
  module_compile_dynamic: { maxCount: 3, from: 'HIGH', to: 'LOW' },
309
311
  module_compile: { maxCount: 3, from: 'HIGH', to: 'LOW' },
@@ -322,8 +324,10 @@ const FP_COUNT_THRESHOLDS = {
322
324
  // P6: HTTP client libraries (undici, aws-sdk, nodemailer, jsdom) parse Authorization/Bearer headers
323
325
  // with 3+ credential regexes. Real harvesters use 1-2 targeted regexes.
324
326
  // Audit v3: removed `from` constraint — ALL severity levels downgraded when count > 2.
325
- // This eliminates the dilution floor (floor requires `from` field) for complete suppression.
326
- credential_regex_harvest: { maxCount: 2, to: 'LOW' },
327
+ // Audit 2026-05 SC-C2: floorEligible: true restores 1 instance at original
328
+ // severity. Without it an attacker injects 3+ benign header regexes (Authorization,
329
+ // Cookie, X-Forwarded-For) and downgrades all real exfil regexes to LOW.
330
+ credential_regex_harvest: { maxCount: 2, to: 'LOW', floorEligible: true },
327
331
  // P7→Audit v3: Config frameworks (pm2, nx, dotenv, aws-sdk, oclif) read 5+ env vars — not credential theft.
328
332
  // Real stealers access 1-4 targeted env vars. Count >4 = config loader pattern.
329
333
  // Lowered from 10→4 for better FP reduction. B5 network_sink_immunity protects genuine exfiltration.
@@ -1015,16 +1019,16 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps, re
1015
1019
 
1016
1020
  // Dilution floor: retain at least one instance at original severity per type
1017
1021
  // to prevent complete count-threshold dilution by injected benign patterns.
1018
- // Only applies to types with low maxCount (≤3) and a severity constraint (from field),
1019
- // where injection of benign patterns is feasible. High-count types (dynamic_require,
1020
- // env_access) and unconstrained types (suspicious_dataflow) represent legitimate
1021
- // framework patterns and should allow full downgrade.
1022
+ // Applies to types with low maxCount (≤3) AND either a `from` severity
1023
+ // constraint OR an explicit `floorEligible: true` opt-in (audit 2026-05 SC-C2).
1024
+ // High-count types (dynamic_require, env_access) represent legitimate framework
1025
+ // patterns and remain ineligible (no floor → full downgrade allowed).
1022
1026
  const restoredTypes = new Set();
1023
1027
  for (const t of threats) {
1024
1028
  const lastReduction = t.reductions?.find(r => r.rule === 'count_threshold');
1025
1029
  if (lastReduction && !restoredTypes.has(t.type)) {
1026
1030
  const rule = FP_COUNT_THRESHOLDS[t.type];
1027
- if (rule && rule.from && rule.maxCount <= 3) {
1031
+ if (rule && (rule.from || rule.floorEligible) && rule.maxCount <= 3) {
1028
1032
  t.severity = lastReduction.from;
1029
1033
  t.reductions = t.reductions.filter(r => r.rule !== 'count_threshold');
1030
1034
  t.reductions.push({ rule: 'count_threshold_floor', note: 'retained one instance at original severity' });