security: add node-ipc IOC coverage (#1924)

This commit is contained in:
Affaan Mustafa
2026-05-15 06:56:57 -04:00
committed by GitHub
parent 5b9acd1d92
commit ee85e1482e
4 changed files with 149 additions and 3 deletions

View File

@@ -64,17 +64,22 @@ Project documents added in Linear:
| Surface | Evidence | | Surface | Evidence |
| --- | --- | | --- | --- |
| PR #1921 | Merged supply-chain IOC expansion for Mini Shai-Hulud/TanStack follow-up | | PR #1921 | Merged supply-chain IOC expansion for Mini Shai-Hulud/TanStack follow-up |
| Node IPC follow-up | Added May 14 `node-ipc` malicious-version, hash, DNS, and runtime IOC coverage |
| Merge commit | `f04702bdac132662c8496e817bcd850c86e2b854` | | Merge commit | `f04702bdac132662c8496e817bcd850c86e2b854` |
| Local IOC tests | `node tests/ci/scan-supply-chain-iocs.test.js` passed 11/11 | | Local IOC tests | `node tests/ci/scan-supply-chain-iocs.test.js` passed 12/12 |
| Unicode safety | `node scripts/ci/check-unicode-safety.js` passed | | Unicode safety | `node scripts/ci/check-unicode-safety.js` passed |
| IOC scan | `npm run security:ioc-scan` passed | | IOC scan | `npm run security:ioc-scan` passed |
| Root suite | `npm test` passed 2426/2426, 0 failed | | Root suite | `npm test` passed 2427/2427, 0 failed |
| Repo sweeps | IOC scanner sweep passed for trunk, AgentShield, ECC Tools, ECC website, JARVIS, and the ECC document mirror | | Repo sweeps | IOC scanner sweep passed for trunk, AgentShield, ECC Tools, ECC website, JARVIS, and the ECC document mirror |
The May 15 IOC expansion added coverage for OpenSearch/Mistral/Guardrails/ The May 15 IOC expansion added coverage for OpenSearch/Mistral/Guardrails/
UiPath/Squawk-style campaign variants, `opensearch_init.js`, `vite_setup.mjs`, UiPath/Squawk-style campaign variants, `opensearch_init.js`, `vite_setup.mjs`,
dead-drop/session protocol strings, and AI-tooling persistence surfaces without dead-drop/session protocol strings, and AI-tooling persistence surfaces without
committing full high-entropy indicators that trip secret scanners. committing full high-entropy indicators that trip secret scanners.
The May 15 node-ipc follow-up blocks `node-ipc@9.1.6`, `9.2.3`, `10.1.1`,
`10.1.2`, `11.0.0`, `11.1.0`, and `12.0.1`, plus the `node-ipc.cjs` payload
hash, malicious tarball hashes, DNS exfil domains, and runtime markers reported
by Socket.
## Current Publication Blockers ## Current Publication Blockers

View File

@@ -21,6 +21,10 @@ credentials:
- Follow-on reporting from StepSecurity, Socket, Aikido, and Wiz describes the - Follow-on reporting from StepSecurity, Socket, Aikido, and Wiz describes the
same campaign expanding into packages associated with Mistral AI, UiPath, same campaign expanding into packages associated with Mistral AI, UiPath,
OpenSearch, Guardrails AI, Squawk, and other npm/PyPI packages. OpenSearch, Guardrails AI, Squawk, and other npm/PyPI packages.
- Socket's 2026-05-14 `node-ipc` report describes a separate active npm
compromise affecting `node-ipc` versions `9.1.6`, `9.2.3`, and `12.0.1`,
with historical malicious `node-ipc` versions also blocked by ECC because
they carried destructive or unauthorized file-writing behavior.
- The live IOC set includes persistence through Claude Code - The live IOC set includes persistence through Claude Code
`.claude/settings.json`, VS Code `.vscode/tasks.json`, and OS-level `.claude/settings.json`, VS Code `.vscode/tasks.json`, and OS-level
`gh-token-monitor` LaunchAgent/systemd services. Some variants add a `gh-token-monitor` LaunchAgent/systemd services. Some variants add a
@@ -35,6 +39,12 @@ credentials:
`opensearch_init.js`, `vite_setup.mjs`, campaign salt `svksjrhjkcejg`, `opensearch_init.js`, `vite_setup.mjs`, campaign salt `svksjrhjkcejg`,
Session protocol strings, `claude@users.noreply.github.com` dead-drop Session protocol strings, `claude@users.noreply.github.com` dead-drop
commits, `dependabout/` branch names, and `OhNoWhatsGoingOnWithGitHub`. commits, `dependabout/` branch names, and `OhNoWhatsGoingOnWithGitHub`.
- The `node-ipc` sweep watches for `node-ipc.cjs` payload hash
`96097e06...d9034144`, tarball hashes for the malicious `9.1.6`, `9.2.3`,
and `12.0.1` artifacts, `sh.azurestaticprovider.net`, `bt.node.js`,
`37.16.75.69`, DNS exfil labels `xh` / `xd` / `xf` where present in
artifacts, `__ntw`, `__ntRun`, `/nt-` temp archives, and archive entries such
as `uname.txt`, `envs.txt`, and `fixtures/_paths.txt`.
- The attack chain combined `pull_request_target`, GitHub Actions cache - The attack chain combined `pull_request_target`, GitHub Actions cache
poisoning across a fork/base trust boundary, and OIDC token extraction from a poisoning across a fork/base trust boundary, and OIDC token extraction from a
GitHub Actions runner. GitHub Actions runner.
@@ -47,6 +57,7 @@ Primary references:
- <https://tanstack.com/blog/npm-supply-chain-compromise-postmortem> - <https://tanstack.com/blog/npm-supply-chain-compromise-postmortem>
- <https://github.com/advisories/GHSA-g7cv-rxg3-hmpx> - <https://github.com/advisories/GHSA-g7cv-rxg3-hmpx>
- <https://tanstack.com/blog/incident-followup> - <https://tanstack.com/blog/incident-followup>
- <https://socket.dev/blog/node-ipc-package-compromised>
- <https://docs.npmjs.com/trusted-publishers/> - <https://docs.npmjs.com/trusted-publishers/>
- <https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem> - <https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem>

View File

@@ -5,6 +5,7 @@
*/ */
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
@@ -204,6 +205,7 @@ const MALICIOUS_PACKAGE_VERSIONS = {
'mbt': ['1.2.48'], 'mbt': ['1.2.48'],
'mistralai': ['2.4.6'], 'mistralai': ['2.4.6'],
'ml-toolkit-ts': ['1.0.4', '1.0.5'], 'ml-toolkit-ts': ['1.0.4', '1.0.5'],
'node-ipc': ['9.1.6', '9.2.3', '10.1.1', '10.1.2', '11.0.0', '11.1.0', '12.0.1'],
'nextmove-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.7'], 'nextmove-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.7'],
'safe-action': ['0.8.3', '0.8.4'], 'safe-action': ['0.8.3', '0.8.4'],
'ts-dna': ['3.0.1', '3.0.2', '3.0.3', '3.0.4', '3.0.5'], 'ts-dna': ['3.0.1', '3.0.2', '3.0.3', '3.0.4', '3.0.5'],
@@ -266,8 +268,60 @@ const CRITICAL_TEXT_INDICATORS = [
'PUSH UR T3MPRR', 'PUSH UR T3MPRR',
'codeql_analysis.yml', 'codeql_analysis.yml',
'shai-hulud-workflow.yml', 'shai-hulud-workflow.yml',
[
'96097e0612d9575c',
'b133021017fb1a5c',
'68a03b60f9f3d24e',
'bdc0e628d9034144',
].join(''),
[
'449e4265979b5fdb',
'2d3446c021af437e',
'815debd66de7da2f',
'e54f1ad93cbcc75e',
].join(''),
[
'c2f4dc64aec46315',
'40a568e88932b61d',
'aebbfb7e8281b812',
'fa01b7215f9be9ea',
].join(''),
[
'78a82d93b4f58083',
'5f5823b85a3d9ee1',
'f03a15ee6f0e01b',
'4eac86252a7002981',
].join(''),
'sh.azurestaticprovider.net',
'37.16.75.69',
'bt.node.js',
'__ntw',
'__ntRun',
'/nt-',
'uname.txt',
'envs.txt',
'fixtures/_paths.txt',
]; ];
const MALICIOUS_FILE_HASHES = {
'96097e0612d9575cb133021017fb1a5c68a03b60f9f3d24ebdc0e628d9034144': {
indicator: 'node-ipc.cjs sha256',
message: 'Known malicious node-ipc CommonJS payload hash is present',
},
'449e4265979b5fdb2d3446c021af437e815debd66de7da2fe54f1ad93cbcc75e': {
indicator: 'node-ipc-9.1.6.tgz sha256',
message: 'Known malicious node-ipc tarball hash is present',
},
'c2f4dc64aec4631540a568e88932b61daebbfb7e8281b812fa01b7215f9be9ea': {
indicator: 'node-ipc-9.2.3.tgz sha256',
message: 'Known malicious node-ipc tarball hash is present',
},
'78a82d93b4f580835f5823b85a3d9ee1f03a15ee6f0e01b4eac86252a7002981': {
indicator: 'node-ipc-12.0.1.tar.gz sha256',
message: 'Known malicious node-ipc tarball hash is present',
},
};
const DEPENDENCY_FILENAMES = new Set([ const DEPENDENCY_FILENAMES = new Set([
'package.json', 'package.json',
'package-lock.json', 'package-lock.json',
@@ -279,6 +333,13 @@ const DEPENDENCY_FILENAMES = new Set([
'requirements.txt', 'requirements.txt',
]); ]);
const INSPECT_ONLY_FILENAMES = new Set([
'node-ipc.cjs',
'node-ipc-9.1.6.tgz',
'node-ipc-9.2.3.tgz',
'node-ipc-12.0.1.tar.gz',
]);
const PERSISTENCE_FILENAMES = new Set([ const PERSISTENCE_FILENAMES = new Set([
'settings.json', 'settings.json',
'tasks.json', 'tasks.json',
@@ -342,6 +403,7 @@ function shouldInspectFile(filePath) {
if (DEPENDENCY_FILENAMES.has(base)) return true; if (DEPENDENCY_FILENAMES.has(base)) return true;
if (PERSISTENCE_FILENAMES.has(base) && isInSpecialConfigPath(filePath)) return true; if (PERSISTENCE_FILENAMES.has(base) && isInSpecialConfigPath(filePath)) return true;
if (PAYLOAD_FILENAMES.has(base) && filePath.includes(`${path.sep}node_modules${path.sep}`)) return true; if (PAYLOAD_FILENAMES.has(base) && filePath.includes(`${path.sep}node_modules${path.sep}`)) return true;
if (INSPECT_ONLY_FILENAMES.has(base)) return true;
return false; return false;
} }
@@ -392,7 +454,13 @@ function walkNodeModules(nodeModulesDir, files) {
} }
function inspectPackageDir(packageDir, files) { function inspectPackageDir(packageDir, files) {
for (const filename of [...DEPENDENCY_FILENAMES, ...PAYLOAD_FILENAMES, 'setup.mjs', 'execution.js']) { for (const filename of [
...DEPENDENCY_FILENAMES,
...PAYLOAD_FILENAMES,
...INSPECT_ONLY_FILENAMES,
'setup.mjs',
'execution.js',
]) {
const candidate = path.join(packageDir, filename); const candidate = path.join(packageDir, filename);
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
files.push(candidate); files.push(candidate);
@@ -408,6 +476,14 @@ function readText(filePath) {
} }
} }
function sha256File(filePath) {
try {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
} catch {
return '';
}
}
function lineForIndex(text, index) { function lineForIndex(text, index) {
return text.slice(0, index).split(/\r?\n/).length; return text.slice(0, index).split(/\r?\n/).length;
} }
@@ -425,6 +501,18 @@ function scanFile(filePath, rootDir, findings) {
const relativePath = path.relative(rootDir, filePath) || filePath; const relativePath = path.relative(rootDir, filePath) || filePath;
const text = readText(filePath); const text = readText(filePath);
const lowerText = normalizeForMatch(text); const lowerText = normalizeForMatch(text);
const hashFinding = MALICIOUS_FILE_HASHES[sha256File(filePath)];
if (hashFinding) {
addFinding(
findings,
'critical',
relativePath,
1,
hashFinding.indicator,
hashFinding.message,
);
}
if (PAYLOAD_FILENAMES.has(base)) { if (PAYLOAD_FILENAMES.has(base)) {
addFinding( addFinding(
@@ -492,8 +580,14 @@ function runtimeTargets() {
return [ return [
'/tmp/transformers.pyz', '/tmp/transformers.pyz',
'/tmp/pgmonitor.py', '/tmp/pgmonitor.py',
'/tmp/node-ipc-9.1.6.tgz',
'/tmp/node-ipc-9.2.3.tgz',
'/tmp/node-ipc-12.0.1.tar.gz',
'/private/tmp/transformers.pyz', '/private/tmp/transformers.pyz',
'/private/tmp/pgmonitor.py', '/private/tmp/pgmonitor.py',
'/private/tmp/node-ipc-9.1.6.tgz',
'/private/tmp/node-ipc-9.2.3.tgz',
'/private/tmp/node-ipc-12.0.1.tar.gz',
]; ];
} }
@@ -575,6 +669,7 @@ if (require.main === module) {
module.exports = { module.exports = {
CRITICAL_TEXT_INDICATORS, CRITICAL_TEXT_INDICATORS,
MALICIOUS_FILE_HASHES,
MALICIOUS_PACKAGE_VERSIONS, MALICIOUS_PACKAGE_VERSIONS,
scanSupplyChainIocs, scanSupplyChainIocs,
}; };

View File

@@ -104,6 +104,41 @@ function run() {
}); });
})) passed++; else failed++; })) passed++; else failed++;
if (test('rejects node-ipc campaign package versions and CJS indicators', () => {
withFixture({
'package-lock.json': JSON.stringify({
packages: {
'node_modules/node-ipc': {
version: '12.0.1',
},
},
}, null, 2),
'node_modules/node-ipc/package.json': JSON.stringify({
name: 'node-ipc',
version: '9.2.3',
}, null, 2),
'node_modules/node-ipc/node-ipc.cjs': [
'const host = "sh.azurestaticprovider.net";',
'const zone = "bt.node.js";',
'process.env.__ntw = "1";',
'module.exports.__ntRun = true;',
'const archive = "/nt-/sample.tar.gz";',
'const entries = ["uname.txt", "envs.txt", "fixtures/_paths.txt"];',
].join('\n'),
}, rootDir => {
const result = scanSupplyChainIocs({ rootDir });
const indicators = result.findings.map(finding => finding.indicator);
assert.ok(indicators.includes('node-ipc@12.0.1'));
assert.ok(indicators.includes('node-ipc@9.2.3'));
assert.ok(indicators.includes('sh.azurestaticprovider.net'));
assert.ok(indicators.includes('bt.node.js'));
assert.ok(indicators.includes('__ntw'));
assert.ok(indicators.includes('__ntRun'));
assert.ok(indicators.includes('/nt-'));
assert.ok(indicators.includes('fixtures/_paths.txt'));
});
})) passed++; else failed++;
if (test('passes clean versions of watched packages', () => { if (test('passes clean versions of watched packages', () => {
withFixture({ withFixture({
'package-lock.json': JSON.stringify({ 'package-lock.json': JSON.stringify({