muaddib-scanner 2.11.118 → 2.11.119
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 +1 -1
- package/{self-scan-v2.11.118.json → self-scan-v2.11.119.json} +1 -1
- package/src/monitor/daemon.js +18 -2
- package/src/monitor/deferred-sandbox.js +87 -21
- package/src/monitor/ingestion.js +1 -0
- package/src/monitor/spill.js +7 -1
- package/src/scanner/ast-detectors/handle-import-expression.js +71 -4
- package/src/scanner/ast-detectors/handle-post-walk.js +6 -1
- package/src/scanner/dataflow.js +34 -0
- package/src/scoring.js +35 -6
- package/src/sdk-destination.js +40 -0
package/package.json
CHANGED
package/src/monitor/daemon.js
CHANGED
|
@@ -9,7 +9,7 @@ const { setVerboseMode, isSandboxEnabled, isCanaryEnabled, isLlmDetectiveEnabled
|
|
|
9
9
|
const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, isDailyReportDue, atomicWriteFileSync, saveNpmSeq, ALERTS_FILE, runStateMigrations, loadRecentlyScanned, saveRecentlyScanned } = require('./state.js');
|
|
10
10
|
const { isTemporalEnabled, isTemporalAstEnabled, isTemporalPublishEnabled, isTemporalMaintainerEnabled } = require('./temporal.js');
|
|
11
11
|
const { pendingGrouped, flushScopeGroup, sendDailyReport, redeliverPendingReportOnBoot, alertedPackageRules, ALERTED_PACKAGES_MAX: MAX_ALERTED_PACKAGES } = require('./webhook.js');
|
|
12
|
-
const { poll, getPollBackoffMs } = require('./ingestion.js');
|
|
12
|
+
const { poll, getPollBackoffMs, SOFT_BACKPRESSURE_THRESHOLD } = require('./ingestion.js');
|
|
13
13
|
const { ensureWorkers, drainWorkers, getTargetConcurrency, setTargetConcurrency, getActiveWorkers, terminateAllWorkers, getInFlightItems, computeInterruptDisposition } = require('./queue.js');
|
|
14
14
|
const { computeTarget, ADJUST_INTERVAL_MS, BASE_CONCURRENCY } = require('./adaptive-concurrency.js');
|
|
15
15
|
const { startHealthcheck } = require('./healthcheck.js');
|
|
@@ -42,9 +42,25 @@ const SHUTDOWN_DRAIN_MAX_MS = (() => {
|
|
|
42
42
|
return Number.isFinite(v) && v > 0 ? v : 20_000;
|
|
43
43
|
})();
|
|
44
44
|
|
|
45
|
+
// Drain ceiling (marge): re-ingest from the spill backlog as long as the live
|
|
46
|
+
// queue stays a safe margin BELOW the ingestion backpressure point. The old
|
|
47
|
+
// default (500) was unreachable in steady state — the live queue structurally
|
|
48
|
+
// sits in the thousands (μ scan ≈ λ ingest in active hours), so the backlog
|
|
49
|
+
// drained ~never and grew toward its cap (a one-way street). Tying the ceiling
|
|
50
|
+
// to SOFT_BACKPRESSURE_THRESHOLD makes the drain a self-throttling trickle: it
|
|
51
|
+
// fires during any non-congested window (pressure NONE + headroom) and stops as
|
|
52
|
+
// the queue approaches the point where ingestion would pause anyway, so the
|
|
53
|
+
// backlog never starves fresh ingestion. Env-tunable for live ops.
|
|
54
|
+
const SPILL_DRAIN_MARGIN = (() => {
|
|
55
|
+
const v = parseInt(process.env.MUADDIB_SPILL_DRAIN_MARGIN, 10);
|
|
56
|
+
return Number.isFinite(v) && v > 0 ? v : 5_000;
|
|
57
|
+
})();
|
|
45
58
|
const SPILL_DRAIN_THRESHOLD = (() => {
|
|
46
59
|
const v = parseInt(process.env.MUADDIB_SPILL_DRAIN_THRESHOLD, 10);
|
|
47
|
-
|
|
60
|
+
if (Number.isFinite(v) && v > 0) return v;
|
|
61
|
+
// Default: a fixed margin below backpressure (30K - 5K = 25K). Clamp to >= 1
|
|
62
|
+
// in case a future backpressure value is smaller than the margin.
|
|
63
|
+
return Math.max(1, SOFT_BACKPRESSURE_THRESHOLD - SPILL_DRAIN_MARGIN);
|
|
48
64
|
})();
|
|
49
65
|
const SPILL_DRAIN_BATCH = (() => {
|
|
50
66
|
const v = parseInt(process.env.MUADDIB_SPILL_DRAIN_BATCH, 10);
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* Items are sorted by riskScore DESC (highest-risk first) to defend
|
|
7
7
|
* against queue-poisoning attacks.
|
|
8
8
|
*
|
|
9
|
-
* The worker owns a dedicated sandbox
|
|
10
|
-
* completely independent from the shared semaphore
|
|
11
|
-
* This guarantees the deferred worker can always
|
|
12
|
-
* how many main-path sandboxes are running
|
|
13
|
-
*
|
|
9
|
+
* The worker owns a dedicated POOL of sandbox slots (DEFERRED_SANDBOX_SLOTS,
|
|
10
|
+
* _deferredSlotsActive) that is completely independent from the shared semaphore
|
|
11
|
+
* used by the synchronous path. This guarantees the deferred worker can always
|
|
12
|
+
* process, regardless of how many main-path sandboxes are running, and runs
|
|
13
|
+
* several items concurrently so the queue actually drains (a single slot
|
|
14
|
+
* serialized all T1a deep sandboxes and the queue stayed permanently full).
|
|
14
15
|
*/
|
|
15
16
|
const fs = require('fs');
|
|
16
17
|
const path = require('path');
|
|
@@ -32,10 +33,23 @@ const DEFERRED_STATE_FILE = path.join(__dirname, '..', '..', 'data', 'deferred-q
|
|
|
32
33
|
// slot. HIGH=10 pts is the intended T1b floor — values below 5 are LOW-only
|
|
33
34
|
// aggregates which carry no actionable sandbox signal.
|
|
34
35
|
const DEFERRED_MIN_SCORE = 5;
|
|
35
|
-
// Hard ceiling on a single deferred sandbox run so
|
|
36
|
-
//
|
|
37
|
-
//
|
|
36
|
+
// Hard ceiling on a single deferred sandbox run so a deferred slot can never
|
|
37
|
+
// wedge. maxRuns=1 self-bounds at ~SINGLE_RUN_TIMEOUT (90s) + the sandbox
|
|
38
|
+
// watchdog grace; this AbortController is belt-and-suspenders.
|
|
38
39
|
const DEFERRED_SANDBOX_TIMEOUT_MS = 150_000;
|
|
40
|
+
// Number of CONCURRENT deferred sandbox runs. The old design used a single
|
|
41
|
+
// boolean slot (1 at a time), which serialized ALL deferred T1a deep sandboxes
|
|
42
|
+
// — measured at ~1 run / several minutes, so the queue (cap DEFERRED_QUEUE_MAX)
|
|
43
|
+
// sat permanently full with items aging out at TTL. Phase 3 routed T1a's sandbox
|
|
44
|
+
// here AND bypasses the shared semaphore, so the main pool (MUADDIB_SANDBOX_CONCURRENCY)
|
|
45
|
+
// was sitting idle while everything queued behind one deferred slot. This pool
|
|
46
|
+
// uses that idle capacity. Default 3 (conservative under the typical 4-slot main
|
|
47
|
+
// pool); each gVisor container is ~512 MB, so 3 ≈ 1.5 GB — keep an eye on host
|
|
48
|
+
// RSS if raised. Env-tunable for live ops.
|
|
49
|
+
const DEFERRED_SANDBOX_SLOTS = (() => {
|
|
50
|
+
const v = parseInt(process.env.MUADDIB_DEFERRED_SANDBOX_SLOTS, 10);
|
|
51
|
+
return Number.isFinite(v) && v >= 1 ? v : 3;
|
|
52
|
+
})();
|
|
39
53
|
|
|
40
54
|
// Tier priority for the deferred queue. Phase 3 routes T1a's sandbox here (async)
|
|
41
55
|
// instead of block-waiting a scan worker, so T1a is the highest-confidence tier and
|
|
@@ -61,7 +75,10 @@ const _deferredQueue = [];
|
|
|
61
75
|
const _deferredSeen = new Set(); // name@version dedup
|
|
62
76
|
let _workerHandle = null;
|
|
63
77
|
let _stats = null; // reference to shared stats object
|
|
64
|
-
let
|
|
78
|
+
let _deferredSlotsActive = 0; // Concurrent deferred sandbox runs in flight (0..DEFERRED_SANDBOX_SLOTS)
|
|
79
|
+
// Indirection so tests can inject a controllable async sandbox without Docker
|
|
80
|
+
// (the concurrency contract is verified behaviorally, not by source-grep).
|
|
81
|
+
let _runSandboxFn = runSandbox;
|
|
65
82
|
|
|
66
83
|
// ── Queue management ──
|
|
67
84
|
|
|
@@ -204,8 +221,11 @@ async function processDeferredItem(stats) {
|
|
|
204
221
|
|
|
205
222
|
if (_deferredQueue.length === 0) return null;
|
|
206
223
|
|
|
207
|
-
// 2.
|
|
208
|
-
|
|
224
|
+
// 2. Pool slot check — completely independent from main semaphore. The
|
|
225
|
+
// synchronous prefix below (shift + increment) runs before the first await,
|
|
226
|
+
// so processDeferredBatch can launch several of these in a tight loop without
|
|
227
|
+
// over-subscribing: each increment is visible to the next iteration.
|
|
228
|
+
if (_deferredSlotsActive >= DEFERRED_SANDBOX_SLOTS) {
|
|
209
229
|
if (stats) stats.deferredSkipped = (stats.deferredSkipped || 0) + 1;
|
|
210
230
|
return null;
|
|
211
231
|
}
|
|
@@ -215,10 +235,10 @@ async function processDeferredItem(stats) {
|
|
|
215
235
|
const key = `${item.name}@${item.version}`;
|
|
216
236
|
_deferredSeen.delete(key);
|
|
217
237
|
|
|
218
|
-
console.log(`[DEFERRED] PROCESSING: ${key} (tier=${_tierLabel(item.tier)}, score=${item.riskScore}, retries=${item.retries})`);
|
|
238
|
+
console.log(`[DEFERRED] PROCESSING: ${key} (tier=${_tierLabel(item.tier)}, score=${item.riskScore}, retries=${item.retries}, slots=${_deferredSlotsActive + 1}/${DEFERRED_SANDBOX_SLOTS})`);
|
|
219
239
|
|
|
220
|
-
// 4. Run sandbox on
|
|
221
|
-
|
|
240
|
+
// 4. Run sandbox on a pool slot (bypasses shared semaphore)
|
|
241
|
+
_deferredSlotsActive++;
|
|
222
242
|
let sandboxResult;
|
|
223
243
|
const ac = new AbortController();
|
|
224
244
|
const deadline = setTimeout(() => ac.abort(), DEFERRED_SANDBOX_TIMEOUT_MS);
|
|
@@ -230,7 +250,7 @@ async function processDeferredItem(stats) {
|
|
|
230
250
|
// single-run (maxRuns=1, ~90s vs ~270s) for fast deferred-queue drain.
|
|
231
251
|
const maxRuns = item.tier === '1a' ? undefined : 1;
|
|
232
252
|
markSandboxed(item.name); // stamp for sandbox-revalidation cadence (matches the synchronous path)
|
|
233
|
-
sandboxResult = await
|
|
253
|
+
sandboxResult = await _runSandboxFn(item.name, { canary, skipSemaphore: true, maxRuns, signal: ac.signal });
|
|
234
254
|
console.log(`[DEFERRED] SANDBOX COMPLETE: ${key} -> score=${sandboxResult.score}, severity=${sandboxResult.severity}`);
|
|
235
255
|
} catch (err) {
|
|
236
256
|
console.error(`[DEFERRED] SANDBOX ERROR: ${key} — ${err.message}`);
|
|
@@ -247,7 +267,7 @@ async function processDeferredItem(stats) {
|
|
|
247
267
|
return null;
|
|
248
268
|
} finally {
|
|
249
269
|
clearTimeout(deadline);
|
|
250
|
-
|
|
270
|
+
_deferredSlotsActive--;
|
|
251
271
|
}
|
|
252
272
|
|
|
253
273
|
// 5. Follow-up webhook if sandbox found something
|
|
@@ -302,6 +322,31 @@ async function processDeferredItem(stats) {
|
|
|
302
322
|
return sandboxResult;
|
|
303
323
|
}
|
|
304
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Tick dispatcher: launch deferred items CONCURRENTLY up to the free pool slots.
|
|
327
|
+
* processDeferredItem runs its slot-acquire (shift + increment) synchronously
|
|
328
|
+
* before its first await, so each launch is visible to the next loop iteration —
|
|
329
|
+
* no over-subscription past DEFERRED_SANDBOX_SLOTS. Calls are fire-and-forget:
|
|
330
|
+
* processDeferredItem is fully self-contained (its try/catch/finally swallows
|
|
331
|
+
* sandbox errors and always releases the slot), so a launched run never rejects
|
|
332
|
+
* the dispatcher. Returns the number launched this tick (for tests/observability).
|
|
333
|
+
* @returns {number}
|
|
334
|
+
*/
|
|
335
|
+
function processDeferredBatch(stats) {
|
|
336
|
+
let launched = 0;
|
|
337
|
+
// Bound the loop by the free slot count so a transient queue can't spin it.
|
|
338
|
+
while (_deferredSlotsActive < DEFERRED_SANDBOX_SLOTS && _deferredQueue.length > 0) {
|
|
339
|
+
const before = _deferredSlotsActive;
|
|
340
|
+
const p = processDeferredItem(stats);
|
|
341
|
+
// If the slot wasn't acquired (e.g. queue emptied by pruning inside the call),
|
|
342
|
+
// stop — otherwise the guard above could loop without progress.
|
|
343
|
+
if (_deferredSlotsActive === before) break;
|
|
344
|
+
launched++;
|
|
345
|
+
if (p && typeof p.catch === 'function') p.catch(() => { /* self-handled */ });
|
|
346
|
+
}
|
|
347
|
+
return launched;
|
|
348
|
+
}
|
|
349
|
+
|
|
305
350
|
/**
|
|
306
351
|
* Build Discord embed for deferred sandbox follow-up.
|
|
307
352
|
*/
|
|
@@ -348,10 +393,14 @@ function buildDeferredFollowUpEmbed(name, version, ecosystem, sandboxResult, sta
|
|
|
348
393
|
function startDeferredWorker(stats) {
|
|
349
394
|
_stats = stats;
|
|
350
395
|
if (_workerHandle) return _workerHandle;
|
|
351
|
-
console.log(`[DEFERRED] Worker started (interval=${DEFERRED_WORKER_INTERVAL_MS / 1000}s, max=${DEFERRED_QUEUE_MAX}, ttl=${DEFERRED_TTL_MS / 3600000}h)`);
|
|
352
|
-
_workerHandle = setInterval(
|
|
396
|
+
console.log(`[DEFERRED] Worker started (interval=${DEFERRED_WORKER_INTERVAL_MS / 1000}s, max=${DEFERRED_QUEUE_MAX}, slots=${DEFERRED_SANDBOX_SLOTS}, ttl=${DEFERRED_TTL_MS / 3600000}h)`);
|
|
397
|
+
_workerHandle = setInterval(() => {
|
|
353
398
|
try {
|
|
354
|
-
|
|
399
|
+
// Fill free pool slots each tick. The dispatcher launches concurrent runs
|
|
400
|
+
// (fire-and-forget); long-running sandboxes keep their slots across ticks,
|
|
401
|
+
// so steady state is DEFERRED_SANDBOX_SLOTS in flight while the queue drains.
|
|
402
|
+
pruneExpired(_stats);
|
|
403
|
+
processDeferredBatch(_stats);
|
|
355
404
|
} catch (err) {
|
|
356
405
|
console.error(`[DEFERRED] Worker tick error: ${err.message}`);
|
|
357
406
|
}
|
|
@@ -465,12 +514,25 @@ function _resetDeferredQueue() {
|
|
|
465
514
|
_deferredQueue.length = 0;
|
|
466
515
|
_deferredSeen.clear();
|
|
467
516
|
_stats = null;
|
|
468
|
-
|
|
517
|
+
_deferredSlotsActive = 0;
|
|
518
|
+
_runSandboxFn = runSandbox;
|
|
469
519
|
stopDeferredWorker();
|
|
470
520
|
}
|
|
471
521
|
|
|
522
|
+
// Test seam: inject a controllable sandbox runner (restored by _resetDeferredQueue).
|
|
523
|
+
function _setRunSandboxForTest(fn) {
|
|
524
|
+
_runSandboxFn = fn || runSandbox;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// True while at least one deferred sandbox is in flight. Kept for back-compat
|
|
528
|
+
// (callers/tests that only care "is the deferred path active"); use
|
|
529
|
+
// getDeferredSlotsActive() for the concurrent count.
|
|
472
530
|
function isDeferredSlotBusy() {
|
|
473
|
-
return
|
|
531
|
+
return _deferredSlotsActive > 0;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function getDeferredSlotsActive() {
|
|
535
|
+
return _deferredSlotsActive;
|
|
474
536
|
}
|
|
475
537
|
|
|
476
538
|
/**
|
|
@@ -492,14 +554,18 @@ module.exports = {
|
|
|
492
554
|
startDeferredWorker,
|
|
493
555
|
stopDeferredWorker,
|
|
494
556
|
processDeferredItem,
|
|
557
|
+
processDeferredBatch,
|
|
495
558
|
persistDeferredQueue,
|
|
496
559
|
restoreDeferredQueue,
|
|
497
560
|
buildDeferredFollowUpEmbed,
|
|
498
561
|
pruneExpired,
|
|
499
562
|
isDeferredSlotBusy,
|
|
563
|
+
getDeferredSlotsActive,
|
|
500
564
|
clearDeferredQueue,
|
|
501
565
|
_resetDeferredQueue,
|
|
566
|
+
_setRunSandboxForTest,
|
|
502
567
|
DEFERRED_QUEUE_MAX,
|
|
568
|
+
DEFERRED_SANDBOX_SLOTS,
|
|
503
569
|
DEFERRED_TTL_MS,
|
|
504
570
|
DEFERRED_MAX_RETRIES,
|
|
505
571
|
DEFERRED_WORKER_INTERVAL_MS,
|
package/src/monitor/ingestion.js
CHANGED
package/src/monitor/spill.js
CHANGED
|
@@ -182,7 +182,13 @@ function _compactBacklog(file, ledgerFn = null) {
|
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
184
|
* Pure drain predicate (exported for tests + the daemon main loop): drain only
|
|
185
|
-
* when memory pressure is fully cleared AND the live queue
|
|
185
|
+
* when memory pressure is fully cleared AND the live queue is below the drain
|
|
186
|
+
* ceiling. `threshold` is a MARGE ceiling (a margin below the ingestion
|
|
187
|
+
* backpressure point — see daemon.js SPILL_DRAIN_THRESHOLD), NOT a "queue nearly
|
|
188
|
+
* empty" low-water mark: the latter (the old 500/5000) was unreachable in steady
|
|
189
|
+
* state, so the backlog never drained. With the marge ceiling the drain is a
|
|
190
|
+
* self-throttling trickle — it auto-stops the moment pressure rises (≥ ELEVATED)
|
|
191
|
+
* or the queue climbs toward backpressure, so it never starves fresh ingestion.
|
|
186
192
|
*/
|
|
187
193
|
function shouldDrain(pressureLevel, queueLen, threshold) {
|
|
188
194
|
return pressureLevel === 0 && queueLen < threshold;
|
|
@@ -3,6 +3,55 @@
|
|
|
3
3
|
const {
|
|
4
4
|
SOLANA_PACKAGES
|
|
5
5
|
} = require('./constants.js');
|
|
6
|
+
const { containsDecodePattern } = require('./helpers.js');
|
|
7
|
+
|
|
8
|
+
// Gate #2 (FPR 2026-06-15 — Étape 0 adjudication): a computed dynamic import() is only
|
|
9
|
+
// remote-code-loading when there is positive evidence of a remote/decoded/env-driven target
|
|
10
|
+
// (URL literal, .replace() URL manipulation, atob/Buffer decode, or a process.env-sourced
|
|
11
|
+
// specifier). Bounded-local imports — CLI subcommand dispatchers (import(MAP[cmd])), layout/i18n
|
|
12
|
+
// loaders (import(`../x/${name}.js`)), dep-resolve / own-dist shims (import(join(dir,'dist/main.js')))
|
|
13
|
+
// — were ~19% of the band-20-49 false positives with 0 TP. Without evidence, computed imports
|
|
14
|
+
// stay HIGH (still fires, but ~25→10 pts: sub-threshold alone) instead of CRITICAL. Flag-gated;
|
|
15
|
+
// when the flag is off the legacy CRITICAL-on-Identifier/TemplateLiteral behavior is preserved.
|
|
16
|
+
function _importStaticText(node) {
|
|
17
|
+
if (!node) return '';
|
|
18
|
+
if (node.type === 'Literal') return typeof node.value === 'string' ? node.value : '';
|
|
19
|
+
if (node.type === 'TemplateLiteral') {
|
|
20
|
+
return (node.quasis || [])
|
|
21
|
+
.map(q => (q.value && (q.value.cooked != null ? q.value.cooked : q.value.raw)) || '')
|
|
22
|
+
.join(' ');
|
|
23
|
+
}
|
|
24
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
25
|
+
return _importStaticText(node.left) + ' ' + _importStaticText(node.right);
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _isProcessEnvMember(node) {
|
|
31
|
+
return !!node && node.type === 'MemberExpression' &&
|
|
32
|
+
node.object && node.object.type === 'MemberExpression' &&
|
|
33
|
+
node.object.object && node.object.object.type === 'Identifier' && node.object.object.name === 'process' &&
|
|
34
|
+
node.object.property && node.object.property.type === 'Identifier' && node.object.property.name === 'env';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _importRemoteEvidence(src, ctx) {
|
|
38
|
+
// URL manipulation (GlassWorm): import(x.replace(...))
|
|
39
|
+
if (src.type === 'CallExpression' && src.callee && src.callee.type === 'MemberExpression' &&
|
|
40
|
+
src.callee.property && src.callee.property.name === 'replace') return true;
|
|
41
|
+
// env-driven specifier: import(process.env.X), or import(v) where v was assigned from process.env.X
|
|
42
|
+
if (_isProcessEnvMember(src)) return true;
|
|
43
|
+
if (src.type === 'Identifier' && ctx.varSource && ctx.varSource.get(src.name) === 'env_var') return true;
|
|
44
|
+
// identifier resolving to a URL string literal: const u = 'https://evil/x.js'; import(u)
|
|
45
|
+
if (src.type === 'Identifier' && ctx.stringVarValues) {
|
|
46
|
+
const resolved = ctx.stringVarValues.get(src.name);
|
|
47
|
+
if (resolved && /https?:|:\/\//i.test(resolved)) return true;
|
|
48
|
+
}
|
|
49
|
+
// runtime decode: import(atob(...)) / import(Buffer.from(...).toString())
|
|
50
|
+
if (containsDecodePattern(src)) return true;
|
|
51
|
+
// explicit URL scheme in the static parts of the specifier
|
|
52
|
+
if (/https?:|:\/\//i.test(_importStaticText(src))) return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
6
55
|
|
|
7
56
|
function handleImportExpression(node, ctx) {
|
|
8
57
|
if (node.source) {
|
|
@@ -25,11 +74,29 @@ function handleImportExpression(node, ctx) {
|
|
|
25
74
|
if (SOLANA_PACKAGES.some(pkg => src.value === pkg)) {
|
|
26
75
|
ctx.hasSolanaImport = true;
|
|
27
76
|
}
|
|
77
|
+
} else if (process.env.MUADDIB_DYNIMPORT_BOUNDED === '1') {
|
|
78
|
+
// Gate #2 (downgrade-only — never escalates above legacy severity, so it cannot raise FPR):
|
|
79
|
+
// a legacy-CRITICAL computed import (Identifier / TemplateLiteral / .replace URL) drops to HIGH
|
|
80
|
+
// when there is NO remote/decode/env evidence (bounded/local: CLI dispatchers, layout/i18n
|
|
81
|
+
// loaders, dep-resolve shims). With evidence it stays CRITICAL; a legacy-HIGH argument stays HIGH.
|
|
82
|
+
const legacyCritical = src.type === 'Identifier' || src.type === 'TemplateLiteral' ||
|
|
83
|
+
(src.type === 'CallExpression' && src.callee?.property?.name === 'replace');
|
|
84
|
+
const bounded = legacyCritical && !_importRemoteEvidence(src, ctx);
|
|
85
|
+
ctx.threats.push({
|
|
86
|
+
type: 'dynamic_import',
|
|
87
|
+
severity: bounded ? 'HIGH' : (legacyCritical ? 'CRITICAL' : 'HIGH'),
|
|
88
|
+
message: bounded
|
|
89
|
+
? 'Dynamic import() with computed (bounded/local) argument — possible obfuscation.'
|
|
90
|
+
: (legacyCritical
|
|
91
|
+
? 'Dynamic import() with computed URL argument — remote code loading from dynamically constructed URL.'
|
|
92
|
+
: 'Dynamic import() with computed argument (possible obfuscation).'),
|
|
93
|
+
file: ctx.relFile
|
|
94
|
+
});
|
|
28
95
|
} else {
|
|
29
|
-
// Blue Team v8b (C6)
|
|
30
|
-
//
|
|
31
|
-
const isCritical =
|
|
32
|
-
(
|
|
96
|
+
// Legacy behavior (gate off): Blue Team v8b (C6) — non-literal arg is CRITICAL when it
|
|
97
|
+
// looks like a constructed URL (Identifier / TemplateLiteral / .replace()).
|
|
98
|
+
const isCritical = src.type === 'Identifier' || src.type === 'TemplateLiteral' ||
|
|
99
|
+
(src.type === 'CallExpression' && src.callee?.property?.name === 'replace');
|
|
33
100
|
ctx.threats.push({
|
|
34
101
|
type: 'dynamic_import',
|
|
35
102
|
severity: isCritical ? 'CRITICAL' : 'HIGH',
|
|
@@ -216,6 +216,11 @@ function handlePostWalk(ctx) {
|
|
|
216
216
|
});
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
// Per-file network-destination verdict (decoy-safe): true iff every literal host is
|
|
220
|
+
// local/reserved or a curated provider; any public-IP/suspicious/unknown host — or no host —
|
|
221
|
+
// ⇒ false. Reused by the detached/uncaught-exfil compounds below.
|
|
222
|
+
const destAllBenign = ctx._content ? networkDestinationsAllBenign(ctx._content) : false;
|
|
223
|
+
|
|
219
224
|
// Credential regex harvesting: credential-matching regex + network call in same file
|
|
220
225
|
// Real-world pattern: Transform/stream that scans data for tokens/passwords and exfiltrates
|
|
221
226
|
if (ctx.hasCredentialRegex && ctx.hasNetworkCallInFile) {
|
|
@@ -328,7 +333,7 @@ function handlePostWalk(ctx) {
|
|
|
328
333
|
// destination in the file is first-party/local/provider (e.g. an otel collector on
|
|
329
334
|
// localhost, an SDK POST to its own API). A suspicious/unknown/public-IP host — or no
|
|
330
335
|
// literal host at all — leaves it firing (conservative: confirmed-benign only).
|
|
331
|
-
|
|
336
|
+
// (destAllBenign is computed once above, at the credential_regex_harvest emission site.)
|
|
332
337
|
if (hasDetachedInFile && hasSensitiveEnvInFile && ctx.hasNetworkCallInFile && !destAllBenign) {
|
|
333
338
|
ctx.threats.push({
|
|
334
339
|
type: 'detached_credential_exfil',
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -1043,6 +1043,40 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
1043
1043
|
}
|
|
1044
1044
|
}
|
|
1045
1045
|
|
|
1046
|
+
// Gate #1 (FPR 2026-06-15 — Étape 0 adjudication): the C7 block above only covers pure
|
|
1047
|
+
// env_read sources; the dominant live FP cluster (~25% of band 20-49, 0 TP) is a
|
|
1048
|
+
// credential_env_read API key (OPENAI_API_KEY, YINGDAO_ACCESS_TOKEN, …) flowing to the
|
|
1049
|
+
// package's OWN first-party API or a curated provider. The decoy-safe discriminant is
|
|
1050
|
+
// brand coherence (env-var brand ↔ host label) + curated providers + local hosts, applied
|
|
1051
|
+
// to EVERY destination. Limited to env-like sources (a credential_read FILE, command_output,
|
|
1052
|
+
// or fingerprint_read source stays CRITICAL — those are genuinely higher-risk). Downgrade to
|
|
1053
|
+
// MEDIUM so the signal survives; residual = compromised first-party domain, the same risk the
|
|
1054
|
+
// mature/MT-1 cap already accepts. Flag-gated (default off) for measure-then-flip rollout.
|
|
1055
|
+
if (process.env.MUADDIB_DF_SDK_GATE === '1' &&
|
|
1056
|
+
(severity === 'CRITICAL' || severity === 'HIGH')) {
|
|
1057
|
+
const envLike = sources.filter(s => s.type === 'env_read' || s.type === 'credential_env_read');
|
|
1058
|
+
const onlyEnvLike = sources.every(s =>
|
|
1059
|
+
s.type === 'env_read' || s.type === 'credential_env_read' || s.type === 'telemetry_read');
|
|
1060
|
+
if (envLike.length > 0 && onlyEnvLike) {
|
|
1061
|
+
try {
|
|
1062
|
+
const { extractBrandFromEnvVar, networkDestinationsAllBenignOrBrand } = require('../sdk-destination.js');
|
|
1063
|
+
const gateContent = fs.readFileSync(filePath, 'utf8');
|
|
1064
|
+
const brands = envLike.map(s => {
|
|
1065
|
+
const envVar = s.name
|
|
1066
|
+
.replace(/^process\.env\./, '')
|
|
1067
|
+
.replace(/^process\.env\[['"]/, '')
|
|
1068
|
+
.replace(/['"]\]$/, '');
|
|
1069
|
+
return extractBrandFromEnvVar(envVar);
|
|
1070
|
+
}).filter(Boolean);
|
|
1071
|
+
if (networkDestinationsAllBenignOrBrand(gateContent, brands)) {
|
|
1072
|
+
severity = 'MEDIUM';
|
|
1073
|
+
}
|
|
1074
|
+
} catch {
|
|
1075
|
+
// sdk-destination / file read unavailable — keep severity
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1046
1080
|
const sourceDesc = hasCommandOutput ? 'command output' : 'credentials read';
|
|
1047
1081
|
threats.push({
|
|
1048
1082
|
type: 'suspicious_dataflow',
|
package/src/scoring.js
CHANGED
|
@@ -1051,6 +1051,25 @@ function _hasExfilSink(threats) {
|
|
|
1051
1051
|
return threats.some(t => EXFIL_SINK_TYPES.has(t.type) && t.severity !== 'LOW');
|
|
1052
1052
|
}
|
|
1053
1053
|
|
|
1054
|
+
// Sink-coupling (chantier 2026-06-15): the subset of EXFIL_SINK_TYPES that PROVES taint or
|
|
1055
|
+
// unambiguous structural malice — NOT mere host-reputation string presence. When one of these
|
|
1056
|
+
// co-occurs with credential_regex_harvest it stays HIGH (anti-FN floor: protects cross-file
|
|
1057
|
+
// read→exfil and the intent/detached/staged compounds). The complement (suspicious_domain,
|
|
1058
|
+
// direct_ip_exfil, ioc_string_match, ioc_match) is host-reputation-only.
|
|
1059
|
+
const PROVEN_EXFIL_SINK_TYPES = new Set([
|
|
1060
|
+
'known_malicious_package', 'pypi_malicious_package', 'shai_hulud_marker',
|
|
1061
|
+
'detached_credential_exfil', 'silent_stealth_process',
|
|
1062
|
+
'curl_pipe_shell', 'curl_env_exfil', 'reverse_shell', 'dns_exfil', 'oast_callback',
|
|
1063
|
+
'function_constructor_require', 'staged_remote_loader', 'staged_eval_decode',
|
|
1064
|
+
'fetch_decrypt_exec', 'download_exec_binary', 'self_destruct_eval',
|
|
1065
|
+
'newsletter_auto_follow', 'cross_file_dataflow', 'intent_credential_exfil',
|
|
1066
|
+
'intent_command_exfil', 'sandbox_known_exfil_domain', 'sandbox_network_after_sensitive_read'
|
|
1067
|
+
]);
|
|
1068
|
+
function _hasProvenExfilSink(threats) {
|
|
1069
|
+
if (!Array.isArray(threats)) return false;
|
|
1070
|
+
return threats.some(t => PROVEN_EXFIL_SINK_TYPES.has(t.type) && t.severity !== 'LOW');
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1054
1073
|
function applyFPReductions(threats, reachableFiles, packageName, packageDeps, reachableFunctions) {
|
|
1055
1074
|
// Initialize reductions audit trail on each threat
|
|
1056
1075
|
// Store original severity before any FP reductions, so compound
|
|
@@ -1206,13 +1225,23 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps, re
|
|
|
1206
1225
|
// taint ...). When no such sink is present, downgrade HIGH/CRITICAL → LOW. Runs after the dilution
|
|
1207
1226
|
// floor so the floor's restored instance is also gated (the floor protects real exfil; with no sink
|
|
1208
1227
|
// there is nothing to protect). No GT sample relies on credential_regex_harvest (verified).
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1228
|
+
// Sink-coupling for credential_regex_harvest (per-instance, two-way): a proven taint /
|
|
1229
|
+
// structural-malice sink ⇒ keep HIGH (anti-FN floor); no exfil sink at all ⇒ LOW.
|
|
1230
|
+
const _crhProvenSink = _hasProvenExfilSink(threats);
|
|
1231
|
+
const _crhAnySink = _hasExfilSink(threats);
|
|
1232
|
+
for (const t of threats) {
|
|
1233
|
+
if (t.type !== 'credential_regex_harvest') continue;
|
|
1234
|
+
if (t.severity !== 'HIGH' && t.severity !== 'CRITICAL') continue;
|
|
1235
|
+
// (1) anti-FN floor: a proven taint / structural-malice sink ⇒ keep HIGH (host/flag irrelevant).
|
|
1236
|
+
if (_crhProvenSink) continue;
|
|
1237
|
+
// (2) no exfil sink at all ⇒ LOW (legacy behavior, flag-independent).
|
|
1238
|
+
if (!_crhAnySink) {
|
|
1239
|
+
t.reductions.push({ rule: 'sink_coupling', from: t.severity, to: 'LOW' });
|
|
1240
|
+
t.severity = 'LOW';
|
|
1241
|
+
continue;
|
|
1215
1242
|
}
|
|
1243
|
+
// (3) only host-reputation sink(s) co-occur ⇒ keep HIGH (fall-through). A host-coupling
|
|
1244
|
+
// downgrade here (gate #3, MUADDIB_CRH_HOST_GATE) was measured inert and removed 2026-06-15.
|
|
1216
1245
|
}
|
|
1217
1246
|
|
|
1218
1247
|
for (const t of threats) {
|
package/src/sdk-destination.js
CHANGED
|
@@ -308,6 +308,45 @@ function networkDestinationsAllBenign(fileContent) {
|
|
|
308
308
|
return true;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Gate #1 variant of networkDestinationsAllBenign: a host ALSO passes if one of its labels
|
|
313
|
+
* matches a credential env-var BRAND (e.g. YINGDAO_ACCESS_TOKEN → api.yingdao.com). This covers
|
|
314
|
+
* the dominant credential→own-API FP cluster (Étape 0 2026-06-15: ~25% of band 20-49, 0 TP) that
|
|
315
|
+
* networkDestinationsAllBenign rejects because a package's own domain is not a curated provider.
|
|
316
|
+
* Decoy-safe by construction: EVERY host must be local/reserved OR a curated provider OR
|
|
317
|
+
* brand-coherent; any unknown / public-IP / suspicious-tunnel host ⇒ false. No hosts ⇒ false.
|
|
318
|
+
* Brand coherence is not attacker-spoofable for the credential-theft case: stealing a VICTIM's
|
|
319
|
+
* OTHER-service key (OPENAI_API_KEY) and sending it to attacker.com yields brand "openai" vs label
|
|
320
|
+
* "attacker" ⇒ mismatch ⇒ keeps firing.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} fileContent - source of the file containing the network sink
|
|
323
|
+
* @param {string[]} brands - brand tokens extracted from the credential env-var names
|
|
324
|
+
* @returns {boolean}
|
|
325
|
+
*/
|
|
326
|
+
function networkDestinationsAllBenignOrBrand(fileContent, brands) {
|
|
327
|
+
const hosts = extractHostsFromContent(fileContent);
|
|
328
|
+
if (hosts.length === 0) return false;
|
|
329
|
+
// RFC 2606 / 6761 documentation & test placeholders (example.com/.net/.org, *.test, *.invalid)
|
|
330
|
+
// are NOT real SDK destinations — no benign SDK ships a live credential flow to example.com.
|
|
331
|
+
// A credential→placeholder flow is either a synthetic exfil sample or an evasion stand-in, so it
|
|
332
|
+
// must keep firing (it is deliberately NOT in the local-IPC benign class, unlike loopback/RFC1918).
|
|
333
|
+
const DOC_DOMAIN_RE = /(^|\.)example\.(?:com|net|org)$|\.(?:test|example|invalid)$/i;
|
|
334
|
+
const brandSet = (brands || [])
|
|
335
|
+
.map(b => String(b || '').toLowerCase())
|
|
336
|
+
.filter(b => b.length >= 3);
|
|
337
|
+
for (const h of hosts) {
|
|
338
|
+
if (SUSPICIOUS_DOMAIN_PATTERNS.test(h)) return false;
|
|
339
|
+
if (isPublicIpHost(h)) return false;
|
|
340
|
+
if (DOC_DOMAIN_RE.test(h)) return false;
|
|
341
|
+
if (isLocalOrReservedHost(h)) continue;
|
|
342
|
+
if (PROVIDER_DOMAIN_SUFFIXES.some(s => domainMatchesSuffix(h, [s]))) continue;
|
|
343
|
+
const labels = String(h).toLowerCase().split('.');
|
|
344
|
+
if (brandSet.length && labels.some(l => brandSet.includes(l))) continue;
|
|
345
|
+
return false; // unknown / unrecognised destination → keep firing
|
|
346
|
+
}
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
311
350
|
module.exports = {
|
|
312
351
|
SDK_ENV_DOMAIN_MAP,
|
|
313
352
|
ENV_NOISE_TOKENS,
|
|
@@ -320,6 +359,7 @@ module.exports = {
|
|
|
320
359
|
extractDomain,
|
|
321
360
|
domainMatchesSuffix,
|
|
322
361
|
isSDKPattern,
|
|
362
|
+
networkDestinationsAllBenignOrBrand,
|
|
323
363
|
stripPort,
|
|
324
364
|
isLocalOrReservedHost,
|
|
325
365
|
isPublicIpHost,
|