muaddib-scanner 2.10.66 → 2.10.68

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.66",
3
+ "version": "2.10.68",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -123,6 +123,25 @@ FEATURE_NAMES = [
123
123
 
124
124
  assert len(FEATURE_NAMES) == 87, f"Expected 87 features, got {len(FEATURE_NAMES)}"
125
125
 
126
+ # Features to exclude: metadata/source-identity proxies that differ between
127
+ # monitor (negatives) and Datadog (positives) for non-behavioral reasons.
128
+ # See corrected retrain plan for full justification of each exclusion.
129
+ EXCLUDED_METADATA = {
130
+ # npm registry metadata — always 0 in Datadog positives (not fetched),
131
+ # 8-13% non-zero in monitor negatives → source leak
132
+ 'package_age_days', 'weekly_downloads', 'version_count',
133
+ 'author_package_count', 'has_repository', 'readme_size',
134
+ # Derived from corrupted npm metadata (age_days, version_count, downloads).
135
+ # Currently zero-variance (always 1.0) but becomes a leak when future
136
+ # records have actual computed values.
137
+ 'reputation_factor',
138
+ # Package-level metadata not from behavioral scan —
139
+ # 88-95% non-zero in negatives, 0% in positives → massive source proxy
140
+ 'unpacked_size_bytes', 'file_count_total',
141
+ # 13% non-zero in negatives, 0% in positives → source proxy
142
+ 'has_tests',
143
+ }
144
+
126
145
 
127
146
  # --- Data loading ---
128
147
 
@@ -300,10 +319,14 @@ def filter_leaky_features(X: pd.DataFrame, y: np.ndarray,
300
319
  retained = []
301
320
  excluded = []
302
321
 
322
+ # Iterate over columns actually present in X (metadata may have been
323
+ # dropped by Step 2a before this function is called).
324
+ available_features = list(X.columns)
325
+
303
326
  print(f"\n {'Feature':<40s} {'Neg%':>6s} {'Pos%':>6s} {'All%':>6s} {'Status'}")
304
327
  print(f" {'-' * 40} {'-' * 6} {'-' * 6} {'-' * 6} {'-' * 8}")
305
328
 
306
- for feat in FEATURE_NAMES:
329
+ for feat in available_features:
307
330
  neg_nonzero = float((X.loc[neg_mask, feat] != 0).sum()) / max(n_neg, 1)
308
331
  pos_nonzero = float((X.loc[pos_mask, feat] != 0).sum()) / max(n_pos, 1)
309
332
  all_nonzero = float((X[feat] != 0).sum()) / max(n_total, 1)
@@ -328,7 +351,7 @@ def filter_leaky_features(X: pd.DataFrame, y: np.ndarray,
328
351
  print(f" {feat:<40s} {neg_nonzero * 100:5.1f}% {pos_nonzero * 100:5.1f}% "
329
352
  f"{all_nonzero * 100:5.1f}% {status}")
330
353
 
331
- print(f"\n Retained: {len(retained)}/{len(FEATURE_NAMES)} features")
354
+ print(f"\n Retained: {len(retained)}/{len(available_features)} features")
332
355
  if excluded:
333
356
  print(f" Excluded ({len(excluded)}): {', '.join(excluded)}")
334
357
 
@@ -336,6 +359,85 @@ def filter_leaky_features(X: pd.DataFrame, y: np.ndarray,
336
359
  return X_filtered, retained
337
360
 
338
361
 
362
+ def source_discrimination_gate(X: pd.DataFrame, y: np.ndarray,
363
+ active_features: list,
364
+ max_accuracy: float = 0.65) -> bool:
365
+ """
366
+ Step 2c: Hard gate — verify that retained behavioral features cannot
367
+ trivially distinguish data source (monitor vs Datadog).
368
+
369
+ Since all negatives come from monitor and all positives from Datadog,
370
+ y IS the source label. A shallow classifier that achieves accuracy > 65%
371
+ on the retained features indicates residual source-identity leaks.
372
+
373
+ Returns: True if gate passes (accuracy <= max_accuracy), False if fails.
374
+ Prints SHAP top 10 of the discriminator to identify offending features.
375
+ """
376
+ print("\n" + "=" * 60)
377
+ print(f"[Step 2c/8] Source discrimination gate (threshold={max_accuracy:.0%})...")
378
+ print("=" * 60)
379
+
380
+ X_active = X[active_features]
381
+
382
+ # 70/30 split with different seed to avoid overlap with main split
383
+ X_tr, X_te, y_tr, y_te = train_test_split(
384
+ X_active, y, test_size=0.3, stratify=y, random_state=99
385
+ )
386
+
387
+ # Shallow model — depth=3, 50 rounds, no class weighting
388
+ # (we want to detect ANY discriminability, not optimize for one class)
389
+ params = {
390
+ 'objective': 'binary:logistic',
391
+ 'eval_metric': 'logloss',
392
+ 'max_depth': 3,
393
+ 'learning_rate': 0.1,
394
+ 'subsample': 0.8,
395
+ 'seed': 99,
396
+ 'verbosity': 0,
397
+ }
398
+
399
+ dtrain = xgb.DMatrix(X_tr, label=y_tr, feature_names=active_features)
400
+ dtest = xgb.DMatrix(X_te, label=y_te, feature_names=active_features)
401
+
402
+ model = xgb.train(params, dtrain, num_boost_round=50)
403
+ probs = model.predict(dtest)
404
+ preds = (probs >= 0.5).astype(int)
405
+ accuracy = float((preds == y_te).mean())
406
+
407
+ p = precision_score(y_te, preds, zero_division=0)
408
+ r = recall_score(y_te, preds, zero_division=0)
409
+
410
+ print(f" Discrimination accuracy: {accuracy:.3f} (P={p:.3f} R={r:.3f})")
411
+
412
+ # SHAP analysis to identify which features drive discrimination
413
+ explainer = shap.TreeExplainer(model)
414
+ shap_values = explainer.shap_values(X_te)
415
+ mean_abs_shap = np.abs(shap_values).mean(axis=0)
416
+ importance = sorted(zip(active_features, mean_abs_shap),
417
+ key=lambda x: x[1], reverse=True)
418
+
419
+ print(f"\n Top 10 features driving source discrimination:")
420
+ for i, (name, val) in enumerate(importance[:10]):
421
+ flag = ""
422
+ # Flag non-behavioral features that shouldn't be discriminative
423
+ if name in ('unpacked_size_bytes', 'file_count_total', 'has_tests',
424
+ 'dep_count', 'dev_dep_count', 'reputation_factor'):
425
+ flag = " *** NON-BEHAVIORAL"
426
+ print(f" {i + 1:2d}. {name:40s} {val:.6f}{flag}")
427
+
428
+ if accuracy <= max_accuracy:
429
+ print(f"\n [GATE PASS] Accuracy {accuracy:.3f} <= {max_accuracy:.3f}")
430
+ print(f" Behavioral features do not trivially encode source identity.")
431
+ return True
432
+ else:
433
+ print(f"\n [GATE FAIL] Accuracy {accuracy:.3f} > {max_accuracy:.3f}")
434
+ print(f" Retained features still encode source identity.")
435
+ print(f" Offending features (exclude and re-run):")
436
+ for name, val in importance[:5]:
437
+ print(f" - {name} (SHAP={val:.6f})")
438
+ return False
439
+
440
+
339
441
  def split_data(X: pd.DataFrame, y: np.ndarray) -> tuple:
340
442
  """
341
443
  Step 3: Stratified 80/20 split.
@@ -693,13 +795,15 @@ def main():
693
795
  help='Path to negatives JSONL (clean/fp labels)')
694
796
  parser.add_argument('--positives', required=True,
695
797
  help='Path to positives JSONL (malicious labels)')
696
- parser.add_argument('--output', default='src/ml/model-trees.js',
697
- help='Output JS file path (default: src/ml/model-trees.js)')
698
- parser.add_argument('--top-features', type=int, default=40,
699
- help='Number of top SHAP features to select (default: 40)')
798
+ parser.add_argument('--output', default='src/ml/model-trees-shadow.js',
799
+ help='Output JS file path (default: src/ml/model-trees-shadow.js)')
800
+ parser.add_argument('--top-features', type=int, default=50,
801
+ help='Number of top SHAP features to select (default: 50)')
700
802
  parser.add_argument('--common-only', action=argparse.BooleanOptionalAction,
701
803
  default=True,
702
804
  help='Only use features with >=1%% non-zero coverage in BOTH sources (default: on)')
805
+ parser.add_argument('--skip-gate', action='store_true',
806
+ help='Skip source discrimination gate (dangerous — use only for debugging)')
703
807
  args = parser.parse_args()
704
808
 
705
809
  # Validate inputs
@@ -716,11 +820,34 @@ def main():
716
820
  # Step 2: Align features
717
821
  X, y, stats = align_features(negatives, positives)
718
822
 
719
- # Step 2b: Filter leaky features
823
+ # Step 2a: Remove known metadata/source-proxy features BEFORE leak filter.
824
+ # These features differ between sources for non-behavioral reasons and would
825
+ # cause the model to learn source identity instead of malicious behavior.
826
+ metadata_cols = [f for f in FEATURE_NAMES if f in EXCLUDED_METADATA]
827
+ X = X.drop(columns=metadata_cols, errors='ignore')
828
+ remaining_features = [f for f in FEATURE_NAMES if f not in EXCLUDED_METADATA]
829
+ print(f"\n [Step 2a] Excluded {len(metadata_cols)} metadata features: "
830
+ f"{', '.join(metadata_cols)}")
831
+ print(f" Remaining: {len(remaining_features)} features")
832
+
833
+ # Step 2b: Filter dead/leaky features (on remaining behavioral features)
720
834
  if args.common_only:
721
835
  X, active_features = filter_leaky_features(X, y)
722
836
  else:
723
- active_features = list(FEATURE_NAMES)
837
+ active_features = list(remaining_features)
838
+
839
+ # Step 2c: Source discrimination gate — HARD STOP if features encode source
840
+ if not args.skip_gate:
841
+ gate_pass = source_discrimination_gate(X, y, active_features)
842
+ if not gate_pass:
843
+ print("\n" + "=" * 60)
844
+ print("ABORTED: Source discrimination gate failed.")
845
+ print("The retained features still encode source identity.")
846
+ print("Add offending features to EXCLUDED_METADATA and re-run.")
847
+ print("=" * 60)
848
+ sys.exit(1)
849
+ else:
850
+ print("\n [Step 2c] Source discrimination gate SKIPPED (--skip-gate)")
724
851
 
725
852
  # Class imbalance weight
726
853
  n_neg = stats['n_neg']
@@ -756,7 +883,8 @@ def main():
756
883
  print("TRAINING COMPLETE")
757
884
  print("=" * 60)
758
885
  print(f" Samples: {n_neg} negatives + {n_pos} positives = {n_neg + n_pos}")
759
- print(f" Features: {len(selected)} selected (from {len(active_features)} active / {len(FEATURE_NAMES)} total)")
886
+ print(f" Features: {len(selected)} selected (from {len(active_features)} active / "
887
+ f"{len(FEATURE_NAMES)} total, {len(EXCLUDED_METADATA)} metadata excluded)")
760
888
  print(f" Threshold: {cv_metrics['threshold']:.3f}")
761
889
  print(f" CV: P={cv_metrics['precision']:.3f} R={cv_metrics['recall']:.3f} F1={cv_metrics['f1']:.3f}")
762
890
  print(f" Holdout: P={holdout_metrics['precision']:.3f} R={holdout_metrics['recall']:.3f} F1={holdout_metrics['f1']:.3f}")
@@ -18,7 +18,6 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const https = require('https');
20
20
  const { acquireRegistrySlot, releaseRegistrySlot } = require('../shared/http-limiter.js');
21
- const { atomicWriteFileSync } = require('./state.js');
22
21
 
23
22
  const DEFAULT_INPUT = path.join(__dirname, '..', '..', 'data', 'ml-training.jsonl');
24
23
  const DEFAULT_OUTPUT = path.join(__dirname, '..', '..', 'data', 'ml-training-relabeled.jsonl');
@@ -191,52 +190,42 @@ async function relabelDataset(options = {}) {
191
190
  const dryRun = options.dryRun || false;
192
191
  const delayMs = options.delayMs != null ? options.delayMs : DEFAULT_DELAY_MS;
193
192
 
194
- // 1. Read records
193
+ // 1. Build package map from input (records freed after block scope)
195
194
  if (!fs.existsSync(inputPath)) {
196
195
  throw new Error(`Input file not found: ${inputPath}`);
197
196
  }
198
- const content = fs.readFileSync(inputPath, 'utf8');
199
- const lines = content.split('\n');
200
- const records = [];
201
- for (let i = 0; i < lines.length; i++) {
202
- const line = lines[i].trim();
203
- if (!line) continue;
204
- try {
205
- records.push({ idx: i, data: JSON.parse(line), raw: lines[i] });
206
- } catch {
207
- records.push({ idx: i, data: null, raw: lines[i] });
208
- }
209
- }
210
-
211
- // 2. Extract unique packages eligible for relabeling
212
- const packageMap = new Map(); // key { name, ecosystem, score, timestamp, indices[] }
213
- for (const rec of records) {
214
- if (!rec.data) continue;
215
- if (!RELABELABLE.has(rec.data.label)) continue;
216
- const key = `${rec.data.ecosystem || 'npm'}/${rec.data.name}`;
217
- if (!packageMap.has(key)) {
218
- packageMap.set(key, {
219
- name: rec.data.name,
220
- ecosystem: rec.data.ecosystem || 'npm',
221
- score: rec.data.score || 0,
222
- timestamp: rec.data.timestamp,
223
- indices: []
224
- });
225
- }
226
- packageMap.get(key).indices.push(rec.idx);
227
- // Use highest score seen for this package
228
- if ((rec.data.score || 0) > packageMap.get(key).score) {
229
- packageMap.get(key).score = rec.data.score;
230
- }
231
- // Use earliest timestamp
232
- if (rec.data.timestamp && (!packageMap.get(key).timestamp || rec.data.timestamp < packageMap.get(key).timestamp)) {
233
- packageMap.get(key).timestamp = rec.data.timestamp;
197
+ let recordCount = 0;
198
+ const packageMap = new Map(); // key → { name, ecosystem, score, timestamp }
199
+ {
200
+ const content = fs.readFileSync(inputPath, 'utf8');
201
+ const lines = content.split('\n');
202
+ for (let i = 0; i < lines.length; i++) {
203
+ const line = lines[i].trim();
204
+ if (!line) continue;
205
+ recordCount++;
206
+ let data;
207
+ try { data = JSON.parse(line); } catch { continue; }
208
+ if (!RELABELABLE.has(data.label)) continue;
209
+ const key = `${data.ecosystem || 'npm'}/${data.name}`;
210
+ if (!packageMap.has(key)) {
211
+ packageMap.set(key, {
212
+ name: data.name,
213
+ ecosystem: data.ecosystem || 'npm',
214
+ score: data.score || 0,
215
+ timestamp: data.timestamp
216
+ });
217
+ }
218
+ const pkg = packageMap.get(key);
219
+ if ((data.score || 0) > pkg.score) pkg.score = data.score;
220
+ if (data.timestamp && (!pkg.timestamp || data.timestamp < pkg.timestamp)) {
221
+ pkg.timestamp = data.timestamp;
222
+ }
234
223
  }
235
- }
224
+ } // content, lines — eligible for GC before registry checks
236
225
 
237
- console.log(`[RELABEL] ${records.length} records, ${packageMap.size} unique packages to check`);
226
+ console.log(`[RELABEL] ${recordCount} records, ${packageMap.size} unique packages to check`);
238
227
 
239
- // 3. Check each package against registry
228
+ // 2. Check each package against registry (crash-resilient)
240
229
  const summary = {
241
230
  checked: 0,
242
231
  relabeled_malicious: 0,
@@ -247,94 +236,115 @@ async function relabelDataset(options = {}) {
247
236
  errors: 0,
248
237
  records_updated: 0
249
238
  };
250
-
251
239
  const labelChanges = new Map(); // packageKey → { label, source }
240
+ let registryError = null;
252
241
 
253
- const total = packageMap.size;
254
- for (const [key, pkg] of packageMap) {
255
- const t0 = Date.now();
256
- let registryStatus;
257
- try {
258
- if (pkg.ecosystem === 'npm') {
259
- registryStatus = await checkNpmStatus(pkg.name, { skipSemaphore: true });
260
- } else if (pkg.ecosystem === 'pypi') {
261
- registryStatus = await checkPyPIStatus(pkg.name);
262
- } else {
263
- summary.unchanged++;
242
+ try {
243
+ const total = packageMap.size;
244
+ for (const [key, pkg] of packageMap) {
245
+ const t0 = Date.now();
246
+ let registryStatus;
247
+ try {
248
+ if (pkg.ecosystem === 'npm') {
249
+ registryStatus = await checkNpmStatus(pkg.name, { skipSemaphore: true });
250
+ } else if (pkg.ecosystem === 'pypi') {
251
+ registryStatus = await checkPyPIStatus(pkg.name);
252
+ } else {
253
+ summary.unchanged++;
254
+ summary.checked++;
255
+ continue;
256
+ }
257
+ } catch (err) {
258
+ summary.errors++;
264
259
  summary.checked++;
260
+ console.log(`[RELABEL] ${key} → error (${Date.now() - t0}ms): ${err.message}`);
265
261
  continue;
266
262
  }
267
- } catch (err) {
268
- summary.errors++;
269
- summary.checked++;
270
- console.log(`[RELABEL] ${key} → error (${Date.now() - t0}ms): ${err.message}`);
271
- continue;
272
- }
273
263
 
274
- if (registryStatus.status === 'error') {
275
- summary.errors++;
276
- summary.checked++;
277
- console.log(`[RELABEL] ${key} → error (${Date.now() - t0}ms): ${registryStatus.detail}`);
278
- if (delayMs > 0) await sleep(delayMs);
279
- continue;
280
- }
264
+ if (registryStatus.status === 'error') {
265
+ summary.errors++;
266
+ summary.checked++;
267
+ console.log(`[RELABEL] ${key} → error (${Date.now() - t0}ms): ${registryStatus.detail}`);
268
+ if (delayMs > 0) await sleep(delayMs);
269
+ continue;
270
+ }
281
271
 
282
- const newLabel = computeNewLabel(pkg, registryStatus);
283
- summary.checked++;
284
- console.log(`[RELABEL] ${key} → ${newLabel ? newLabel.label : 'unchanged'} (${registryStatus.status}, ${Date.now() - t0}ms)`);
272
+ const newLabel = computeNewLabel(pkg, registryStatus);
273
+ summary.checked++;
274
+ console.log(`[RELABEL] ${key} → ${newLabel ? newLabel.label : 'unchanged'} (${registryStatus.status}, ${Date.now() - t0}ms)`);
285
275
 
286
- if (summary.checked % 100 === 0) {
287
- console.log(`[RELABEL] Progress: ${summary.checked}/${total} checked`);
288
- }
276
+ if (summary.checked % 100 === 0) {
277
+ console.log(`[RELABEL] Progress: ${summary.checked}/${total} checked`);
278
+ }
289
279
 
290
- if (newLabel) {
291
- labelChanges.set(key, newLabel);
292
- if (newLabel.label === 'confirmed_malicious') summary.relabeled_malicious++;
293
- else if (newLabel.label === 'confirmed_benign') summary.relabeled_benign++;
294
- else if (newLabel.label === 'likely_benign') summary.relabeled_likely_benign++;
295
- else if (newLabel.label === 'removed_unlabeled') summary.removed_unlabeled++;
280
+ if (newLabel) {
281
+ labelChanges.set(key, newLabel);
282
+ if (newLabel.label === 'confirmed_malicious') summary.relabeled_malicious++;
283
+ else if (newLabel.label === 'confirmed_benign') summary.relabeled_benign++;
284
+ else if (newLabel.label === 'likely_benign') summary.relabeled_likely_benign++;
285
+ else if (newLabel.label === 'removed_unlabeled') summary.removed_unlabeled++;
296
286
 
297
- if (dryRun) {
298
- console.log(`[RELABEL] DRY-RUN: ${key} → ${newLabel.label} (${newLabel.source}, score=${pkg.score}, status=${registryStatus.status})`);
287
+ if (dryRun) {
288
+ console.log(`[RELABEL] DRY-RUN: ${key} → ${newLabel.label} (${newLabel.source}, score=${pkg.score}, status=${registryStatus.status})`);
289
+ }
290
+ } else {
291
+ summary.unchanged++;
299
292
  }
300
- } else {
301
- summary.unchanged++;
302
- }
303
293
 
304
- if (delayMs > 0) await sleep(delayMs);
305
- }
306
-
307
- // 4. Apply label changes to records
308
- const outputLines = [];
309
- for (const rec of records) {
310
- if (!rec.data) {
311
- outputLines.push(rec.raw);
312
- continue;
313
- }
314
- const key = `${rec.data.ecosystem || 'npm'}/${rec.data.name}`;
315
- const change = labelChanges.get(key);
316
- if (change && RELABELABLE.has(rec.data.label)) {
317
- rec.data.label = change.label;
318
- rec.data.relabel_source = change.source;
319
- rec.data.relabel_timestamp = new Date().toISOString();
320
- outputLines.push(JSON.stringify(rec.data));
321
- summary.records_updated++;
322
- } else {
323
- outputLines.push(rec.raw);
294
+ if (delayMs > 0) await sleep(delayMs);
324
295
  }
296
+ } catch (err) {
297
+ registryError = err;
298
+ console.error(`[RELABEL] Registry check interrupted at ${summary.checked}/${packageMap.size}: ${err.message}`);
325
299
  }
326
300
 
327
- // 5. Write output
301
+ // 3. Stream output: re-read input, apply collected labelChanges, write line by line
328
302
  if (!dryRun) {
329
303
  const dir = path.dirname(outputPath);
330
304
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
331
- atomicWriteFileSync(outputPath, outputLines.join('\n'));
332
- console.log(`[RELABEL] Written ${outputLines.length} records to ${path.basename(outputPath)} (${summary.records_updated} updated)`);
305
+ const tmpPath = outputPath + '.tmp';
306
+ const ws = fs.createWriteStream(tmpPath);
307
+ const relabelTs = new Date().toISOString();
308
+ const inputContent = fs.readFileSync(inputPath, 'utf8');
309
+ const inputLines = inputContent.split('\n');
310
+ let linesWritten = 0;
311
+ for (let i = 0; i < inputLines.length; i++) {
312
+ const raw = inputLines[i];
313
+ const trimmed = raw.trim();
314
+ let outputLine = raw;
315
+ if (trimmed) {
316
+ try {
317
+ const data = JSON.parse(trimmed);
318
+ const key = `${data.ecosystem || 'npm'}/${data.name}`;
319
+ const change = labelChanges.get(key);
320
+ if (change && RELABELABLE.has(data.label)) {
321
+ data.label = change.label;
322
+ data.relabel_source = change.source;
323
+ data.relabel_timestamp = relabelTs;
324
+ outputLine = JSON.stringify(data);
325
+ summary.records_updated++;
326
+ }
327
+ } catch { /* unparseable line — keep as-is */ }
328
+ linesWritten++;
329
+ }
330
+ ws.write(outputLine);
331
+ if (i < inputLines.length - 1) ws.write('\n');
332
+ }
333
+ await new Promise((resolve, reject) => {
334
+ ws.on('finish', resolve);
335
+ ws.on('error', reject);
336
+ ws.end();
337
+ });
338
+ fs.renameSync(tmpPath, outputPath);
339
+ console.log(`[RELABEL] Written ${linesWritten} records to ${path.basename(outputPath)} (${summary.records_updated} updated${registryError ? ', PARTIAL' : ''})`);
333
340
  } else {
334
- console.log(`[RELABEL] DRY-RUN complete: ${summary.records_updated} records would be updated`);
341
+ console.log(`[RELABEL] DRY-RUN complete: ${summary.records_updated} records would be updated${registryError ? ' (PARTIAL)' : ''}`);
335
342
  }
336
343
 
337
344
  console.log(`[RELABEL] Summary: ${summary.relabeled_malicious} malicious, ${summary.relabeled_benign} benign, ${summary.relabeled_likely_benign} likely_benign, ${summary.removed_unlabeled} removed_unlabeled, ${summary.unchanged} unchanged, ${summary.errors} errors`);
345
+ if (registryError) {
346
+ console.error(`[RELABEL] WARNING: Partial results — registry check failed after ${summary.checked}/${packageMap.size} packages`);
347
+ }
338
348
 
339
349
  return summary;
340
350
  }
@@ -673,6 +673,12 @@ const PLAYBOOKS = {
673
673
  'Analyser le callback du timer pour identifier le payload retarde. ' +
674
674
  'Si delai > 24h: fort indicateur de time-bomb malware. NE PAS installer.',
675
675
 
676
+ timer_delayed_payload:
677
+ 'Timer avec delai >= 60s contenant un sink dangereux (eval/exec/spawn) dans le callback. ' +
678
+ 'Technique d\'evasion temporelle: le payload attend que les sandboxes timeout avant de s\'activer. ' +
679
+ 'Analyser le contenu du callback: rechercher exfiltration de credentials, reverse shell, ou telechargement de payload. ' +
680
+ 'Si delai >= 15min: forte probabilite de malware. NE PAS installer.',
681
+
676
682
  npm_publish_worm:
677
683
  'CRITIQUE: exec("npm publish") detecte — propagation worm. Le code utilise des tokens npm voles ' +
678
684
  'pour publier des versions infectees des packages de la victime. Technique Shai-Hulud 1.0 et 2.0. ' +
@@ -2194,6 +2194,18 @@ const RULES = {
2194
2194
  references: ['https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/apply'],
2195
2195
  mitre: 'T1059'
2196
2196
  },
2197
+ timer_delayed_payload: {
2198
+ id: 'MUADDIB-AST-085',
2199
+ name: 'Timer Delayed Payload',
2200
+ severity: 'HIGH',
2201
+ confidence: 'high',
2202
+ description: 'setTimeout/setInterval avec delai >= 60s contenant un sink dangereux (eval/exec/spawn/Function) dans le callback. Evasion temporelle: le payload s\'active apres le timeout des sandboxes. Technique PhantomRaven/timer-bomb-exfil.',
2203
+ references: [
2204
+ 'https://attack.mitre.org/techniques/T1497/003/',
2205
+ 'https://www.sonatype.com/blog/phantomraven-supply-chain-attack'
2206
+ ],
2207
+ mitre: 'T1497.003'
2208
+ },
2197
2209
  lifecycle_missing_script: {
2198
2210
  id: 'MUADDIB-PKG-017',
2199
2211
  name: 'Phantom Lifecycle Script',
@@ -47,7 +47,8 @@ const {
47
47
  extractStringValueDeep,
48
48
  hasOnlyStringLiteralArgs,
49
49
  hasDecodeArg,
50
- containsDecodePattern
50
+ containsDecodePattern,
51
+ resolveNumericExpression
51
52
  } = require('./helpers.js');
52
53
 
53
54
  function handleCallExpression(node, ctx) {
@@ -1046,9 +1047,7 @@ function handleCallExpression(node, ctx) {
1046
1047
  if (node.arguments.length >= 2) {
1047
1048
  const delayArg = node.arguments[1];
1048
1049
  let delayMs = null;
1049
- if (delayArg.type === 'Literal' && typeof delayArg.value === 'number') {
1050
- delayMs = delayArg.value;
1051
- }
1050
+ delayMs = resolveNumericExpression(delayArg);
1052
1051
  if (delayMs !== null && delayMs > 3600000) { // > 1 hour
1053
1052
  const hours = (delayMs / 3600000).toFixed(1);
1054
1053
  ctx.threats.push({
@@ -1058,6 +1057,36 @@ function handleCallExpression(node, ctx) {
1058
1057
  file: ctx.relFile
1059
1058
  });
1060
1059
  }
1060
+
1061
+ // timer_delayed_payload: delay >= 60s + dangerous sink in callback body
1062
+ if (delayMs !== null && delayMs >= 60000) {
1063
+ const callback = node.arguments[0];
1064
+ if (callback && (callback.type === 'ArrowFunctionExpression' || callback.type === 'FunctionExpression')) {
1065
+ const cbSrc = callback.start !== undefined && callback.end !== undefined
1066
+ ? ctx._sourceCode?.slice(callback.start, callback.end) : '';
1067
+ if (cbSrc) {
1068
+ const hasDangerousSink =
1069
+ /\beval\s*\(/.test(cbSrc) ||
1070
+ /\bnew\s+Function\s*\(/.test(cbSrc) ||
1071
+ /\b(execSync|spawn|spawnSync)\s*\(/.test(cbSrc) ||
1072
+ /(?<!\.)\bexec\s*\(/.test(cbSrc) ||
1073
+ /\brequire\s*\(\s*['"](?:node:)?child_process['"]\s*\)/.test(cbSrc) ||
1074
+ /\bModule\._compile\s*\(/.test(cbSrc);
1075
+ if (hasDangerousSink) {
1076
+ const delayDesc = delayMs >= 3600000
1077
+ ? `${(delayMs / 3600000).toFixed(1)}h`
1078
+ : `${(delayMs / 60000).toFixed(0)}min`;
1079
+ ctx.hasTimerDelayedPayload = true;
1080
+ ctx.threats.push({
1081
+ type: 'timer_delayed_payload',
1082
+ severity: delayMs >= 900000 ? 'CRITICAL' : 'HIGH',
1083
+ message: `${callName}() with ${delayDesc} delay (${delayMs}ms) contains dangerous sink in callback — time-delayed payload execution for sandbox evasion.`,
1084
+ file: ctx.relFile
1085
+ });
1086
+ }
1087
+ }
1088
+ }
1089
+ }
1061
1090
  }
1062
1091
  }
1063
1092
 
@@ -151,6 +151,35 @@ function resolveStringConcatWithVars(node, stringVarValues) {
151
151
  return null;
152
152
  }
153
153
 
154
+ /**
155
+ * Recursively resolve a numeric expression AST node to a concrete number.
156
+ * Handles: Literal numbers, BinaryExpression (*, +, -, /), UnaryExpression (-).
157
+ * Returns null if the expression contains non-resolvable nodes.
158
+ *
159
+ * Examples: 60000 → 60000, 60*1000 → 60000, 10*60*1000 → 600000
160
+ */
161
+ function resolveNumericExpression(node) {
162
+ if (!node) return null;
163
+ if (node.type === 'Literal' && typeof node.value === 'number') return node.value;
164
+ if (node.type === 'UnaryExpression' && node.operator === '-') {
165
+ const val = resolveNumericExpression(node.argument);
166
+ return val !== null ? -val : null;
167
+ }
168
+ if (node.type === 'BinaryExpression') {
169
+ const left = resolveNumericExpression(node.left);
170
+ const right = resolveNumericExpression(node.right);
171
+ if (left === null || right === null) return null;
172
+ switch (node.operator) {
173
+ case '*': return left * right;
174
+ case '+': return left + right;
175
+ case '-': return left - right;
176
+ case '/': return right !== 0 ? left / right : null;
177
+ default: return null;
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+
154
183
  /**
155
184
  * Extract string value from a node, including BinaryExpression resolution.
156
185
  * Falls back to extractStringValue if concat resolution fails.
@@ -253,6 +282,7 @@ module.exports = {
253
282
  countConcatOperands,
254
283
  resolveStringConcat,
255
284
  resolveStringConcatWithVars,
285
+ resolveNumericExpression,
256
286
  extractStringValueDeep,
257
287
  hasOnlyStringLiteralArgs,
258
288
  hasDecodeArg,
@@ -156,6 +156,7 @@ function analyzeFile(content, filePath, basePath) {
156
156
  hasDnsRequire: /\brequire\s*\(\s*['"]dns['"]\s*\)/.test(content) || /\bdns\s*\.\s*resolve/.test(content),
157
157
  hasBase64Encode: /\.toString\s*\(\s*['"]base64(url)?['"]\s*\)/.test(content),
158
158
  hasDnsLoop: false, // set when dns call inside loop context detected
159
+ hasTimerDelayedPayload: false, // set when setTimeout/setInterval >= 60s has dangerous sink in callback
159
160
  // SANDWORM_MODE P2: LLM API key harvesting
160
161
  llmApiKeyCount: 0,
161
162
  // Wave 4: path variable tracking for git hooks and IDE config injection