muaddib-scanner 2.11.91 → 2.11.93
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/bin/muaddib.js +13 -0
- package/package.json +1 -1
- package/{self-scan-v2.11.91.json → self-scan-v2.11.93.json} +1 -1
- package/src/commands/shadow-report.js +106 -0
- package/src/pipeline/processor.js +5 -1
- package/src/scanner/ast-detectors/handle-call-expression.js +42 -2
- package/src/scanner/ast-detectors/handle-post-walk.js +13 -0
- package/src/scanner/ast-detectors/mcp-write-classifier.js +71 -0
- package/src/scanner/ast.js +4 -0
- package/src/scanner/email-domain.js +80 -1
- package/src/scanner/pypi-maintainer.js +4 -1
- package/src/shared/shadow.js +190 -0
package/bin/muaddib.js
CHANGED
|
@@ -659,6 +659,19 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
659
659
|
console.error('[ERROR]', err.message);
|
|
660
660
|
process.exit(1);
|
|
661
661
|
});
|
|
662
|
+
} else if (command === 'shadow-report') {
|
|
663
|
+
const { runShadowReport } = require('../src/commands/shadow-report.js');
|
|
664
|
+
const shOpts = { json: jsonOutput };
|
|
665
|
+
for (let i = 0; i < options.length; i++) {
|
|
666
|
+
if (options[i] === '--since' && options[i + 1]) { shOpts.since = options[i + 1]; i++; }
|
|
667
|
+
else if (options[i] === '--detector' && options[i + 1]) { shOpts.detector = options[i + 1]; i++; }
|
|
668
|
+
}
|
|
669
|
+
runShadowReport(shOpts).then(() => {
|
|
670
|
+
process.exit(0);
|
|
671
|
+
}).catch(err => {
|
|
672
|
+
console.error('[ERROR]', err.message);
|
|
673
|
+
process.exit(1);
|
|
674
|
+
});
|
|
662
675
|
} else if (command === 'evaluate') {
|
|
663
676
|
if (wantHelp) showHelp('evaluate');
|
|
664
677
|
const { evaluate } = require('../src/commands/evaluate.js');
|
package/package.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// muaddib shadow-report — read the shadow-mode divergence log and print the
|
|
4
|
+
// V1-vs-V2 adjudication split per detector. This is the read side of
|
|
5
|
+
// src/shared/shadow.js: detectors compute a candidate semantics alongside the
|
|
6
|
+
// live one and log disagreements; this command turns the log into the table a
|
|
7
|
+
// human adjudicates before flipping the semantics.
|
|
8
|
+
//
|
|
9
|
+
// The log only contains DIVERGENCES (agreements are not recorded), so:
|
|
10
|
+
// old-only = oldVerdict truthy, newVerdict falsy → alerts V2 would drop (FP killed)
|
|
11
|
+
// new-only = newVerdict truthy, oldVerdict falsy → NEW flags (review every one)
|
|
12
|
+
// changed = both truthy but different (e.g. severity reclassification)
|
|
13
|
+
|
|
14
|
+
const { readShadowDivergences } = require('../shared/shadow.js');
|
|
15
|
+
|
|
16
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/** Parse `--since 7d` / `--since 12h` / ISO string → ms epoch (null = all). */
|
|
19
|
+
function parseSince(s) {
|
|
20
|
+
if (!s) return null;
|
|
21
|
+
const m = /^(\d+)([dh])$/.exec(s);
|
|
22
|
+
if (m) {
|
|
23
|
+
const n = parseInt(m[1], 10);
|
|
24
|
+
return Date.now() - n * (m[2] === 'd' ? DAY_MS : 3600 * 1000);
|
|
25
|
+
}
|
|
26
|
+
const p = Date.parse(s);
|
|
27
|
+
return Number.isNaN(p) ? null : p;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function classify(e) {
|
|
31
|
+
const oldT = !!e.oldVerdict, newT = !!e.newVerdict;
|
|
32
|
+
if (oldT && !newT) return 'oldOnly';
|
|
33
|
+
if (!oldT && newT) return 'newOnly';
|
|
34
|
+
return 'changed';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function runShadowReport(opts = {}) {
|
|
38
|
+
const sinceMs = parseSince(opts.since);
|
|
39
|
+
const entries = readShadowDivergences({
|
|
40
|
+
detector: opts.detector || undefined,
|
|
41
|
+
sinceTs: sinceMs !== null ? sinceMs : undefined
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (entries.length === 0) {
|
|
45
|
+
console.log('\n No shadow divergences recorded' +
|
|
46
|
+
(opts.detector ? ` for detector "${opts.detector}"` : '') +
|
|
47
|
+
(opts.since ? ` since ${opts.since}` : '') +
|
|
48
|
+
'.\n (Shadow mode logs only V1≠V2 disagreements; enable with MUADDIB_SHADOW=1.)\n');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Group by detector, dedup by package@version (a package rescanned N times
|
|
53
|
+
// diverges N times — the adjudication unit is the package, not the event).
|
|
54
|
+
const byDetector = new Map();
|
|
55
|
+
for (const e of entries) {
|
|
56
|
+
let d = byDetector.get(e.detector);
|
|
57
|
+
if (!d) { d = { events: 0, byKey: new Map() }; byDetector.set(e.detector, d); }
|
|
58
|
+
d.events++;
|
|
59
|
+
const key = `${e.package || '?'}@${e.version || ''}`;
|
|
60
|
+
if (!d.byKey.has(key)) d.byKey.set(key, e); // first divergence wins for the listing
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (opts.json) {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [det, d] of byDetector) {
|
|
66
|
+
const split = { oldOnly: [], newOnly: [], changed: [] };
|
|
67
|
+
for (const [key, e] of d.byKey) split[classify(e)].push({ key, evidence: e.evidence });
|
|
68
|
+
out[det] = {
|
|
69
|
+
events: d.events, distinct: d.byKey.size,
|
|
70
|
+
oldOnly: split.oldOnly.length, newOnly: split.newOnly.length, changed: split.changed.length,
|
|
71
|
+
newOnlyList: split.newOnly, oldOnlyExamples: split.oldOnly.slice(0, 20)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
console.log(JSON.stringify(out, null, 2));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log('\n MUAD\'DIB Shadow Divergence Report' + (opts.since ? ` (since ${opts.since})` : '') + '\n');
|
|
79
|
+
for (const [det, d] of byDetector) {
|
|
80
|
+
const split = { oldOnly: [], newOnly: [], changed: [] };
|
|
81
|
+
for (const [key, e] of d.byKey) split[classify(e)].push({ key, e });
|
|
82
|
+
console.log(` ${det}`);
|
|
83
|
+
console.log(` divergence events: ${d.events} | distinct pkg@version: ${d.byKey.size}`);
|
|
84
|
+
console.log(` old-only (V2 drops the alert — FP killed): ${split.oldOnly.length}`);
|
|
85
|
+
console.log(` new-only (V2 adds a flag — REVIEW): ${split.newOnly.length}`);
|
|
86
|
+
if (split.changed.length) {
|
|
87
|
+
console.log(` changed (both fire, different verdict): ${split.changed.length}`);
|
|
88
|
+
}
|
|
89
|
+
const show = (label, list, max) => {
|
|
90
|
+
if (!list.length) return;
|
|
91
|
+
console.log(` ${label}:`);
|
|
92
|
+
for (const { key, e } of list.slice(0, max)) {
|
|
93
|
+
const ev = e.evidence ? JSON.stringify(e.evidence) : '';
|
|
94
|
+
console.log(` - ${key} old=${JSON.stringify(e.oldVerdict)} new=${JSON.stringify(e.newVerdict)} ${ev.slice(0, 140)}`);
|
|
95
|
+
}
|
|
96
|
+
if (list.length > max) console.log(` ... and ${list.length - max} more`);
|
|
97
|
+
};
|
|
98
|
+
// Every NEW flag must be human-reviewed (possible FN risk if wrong) — show all.
|
|
99
|
+
show('new-only detail', split.newOnly, 50);
|
|
100
|
+
show('old-only examples', split.oldOnly, 20);
|
|
101
|
+
show('changed detail', split.changed, 20);
|
|
102
|
+
console.log('');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { runShadowReport, parseSince };
|
|
@@ -270,7 +270,11 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
|
|
|
270
270
|
debugLog('[EMAIL-DOMAIN] check failed: ' + err.message);
|
|
271
271
|
}
|
|
272
272
|
try {
|
|
273
|
-
|
|
273
|
+
// shadowCtx identifies the package in shadow-divergence records (V2
|
|
274
|
+
// candidate semantics logged alongside V1 — zero effect on threats).
|
|
275
|
+
const rdapThreats = await checkCompromisedDomain(_pkgMeta.npmRegistryMeta, {
|
|
276
|
+
shadowCtx: { name: packageName, version: packageVersion, ecosystem: 'npm' }
|
|
277
|
+
});
|
|
274
278
|
for (const t of rdapThreats) deduped.push(t);
|
|
275
279
|
} catch (err) {
|
|
276
280
|
debugLog('[RDAP] check failed: ' + err.message);
|
|
@@ -49,6 +49,31 @@ const {
|
|
|
49
49
|
containsDecodePattern,
|
|
50
50
|
resolveNumericExpression
|
|
51
51
|
} = require('./helpers.js');
|
|
52
|
+
const { countInvisibleUnicode } = require('../../shared/unicode-invisibles.js');
|
|
53
|
+
const { classifyMcpWrite } = require('./mcp-write-classifier.js');
|
|
54
|
+
const { isShadowEnabled, recordShadowDivergence } = require('../../shared/shadow.js');
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* SHADOW 3-tier classification for mcp_config_injection emissions (R5 + R5b).
|
|
58
|
+
* Computes the candidate class (template / shell_exec / instruction_injection)
|
|
59
|
+
* and logs a divergence ONLY when the candidate semantics would downgrade the
|
|
60
|
+
* verdict (template → MEDIUM). Zero effect on the threat emitted by the caller
|
|
61
|
+
* — the live severity stays CRITICAL until the shadow data adjudicates the flip.
|
|
62
|
+
* The package identity is not available at AST level; evidence carries the file.
|
|
63
|
+
*/
|
|
64
|
+
function _shadowClassifyMcpWrite(contentStr, checkPath, rule, ctx) {
|
|
65
|
+
try {
|
|
66
|
+
if (!isShadowEnabled()) return;
|
|
67
|
+
const { cls, signals } = classifyMcpWrite(contentStr, checkPath);
|
|
68
|
+
if (cls !== 'template') return; // shell_exec / instruction_injection keep CRITICAL — no divergence
|
|
69
|
+
recordShadowDivergence({
|
|
70
|
+
detector: 'mcp_config_injection_3tier',
|
|
71
|
+
oldVerdict: 'CRITICAL',
|
|
72
|
+
newVerdict: 'MEDIUM',
|
|
73
|
+
evidence: { cls, signals, path: checkPath, rule, file: ctx.relFile }
|
|
74
|
+
});
|
|
75
|
+
} catch { /* shadow must never affect the scan */ }
|
|
76
|
+
}
|
|
52
77
|
|
|
53
78
|
/**
|
|
54
79
|
* Detect whether an AST node points at a user-level filesystem location:
|
|
@@ -756,6 +781,10 @@ function handleCallExpression(node, ctx) {
|
|
|
756
781
|
? MCP_CONTENT_PATTERNS.some(p => contentStr.includes(p.replace(/"/g, '')))
|
|
757
782
|
: isSensitiveConfigFile; // dynamic content only suspicious for known config files
|
|
758
783
|
if (hasContentPattern) {
|
|
784
|
+
// SHADOW 3-tier classification (zero effect on the emitted severity):
|
|
785
|
+
// template-class writes are the scaffolder FP under adjudication —
|
|
786
|
+
// log the would-be CRITICAL→MEDIUM divergence for `shadow-report`.
|
|
787
|
+
_shadowClassifyMcpWrite(contentStr, mcpCheckPath, 'R5', ctx);
|
|
759
788
|
ctx.threats.push({
|
|
760
789
|
type: 'mcp_config_injection',
|
|
761
790
|
severity: 'CRITICAL',
|
|
@@ -780,11 +809,20 @@ function handleCallExpression(node, ctx) {
|
|
|
780
809
|
const contentStr2 = extractStringValue(contentArg2);
|
|
781
810
|
const hasShellContent = !!contentStr2 && /(?:curl|wget)\s+[^\n]*\|\s*(?:sh|bash|zsh)\b|\beval\s*\(|\bsh\s+-c\s+|\bbash\s+-c\s+|\bnode\s+-e\s+/i.test(contentStr2);
|
|
782
811
|
const hasInjectionInstruction = !!contentStr2 && /IMPORTANT[:\s]+(?:before|after|run|execute)|do\s+not\s+(?:display|show|mention)|always\s+run/i.test(contentStr2);
|
|
783
|
-
|
|
812
|
+
// 3d (additive, v2.11.91): zero-width/bidi Unicode in the written
|
|
813
|
+
// content — the TrapDoor hidden-instruction encoding (Socket,
|
|
814
|
+
// 2026-05-25: instructions invisible in an editor, word-broken so
|
|
815
|
+
// the 3b/3c plain-text regexes can't match). A legitimate generator
|
|
816
|
+
// never emits invisible codepoints into a rules file. Strictly
|
|
817
|
+
// additive: can only ADD detections to the 3a/3b/3c OR.
|
|
818
|
+
const hasInvisibleContent = !!contentStr2 && countInvisibleUnicode(contentStr2) > 0;
|
|
819
|
+
if (hasUserLevelPath || hasShellContent || hasInjectionInstruction || hasInvisibleContent) {
|
|
784
820
|
const reasons = [];
|
|
785
821
|
if (hasUserLevelPath) reasons.push('user-level destination (homedir/cwd/env.HOME)');
|
|
786
822
|
if (hasShellContent) reasons.push('shell command in content');
|
|
787
823
|
if (hasInjectionInstruction) reasons.push('AI prompt-injection instruction in content');
|
|
824
|
+
if (hasInvisibleContent) reasons.push('zero-width/bidi Unicode in content (hidden-instruction encoding)');
|
|
825
|
+
_shadowClassifyMcpWrite(contentStr2, mcpCheckPath, 'R5b', ctx);
|
|
788
826
|
ctx.threats.push({
|
|
789
827
|
type: 'mcp_config_injection',
|
|
790
828
|
severity: 'CRITICAL',
|
|
@@ -2047,4 +2085,6 @@ function handleCallExpression(node, ctx) {
|
|
|
2047
2085
|
}
|
|
2048
2086
|
|
|
2049
2087
|
|
|
2050
|
-
|
|
2088
|
+
// _shadowClassifyMcpWrite is shared with handle-post-walk.js (the Wave-4
|
|
2089
|
+
// keyword-co-occurrence emitter — the third mcp_config_injection site).
|
|
2090
|
+
module.exports = { handleCallExpression, _shadowClassifyMcpWrite };
|
|
@@ -274,6 +274,19 @@ function handlePostWalk(ctx) {
|
|
|
274
274
|
|
|
275
275
|
// Wave 4: MCP content keywords in file with writeFileSync = MCP injection signal
|
|
276
276
|
if (ctx.hasMcpContentKeywords && !ctx.threats.some(t => t.type === 'mcp_config_injection')) {
|
|
277
|
+
// SHADOW 3-tier classification (zero effect on the emitted severity). The
|
|
278
|
+
// 2026-06-11 backtest showed this keyword-co-occurrence rule emits ~85% of
|
|
279
|
+
// historical mcp_config_injection alerts (100/118 packages) — every
|
|
280
|
+
// legitimate MCP server installer carries mcpServers keywords + writes —
|
|
281
|
+
// so the adjudication MUST cover this site, not just R5/R5b. The classifier
|
|
282
|
+
// runs on the FILE source (the written content is not extractable here):
|
|
283
|
+
// a file whose code carries shell-exec or hidden-instruction markers keeps
|
|
284
|
+
// CRITICAL silently; an inert config-writer logs the CRITICAL→MEDIUM
|
|
285
|
+
// candidate divergence, tagged rule:'W4' so the report splits it out.
|
|
286
|
+
try {
|
|
287
|
+
const { _shadowClassifyMcpWrite } = require('./handle-call-expression.js');
|
|
288
|
+
_shadowClassifyMcpWrite(typeof ctx._content === 'string' ? ctx._content : null, '(file-level keyword co-occurrence)', 'W4', ctx);
|
|
289
|
+
} catch { /* shadow must never affect the scan */ }
|
|
277
290
|
ctx.threats.push({
|
|
278
291
|
type: 'mcp_config_injection',
|
|
279
292
|
severity: 'CRITICAL',
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mcp-write-classifier.js — pure 3-tier classifier for mcp_config_injection
|
|
5
|
+
* candidates (SHADOW adjudication + the future severity flip).
|
|
6
|
+
*
|
|
7
|
+
* Empirical classes (web research 2026-06-11, calibrated on real campaigns):
|
|
8
|
+
* (a) template — write with inert content: the scaffolder shape
|
|
9
|
+
* (ruler, rulesync, cursor-rules, cursor-tools all legitimately write
|
|
10
|
+
* .cursorrules/CLAUDE.md/AGENTS.md). Candidate MEDIUM after adjudication.
|
|
11
|
+
* (b) shell_exec — content carries a shell command or an
|
|
12
|
+
* agent-hook exec (SafeDep campaign, 2026-05-13: .claude/settings.json
|
|
13
|
+
* SessionStart hook → ELF). Stays CRITICAL.
|
|
14
|
+
* (c) instruction_injection — content carries hidden instructions: zero-
|
|
15
|
+
* width/bidi Unicode (TrapDoor encoding — Socket 2026-05-25; GitHub
|
|
16
|
+
* flags the same) or agent-addressed directives ("do not tell the
|
|
17
|
+
* user…"). Stays CRITICAL.
|
|
18
|
+
*
|
|
19
|
+
* The classifier is PURE (no I/O, no ctx) so it is unit-testable per class and
|
|
20
|
+
* is exactly what gets promoted when the flip lands. Until then it feeds the
|
|
21
|
+
* shadow log: oldVerdict CRITICAL vs newVerdict (template→MEDIUM).
|
|
22
|
+
*
|
|
23
|
+
* Honest default: content that cannot be extracted statically classifies as
|
|
24
|
+
* `template` with signal `dynamic_content` — we don't know, so the shadow
|
|
25
|
+
* numbers must not pretend we do. (The live R5/R5b severity is unaffected
|
|
26
|
+
* either way — this module emits no threats.)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const { countInvisibleUnicode } = require('../../shared/unicode-invisibles.js');
|
|
30
|
+
|
|
31
|
+
// (c) — agent-addressed directives. Superset of the live R5b 3c regex
|
|
32
|
+
// (IMPORTANT/do-not-display/always-run) with the additions calibrated on the
|
|
33
|
+
// Rules-File-Backdoor / Mini-Shai-Hulud wording. Word-boundaried enough not to
|
|
34
|
+
// match benign docs ("important: run tests before committing" matches — by
|
|
35
|
+
// design, that wording addressed to an agent IS the attack shape; the
|
|
36
|
+
// difference is made by the write target, which the caller already gated on).
|
|
37
|
+
const INJECTION_DIRECTIVE_RE = /IMPORTANT[:\s]+(?:before|after|run|execute)|do\s+not\s+(?:display|show|mention|tell)|never\s+(?:mention|reveal|disclose)|hide\s+this\s+from|always\s+run/i;
|
|
38
|
+
|
|
39
|
+
// (b) — shell command in content. Same expression as the live R5b 3b gate.
|
|
40
|
+
const SHELL_CONTENT_RE = /(?:curl|wget)\s+[^\n]*\|\s*(?:sh|bash|zsh)\b|\beval\s*\(|\bsh\s+-c\s+|\bbash\s+-c\s+|\bnode\s+-e\s+/i;
|
|
41
|
+
|
|
42
|
+
// (b) — agent-hook exec in JSON content: a "hooks" structure carrying a
|
|
43
|
+
// "command" (the SafeDep .claude/settings.json SessionStart shape). Order-
|
|
44
|
+
// insensitive containment — the content is config the attacker controls, a
|
|
45
|
+
// strict JSON parse would be evadable with trailing garbage.
|
|
46
|
+
const HOOKS_COMMAND_RE = /"hooks"[\s\S]{0,400}"command"|"command"[\s\S]{0,400}"hooks"/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string|null|undefined} contentStr statically-extracted write content
|
|
50
|
+
* (null/undefined = dynamic, not extractable)
|
|
51
|
+
* @param {string} [checkPath] lowercased destination path (reserved for future
|
|
52
|
+
* signals; not used for class decision today)
|
|
53
|
+
* @returns {{cls: 'template'|'shell_exec'|'instruction_injection', signals: string[]}}
|
|
54
|
+
*/
|
|
55
|
+
function classifyMcpWrite(contentStr, checkPath) { // eslint-disable-line no-unused-vars
|
|
56
|
+
if (contentStr === null || contentStr === undefined || typeof contentStr !== 'string') {
|
|
57
|
+
return { cls: 'template', signals: ['dynamic_content'] };
|
|
58
|
+
}
|
|
59
|
+
const signals = [];
|
|
60
|
+
if (countInvisibleUnicode(contentStr) > 0) signals.push('zero_width_unicode');
|
|
61
|
+
if (INJECTION_DIRECTIVE_RE.test(contentStr)) signals.push('injection_directive');
|
|
62
|
+
if (signals.length > 0) return { cls: 'instruction_injection', signals };
|
|
63
|
+
|
|
64
|
+
if (SHELL_CONTENT_RE.test(contentStr)) signals.push('shell_command');
|
|
65
|
+
if (HOOKS_COMMAND_RE.test(contentStr)) signals.push('hooks_command_json');
|
|
66
|
+
if (signals.length > 0) return { cls: 'shell_exec', signals };
|
|
67
|
+
|
|
68
|
+
return { cls: 'template', signals: [] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { classifyMcpWrite, INJECTION_DIRECTIVE_RE, SHELL_CONTENT_RE, HOOKS_COMMAND_RE };
|
package/src/scanner/ast.js
CHANGED
|
@@ -111,6 +111,10 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
111
111
|
const ctx = {
|
|
112
112
|
threats,
|
|
113
113
|
relFile: path.relative(basePath, filePath),
|
|
114
|
+
// File source reference for the post-walk shadow classifier (Wave-4 MCP
|
|
115
|
+
// site has no extractable written-content string — it classifies the file).
|
|
116
|
+
// A reference to the already-held string: no copy, freed with the ctx.
|
|
117
|
+
_content: content,
|
|
114
118
|
dynamicRequireVars: new Set(),
|
|
115
119
|
staticAssignments: new Set(),
|
|
116
120
|
// v2.10.73 P2: AST-006 source qualification — tracks WHERE a variable's value came from.
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
const dns = require('dns');
|
|
19
19
|
const { debugLog } = require('../utils.js');
|
|
20
|
+
const { isShadowEnabled, recordShadowDivergence } = require('../shared/shadow.js');
|
|
20
21
|
|
|
21
22
|
const MX_TIMEOUT_MS = 3000;
|
|
22
23
|
const MX_CACHE_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
@@ -236,10 +237,67 @@ function isCompromisedDomain(creationDateISO, packageCreatedAtISO) {
|
|
|
236
237
|
return cDate > (rDate - COMPROMISE_MARGIN_MS);
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
// =============================================================================
|
|
241
|
+
// V2 candidate semantics (SHADOW-ONLY until adjudicated — V1 above still emits
|
|
242
|
+
// every threat). Two changes vs V1, both validated by the node-ipc takeover
|
|
243
|
+
// (May 2026: domain atlantis-software.net re-registered 2026-05-07, malicious
|
|
244
|
+
// 9.2.3/12.0.1 published 05-14, FIRST publish years earlier):
|
|
245
|
+
//
|
|
246
|
+
// 1. STRICT comparison — creation > first_publish, the 30-day pre-publish
|
|
247
|
+
// margin removed. A dev who buys their domain a few weeks before shipping
|
|
248
|
+
// v1 is the NORMAL case (the margin was the main source of the 850+ FP);
|
|
249
|
+
// a dev cannot have published with an email on a domain that did not
|
|
250
|
+
// exist yet, so creation strictly after first publish stays a hard signal.
|
|
251
|
+
// RDAP caveat that makes this work: many registries RESET the creation
|
|
252
|
+
// date on re-registration (.net/Namecheap do — node-ipc's signal).
|
|
253
|
+
// 2. Public email providers excluded — gmail.com etc. can never be "taken
|
|
254
|
+
// over" by re-registration; any weird RDAP answer for them is noise.
|
|
255
|
+
// This is a domain-CLASS exclusion, not a package whitelist.
|
|
256
|
+
// =============================================================================
|
|
257
|
+
|
|
258
|
+
// Consumer email providers — domain takeover does not apply (the provider
|
|
259
|
+
// owns the domain; accounts are compromised via other vectors, out of scope
|
|
260
|
+
// for this RDAP signal).
|
|
261
|
+
const PUBLIC_EMAIL_PROVIDERS = new Set([
|
|
262
|
+
'gmail.com', 'googlemail.com',
|
|
263
|
+
'outlook.com', 'hotmail.com', 'live.com', 'msn.com',
|
|
264
|
+
'yahoo.com', 'ymail.com', 'rocketmail.com',
|
|
265
|
+
'proton.me', 'protonmail.com', 'pm.me',
|
|
266
|
+
'icloud.com', 'me.com', 'mac.com',
|
|
267
|
+
'aol.com',
|
|
268
|
+
'gmx.com', 'gmx.de', 'gmx.net',
|
|
269
|
+
'mail.ru', 'inbox.ru', 'list.ru', 'bk.ru',
|
|
270
|
+
'qq.com', 'foxmail.com', '163.com', '126.com', 'yeah.net', 'sina.com',
|
|
271
|
+
'yandex.ru', 'yandex.com',
|
|
272
|
+
'zoho.com', 'fastmail.com', 'hey.com',
|
|
273
|
+
'tutanota.com', 'tuta.com', 'tuta.io',
|
|
274
|
+
'web.de', 't-online.de', 'freenet.de',
|
|
275
|
+
'free.fr', 'orange.fr', 'laposte.net', 'wanadoo.fr', 'sfr.fr',
|
|
276
|
+
'naver.com', 'daum.net', 'hanmail.net',
|
|
277
|
+
'rediffmail.com', 'seznam.cz', 'wp.pl', 'o2.pl', 'interia.pl',
|
|
278
|
+
'duck.com', 'pobox.com', 'hushmail.com', 'mailbox.org', 'posteo.de'
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* V2: strict creation-after-first-publish, public providers excluded.
|
|
283
|
+
* Pure — used by the shadow hook below and by scripts/backtest-email-domain.js.
|
|
284
|
+
*/
|
|
285
|
+
function isCompromisedDomainV2(creationDateISO, firstPublishISO, domain) {
|
|
286
|
+
if (!creationDateISO || !firstPublishISO) return false;
|
|
287
|
+
if (domain && PUBLIC_EMAIL_PROVIDERS.has(String(domain).toLowerCase())) return false;
|
|
288
|
+
const cDate = new Date(creationDateISO).getTime();
|
|
289
|
+
const rDate = new Date(firstPublishISO).getTime();
|
|
290
|
+
if (isNaN(cDate) || isNaN(rDate)) return false;
|
|
291
|
+
return cDate > rDate;
|
|
292
|
+
}
|
|
293
|
+
|
|
239
294
|
/**
|
|
240
295
|
* F1 entry point.
|
|
241
|
-
* @param {object|null} meta - Digested metadata. Reads maintainer_emails + created_at
|
|
296
|
+
* @param {object|null} meta - Digested metadata. Reads maintainer_emails + created_at
|
|
297
|
+
* (= the package's FIRST publish date, both npm and PyPI sides).
|
|
242
298
|
* @param {object} options - { fetchRdap } for tests to inject a mock.
|
|
299
|
+
* { shadowCtx: {name, version, ecosystem} } identifies the scanned package in
|
|
300
|
+
* shadow-divergence records (optional — without it divergences log package:null).
|
|
243
301
|
* @returns {Promise<Array>} threats array
|
|
244
302
|
*/
|
|
245
303
|
async function checkCompromisedDomain(meta, options = {}) {
|
|
@@ -263,6 +321,24 @@ async function checkCompromisedDomain(meta, options = {}) {
|
|
|
263
321
|
continue;
|
|
264
322
|
}
|
|
265
323
|
if (!rdap || !rdap.creationDate) continue;
|
|
324
|
+
// SHADOW (zero effect on the threats emitted below): compare the live V1
|
|
325
|
+
// verdict with the V2 candidate and log only disagreements. Adjudication =
|
|
326
|
+
// scripts/backtest-email-domain.js replay + `muaddib shadow-report`.
|
|
327
|
+
try {
|
|
328
|
+
if (isShadowEnabled()) {
|
|
329
|
+
const v1 = isCompromisedDomain(rdap.creationDate, meta.created_at);
|
|
330
|
+
const v2 = isCompromisedDomainV2(rdap.creationDate, meta.created_at, domain);
|
|
331
|
+
if (v1 !== v2) {
|
|
332
|
+
const ctx = options.shadowCtx || {};
|
|
333
|
+
recordShadowDivergence({
|
|
334
|
+
detector: 'compromised_email_domain',
|
|
335
|
+
package: ctx.name, version: ctx.version, ecosystem: ctx.ecosystem,
|
|
336
|
+
oldVerdict: v1, newVerdict: v2,
|
|
337
|
+
evidence: { domain, creationDate: rdap.creationDate, firstPublish: meta.created_at, oldMarginDays: 30 }
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch { /* shadow must never affect the scan */ }
|
|
266
342
|
if (isCompromisedDomain(rdap.creationDate, meta.created_at)) {
|
|
267
343
|
const cd = rdap.creationDate.slice(0, 10);
|
|
268
344
|
const pd = meta.created_at.slice(0, 10);
|
|
@@ -297,6 +373,9 @@ module.exports = {
|
|
|
297
373
|
checkCompromisedDomain,
|
|
298
374
|
fetchRdap,
|
|
299
375
|
isCompromisedDomain,
|
|
376
|
+
// V2 candidate (shadow-only until adjudicated; used by the backtest script)
|
|
377
|
+
isCompromisedDomainV2,
|
|
378
|
+
PUBLIC_EMAIL_PROVIDERS,
|
|
300
379
|
_resetRdapCache,
|
|
301
380
|
RDAP_TIMEOUT_MS,
|
|
302
381
|
RDAP_CACHE_TTL,
|
|
@@ -72,7 +72,10 @@ async function runPyPIMaintainerChecks(packageName, pypiRegistryMeta, options =
|
|
|
72
72
|
let rdapThreats = [];
|
|
73
73
|
try {
|
|
74
74
|
rdapThreats = await checkCompromisedDomain(helperMeta, {
|
|
75
|
-
fetchRdap: options.fetchRdap
|
|
75
|
+
fetchRdap: options.fetchRdap,
|
|
76
|
+
// PyPI created_at is the earliest release time (pypi-registry.js) =
|
|
77
|
+
// first publish, so the V2 shadow comparison is valid on this side too.
|
|
78
|
+
shadowCtx: { name: packageName, ecosystem: 'pypi' }
|
|
76
79
|
});
|
|
77
80
|
} catch { /* silent */ }
|
|
78
81
|
for (const t of rdapThreats) threats.push(adaptThreatToPyPI(t, declarationFile));
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shadow-mode divergence framework.
|
|
5
|
+
*
|
|
6
|
+
* Lets a detector compute a CANDIDATE new semantics (V2) alongside its live
|
|
7
|
+
* semantics (V1) and log the cases where the two verdicts disagree — with ZERO
|
|
8
|
+
* effect on emitted threats, scores, or tiers. The divergence log is the
|
|
9
|
+
* adjudication input for flipping V1 → V2: replay historical alerts through
|
|
10
|
+
* the shadow (backtest) or let it run live as a post-merge safety net, then
|
|
11
|
+
* read the split with `muaddib shadow-report`.
|
|
12
|
+
*
|
|
13
|
+
* Contract (fail-safe by construction):
|
|
14
|
+
* - Nothing here returns a value the scan pipeline can act on. The framework
|
|
15
|
+
* cannot change a verdict even if misused.
|
|
16
|
+
* - recordShadowDivergence NEVER throws — a shadow failure must never break a
|
|
17
|
+
* scan (same posture as appendScanLedger).
|
|
18
|
+
* - Disabled by default. The daemon opts in via MUADDIB_SHADOW=1 in its
|
|
19
|
+
* service environment; CLI scans and tests stay inert unless they set it.
|
|
20
|
+
* - Bounded: the JSONL file is capped at MUADDIB_SHADOW_MAX entries (default
|
|
21
|
+
* 50 000) with streaming FIFO compaction — same pattern as the scan-ledger.
|
|
22
|
+
*
|
|
23
|
+
* Concurrency: unlike the scan-ledger (main-thread-only writer), this module
|
|
24
|
+
* is called from INSIDE scan workers (pipeline/processor.js runs there), so N
|
|
25
|
+
* worker_threads may append concurrently. Each record is serialized to ONE
|
|
26
|
+
* appendFileSync call of one full line (flag 'a' = O_APPEND; small writes are
|
|
27
|
+
* serialized by the inode lock on ext4) — never two writes per line. The
|
|
28
|
+
* reader skips unparsable lines (a crash mid-write can truncate at most the
|
|
29
|
+
* final line).
|
|
30
|
+
*
|
|
31
|
+
* Env (all read at CALL time so tests can re-point after module load):
|
|
32
|
+
* MUADDIB_SHADOW=1 enable (default off)
|
|
33
|
+
* MUADDIB_SHADOW_FILE=path divergence log override (tests)
|
|
34
|
+
* MUADDIB_SHADOW_MAX=n entry cap (default 50000)
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
|
|
40
|
+
const DEFAULT_SHADOW_FILE = path.join(__dirname, '..', '..', 'data', 'shadow-divergence.jsonl');
|
|
41
|
+
const DEFAULT_MAX_ENTRIES = 50_000;
|
|
42
|
+
const EVIDENCE_MAX_BYTES = 2048;
|
|
43
|
+
// Count lines (cheap streaming pass) only every N appends, not on every write.
|
|
44
|
+
const COMPACT_CHECK_INTERVAL = 500;
|
|
45
|
+
|
|
46
|
+
let _appendsSinceCheck = 0;
|
|
47
|
+
|
|
48
|
+
function isShadowEnabled() {
|
|
49
|
+
return globalThis.process.env.MUADDIB_SHADOW === '1';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _shadowFile() {
|
|
53
|
+
return globalThis.process.env.MUADDIB_SHADOW_FILE || DEFAULT_SHADOW_FILE;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function _maxEntries() {
|
|
57
|
+
const raw = globalThis.process.env.MUADDIB_SHADOW_MAX;
|
|
58
|
+
const n = raw ? parseInt(raw, 10) : NaN;
|
|
59
|
+
return (Number.isFinite(n) && n >= 10 && n <= 5_000_000) ? n : DEFAULT_MAX_ENTRIES;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Serialize evidence with a hard size cap. Oversized evidence is replaced by a
|
|
64
|
+
* truncated string form — the log line must stay small so the single-write
|
|
65
|
+
* append atomicity argument holds.
|
|
66
|
+
*/
|
|
67
|
+
function _capEvidence(evidence) {
|
|
68
|
+
if (evidence === undefined || evidence === null) return null;
|
|
69
|
+
let s;
|
|
70
|
+
try {
|
|
71
|
+
s = JSON.stringify(evidence);
|
|
72
|
+
} catch {
|
|
73
|
+
s = String(evidence);
|
|
74
|
+
}
|
|
75
|
+
if (s.length <= EVIDENCE_MAX_BYTES) {
|
|
76
|
+
try { return JSON.parse(s); } catch { return s; }
|
|
77
|
+
}
|
|
78
|
+
return { _truncated: true, head: s.slice(0, EVIDENCE_MAX_BYTES) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Record one shadow divergence (oldVerdict !== newVerdict). Call sites are
|
|
83
|
+
* expected to compare verdicts BEFORE calling — agreements are not logged
|
|
84
|
+
* (the log captures the would-change population, not every scan).
|
|
85
|
+
* Never throws. No-op when shadow mode is disabled.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} d
|
|
88
|
+
* @param {string} d.detector e.g. 'compromised_email_domain'
|
|
89
|
+
* @param {string} [d.package]
|
|
90
|
+
* @param {string} [d.version]
|
|
91
|
+
* @param {string} [d.ecosystem]
|
|
92
|
+
* @param {*} d.oldVerdict live semantics result
|
|
93
|
+
* @param {*} d.newVerdict candidate semantics result
|
|
94
|
+
* @param {*} [d.evidence] capped at 2KB serialized
|
|
95
|
+
*/
|
|
96
|
+
function recordShadowDivergence(d) {
|
|
97
|
+
try {
|
|
98
|
+
if (!isShadowEnabled()) return;
|
|
99
|
+
if (!d || !d.detector) return;
|
|
100
|
+
const file = _shadowFile();
|
|
101
|
+
const dir = path.dirname(file);
|
|
102
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
103
|
+
const entry = {
|
|
104
|
+
ts: new Date().toISOString(),
|
|
105
|
+
detector: String(d.detector),
|
|
106
|
+
package: d.package || null,
|
|
107
|
+
version: d.version || null,
|
|
108
|
+
ecosystem: d.ecosystem || null,
|
|
109
|
+
oldVerdict: d.oldVerdict !== undefined ? d.oldVerdict : null,
|
|
110
|
+
newVerdict: d.newVerdict !== undefined ? d.newVerdict : null,
|
|
111
|
+
evidence: _capEvidence(d.evidence)
|
|
112
|
+
};
|
|
113
|
+
// ONE write per line — see the concurrency note in the header.
|
|
114
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n', { encoding: 'utf8', flag: 'a' });
|
|
115
|
+
_appendsSinceCheck++;
|
|
116
|
+
if (_appendsSinceCheck >= COMPACT_CHECK_INTERVAL) {
|
|
117
|
+
_appendsSinceCheck = 0;
|
|
118
|
+
_compactShadowJsonl(file);
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Never throw, never log loudly — a shadow failure must not affect scans.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Streaming FIFO compaction: keep only the most recent max entries.
|
|
127
|
+
* Local minimal implementation (not shared with state.js) so the worker-side
|
|
128
|
+
* require graph stays free of the monitor state module.
|
|
129
|
+
*/
|
|
130
|
+
function _compactShadowJsonl(file) {
|
|
131
|
+
try {
|
|
132
|
+
const max = _maxEntries();
|
|
133
|
+
const lines = _readLines(file);
|
|
134
|
+
if (lines.length <= max) return;
|
|
135
|
+
const kept = lines.slice(lines.length - max);
|
|
136
|
+
const tmp = file + '.tmp';
|
|
137
|
+
fs.writeFileSync(tmp, kept.join('\n') + '\n', 'utf8');
|
|
138
|
+
fs.renameSync(tmp, file);
|
|
139
|
+
} catch {
|
|
140
|
+
// Best-effort; an oversized shadow log is preferable to a crashed scan.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Read raw lines, dropping empties. Returns [] on any error. */
|
|
145
|
+
function _readLines(file) {
|
|
146
|
+
try {
|
|
147
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter(l => l.trim().length > 0);
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read divergence entries, tolerant of corrupt lines (skipped silently).
|
|
155
|
+
* @param {object} [opts]
|
|
156
|
+
* @param {string} [opts.detector] filter by detector
|
|
157
|
+
* @param {number|string} [opts.sinceTs] ms epoch or ISO — entries older are skipped
|
|
158
|
+
* @returns {Array<object>}
|
|
159
|
+
*/
|
|
160
|
+
function readShadowDivergences(opts = {}) {
|
|
161
|
+
let sinceMs = null;
|
|
162
|
+
if (typeof opts.sinceTs === 'number' && Number.isFinite(opts.sinceTs)) sinceMs = opts.sinceTs;
|
|
163
|
+
else if (typeof opts.sinceTs === 'string') {
|
|
164
|
+
const p = Date.parse(opts.sinceTs);
|
|
165
|
+
if (!Number.isNaN(p)) sinceMs = p;
|
|
166
|
+
}
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const line of _readLines(_shadowFile())) {
|
|
169
|
+
let e;
|
|
170
|
+
try { e = JSON.parse(line); } catch { continue; } // truncated/corrupt line
|
|
171
|
+
if (!e || typeof e !== 'object' || !e.detector) continue;
|
|
172
|
+
if (opts.detector && e.detector !== opts.detector) continue;
|
|
173
|
+
if (sinceMs !== null) {
|
|
174
|
+
const t = e.ts ? Date.parse(e.ts) : NaN;
|
|
175
|
+
if (Number.isNaN(t) || t < sinceMs) continue;
|
|
176
|
+
}
|
|
177
|
+
out.push(e);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
isShadowEnabled,
|
|
184
|
+
recordShadowDivergence,
|
|
185
|
+
readShadowDivergences,
|
|
186
|
+
// test seams
|
|
187
|
+
_capEvidence,
|
|
188
|
+
_compactShadowJsonl,
|
|
189
|
+
EVIDENCE_MAX_BYTES
|
|
190
|
+
};
|