@automagik/genie 4.260424.18 → 4.260424.20
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": "genie",
|
|
3
|
-
"version": "4.260424.
|
|
3
|
+
"version": "4.260424.20",
|
|
4
4
|
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Namastex Labs"
|
package/scripts/sec-fix.cjs
CHANGED
|
@@ -241,9 +241,37 @@ function promptYesNo(message, { yes }) {
|
|
|
241
241
|
// Scan
|
|
242
242
|
// ---------------------------------------------------------------------------
|
|
243
243
|
|
|
244
|
+
function isRootUid() {
|
|
245
|
+
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
244
248
|
function runScan() {
|
|
245
249
|
section('1/6 Scan — gathering evidence');
|
|
246
|
-
const
|
|
250
|
+
const rootMode = isRootUid();
|
|
251
|
+
if (rootMode) {
|
|
252
|
+
process.stderr.write(
|
|
253
|
+
` ${TTY.bgRed}${TTY.white}${TTY.bold} ROOT MODE ${TTY.reset} ${TTY.red}${TTY.bold}scanning every user home, system-wide persistence, all PIDs, root-only files${TTY.reset}\n`,
|
|
254
|
+
);
|
|
255
|
+
if (process.env.SUDO_USER && process.env.SUDO_USER !== 'root') {
|
|
256
|
+
process.stderr.write(
|
|
257
|
+
` ${TTY.dim}invoking user: ${TTY.reset}${process.env.SUDO_USER} ${TTY.dim}(reinstall will be routed back to this user via su)${TTY.reset}\n`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
process.stderr.write(
|
|
262
|
+
` ${TTY.yellow}running as user $(id -un) — for full coverage of other homes + /etc/cron + /etc/systemd + all PIDs, re-run under ${TTY.bold}sudo -E env "PATH=$PATH" genie sec fix${TTY.reset}\n`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const scanArgs = [SCAN_SCRIPT, '--json', '--no-progress', '--redact'];
|
|
266
|
+
if (rootMode) {
|
|
267
|
+
// --all-homes enumerates /root + every /home/* + /Users/* on darwin.
|
|
268
|
+
// The persistence + shell-history + impact-surface phases iterate these
|
|
269
|
+
// homes so the deeper coverage falls out naturally once they're in the
|
|
270
|
+
// input list. /etc/cron.* and /etc/systemd/system/* are already in the
|
|
271
|
+
// persistence target table and become readable under root.
|
|
272
|
+
scanArgs.push('--all-homes');
|
|
273
|
+
}
|
|
274
|
+
const result = spawnSync(process.execPath, scanArgs, {
|
|
247
275
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
248
276
|
maxBuffer: 256 * 1024 * 1024,
|
|
249
277
|
});
|
|
@@ -365,7 +393,63 @@ function renderAuditRow(severity, verb, target, recovery) {
|
|
|
365
393
|
process.stderr.write(` ${TTY.dim}recovery: ${TTY.reset}${TTY.cyan}${recovery}${TTY.reset}\n\n`);
|
|
366
394
|
}
|
|
367
395
|
|
|
368
|
-
function
|
|
396
|
+
function renderBreachImpact(envelope) {
|
|
397
|
+
const breach = envelope?.breachImpact || { enabled: false };
|
|
398
|
+
if (!breach.enabled || (breach.likelyStolen || []).length === 0) return;
|
|
399
|
+
|
|
400
|
+
process.stderr.write('\n');
|
|
401
|
+
hrule('═', TTY.red + TTY.bold);
|
|
402
|
+
banner('☠ BREACH IMPACT — RETRACING THE WORM ☠', SEVERITY.CRITICAL);
|
|
403
|
+
hrule('═', TTY.red + TTY.bold);
|
|
404
|
+
process.stderr.write('\n');
|
|
405
|
+
|
|
406
|
+
process.stderr.write(
|
|
407
|
+
` ${TTY.dim}Exfil channel:${TTY.reset} ${TTY.red}${TTY.bold}${breach.exfilChannel.host}${TTY.reset} ${breach.exfilChannel.paths.join(', ')}\n`,
|
|
408
|
+
);
|
|
409
|
+
if (breach.compromiseWindow) {
|
|
410
|
+
process.stderr.write(
|
|
411
|
+
` ${TTY.dim}Window:${TTY.reset} ${breach.compromiseWindow.firstEvidence} .. ${breach.compromiseWindow.lastEvidence}\n`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if ((breach.compromisedInstallPaths || []).length > 0) {
|
|
415
|
+
process.stderr.write(` ${TTY.dim}Install path:${TTY.reset} ${breach.compromisedInstallPaths[0]}\n`);
|
|
416
|
+
}
|
|
417
|
+
process.stderr.write('\n');
|
|
418
|
+
process.stderr.write(
|
|
419
|
+
` ${TTY.bold}${TTY.red}The env-compat.cjs payload ran as this user during the window.${TTY.reset}\n`,
|
|
420
|
+
);
|
|
421
|
+
process.stderr.write(` ${TTY.dim}These credentials were readable to it — assume stolen:${TTY.reset}\n\n`);
|
|
422
|
+
|
|
423
|
+
for (const item of breach.rotationChecklist || []) {
|
|
424
|
+
const sev =
|
|
425
|
+
item.severity === 'CRITICAL'
|
|
426
|
+
? SEVERITY.CRITICAL.paint(` ${item.severity} `)
|
|
427
|
+
: SEVERITY.DESTRUCTIVE.paint(` ${item.severity} `);
|
|
428
|
+
process.stderr.write(` ${sev} ${TTY.bold}${item.category}${TTY.reset}\n`);
|
|
429
|
+
for (const p of item.paths || []) process.stderr.write(` ${TTY.dim}path:${TTY.reset} ${p}\n`);
|
|
430
|
+
process.stderr.write(` ${TTY.dim}why:${TTY.reset} ${item.reason}\n`);
|
|
431
|
+
if (item.rotationUrl) {
|
|
432
|
+
process.stderr.write(
|
|
433
|
+
` ${TTY.cyan}rotate:${TTY.reset} ${TTY.underline}${item.rotationUrl}${TTY.reset}\n`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
process.stderr.write('\n');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if ((breach.runningProcessesDuringWindow || []).length > 0) {
|
|
440
|
+
process.stderr.write(` ${TTY.bold}Processes that ran the compromised binary:${TTY.reset}\n`);
|
|
441
|
+
for (const proc of breach.runningProcessesDuringWindow) {
|
|
442
|
+
process.stderr.write(
|
|
443
|
+
` pid=${proc.pid} elapsed=${proc.elapsed} ${TTY.dim}${(proc.command || '').slice(0, 90)}${TTY.reset}\n`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
process.stderr.write('\n');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function showPlanSummary(plan, options, envelope) {
|
|
451
|
+
renderBreachImpact(envelope);
|
|
452
|
+
|
|
369
453
|
process.stderr.write('\n');
|
|
370
454
|
hrule('═', TTY.red + TTY.bold);
|
|
371
455
|
banner('⚠ DESTRUCTIVE OPERATIONS AUDIT — REVIEW BEFORE ACCEPTING ⚠', SEVERITY.DESTRUCTIVE);
|
|
@@ -600,6 +684,29 @@ function reinstall(options) {
|
|
|
600
684
|
warn('bun not found in PATH — skipping reinstall. Install manually: bun add -g @automagik/genie@next');
|
|
601
685
|
return { reinstalled: false, reason: 'bun-not-found' };
|
|
602
686
|
}
|
|
687
|
+
|
|
688
|
+
// When running under sudo, route the install to the invoking user so
|
|
689
|
+
// the bun global ends up in THEIR home (correct ownership + matches the
|
|
690
|
+
// binary that user invokes from the command line). Root's own bun
|
|
691
|
+
// global would be orphaned.
|
|
692
|
+
const sudoUser = process.env.SUDO_USER;
|
|
693
|
+
if (isRootUid() && sudoUser && sudoUser !== 'root') {
|
|
694
|
+
info(`routing reinstall to invoking user: ${sudoUser}`);
|
|
695
|
+
const suBin = findExecutable('su');
|
|
696
|
+
if (!suBin) {
|
|
697
|
+
warn('su not found — falling back to root install (may need manual reinstall as the user later)');
|
|
698
|
+
} else {
|
|
699
|
+
const cmd = `${bunBin} add -g @automagik/genie@next`;
|
|
700
|
+
const result = spawnSync(suBin, ['-', sudoUser, '-c', cmd], { stdio: 'inherit' });
|
|
701
|
+
if (result.status !== 0) {
|
|
702
|
+
warn(`reinstall (as ${sudoUser}) exited with code ${result.status}`);
|
|
703
|
+
return { reinstalled: false, exitCode: result.status, ranAs: sudoUser };
|
|
704
|
+
}
|
|
705
|
+
ok(`reinstalled @automagik/genie@next (as ${sudoUser})`);
|
|
706
|
+
return { reinstalled: true, ranAs: sudoUser };
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
603
710
|
const result = spawnSync(bunBin, ['add', '-g', '@automagik/genie@next'], { stdio: 'inherit' });
|
|
604
711
|
if (result.status !== 0) {
|
|
605
712
|
warn(`reinstall exited with code ${result.status}`);
|
|
@@ -625,7 +732,9 @@ function findExecutable(name) {
|
|
|
625
732
|
function rescan(options) {
|
|
626
733
|
if (options.skipRescan) return null;
|
|
627
734
|
section('6/6 Re-scan — confirm clean state');
|
|
628
|
-
const
|
|
735
|
+
const rescanArgs = [SCAN_SCRIPT, '--json', '--no-progress', '--redact'];
|
|
736
|
+
if (isRootUid()) rescanArgs.push('--all-homes');
|
|
737
|
+
const result = spawnSync(process.execPath, rescanArgs, {
|
|
629
738
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
630
739
|
maxBuffer: 256 * 1024 * 1024,
|
|
631
740
|
});
|
|
@@ -668,7 +777,7 @@ function main() {
|
|
|
668
777
|
|
|
669
778
|
const envelope = runScan();
|
|
670
779
|
const plan = classifyEnvelope(envelope);
|
|
671
|
-
const somethingToDo = showPlanSummary(plan, options);
|
|
780
|
+
const somethingToDo = showPlanSummary(plan, options, envelope);
|
|
672
781
|
|
|
673
782
|
if (!somethingToDo) {
|
|
674
783
|
if (options.json) {
|
package/scripts/sec-scan.cjs
CHANGED
|
@@ -364,9 +364,24 @@ const TEXT_MATCHERS = [
|
|
|
364
364
|
regex: /\b(?:node|bun|bash|sh)\b[^\n]{0,200}env-compat\.(?:cjs|js)\b/i,
|
|
365
365
|
},
|
|
366
366
|
{
|
|
367
|
-
|
|
367
|
+
// Hard evidence: curl/wget/fetch to the exact exfil endpoint path.
|
|
368
|
+
// This is what the CanisterWorm payload uses to upload stolen data
|
|
369
|
+
// (POST /v1/telemetry and POST /v1/drop). Matching these = compromise.
|
|
370
|
+
label: 'network:curl-wget IOC-exfil',
|
|
368
371
|
category: 'network',
|
|
369
|
-
regex:
|
|
372
|
+
regex:
|
|
373
|
+
/\b(?:curl|wget|fetch|Invoke-WebRequest)\b[^\n]{0,200}(?:telemetry\.api-monitor\.com\/v1\/(?:telemetry|drop)|raw\.icp0\.io\/drop)/i,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
// Soft evidence: bare-host mention of the exfil domain WITHOUT the
|
|
377
|
+
// /v1/ path. Almost always an incident responder (or documentation)
|
|
378
|
+
// probing the host, not the payload itself — the payload never runs
|
|
379
|
+
// a bare `curl <host>` because there's no endpoint that would accept
|
|
380
|
+
// the uploaded payload. Classify as 'probe' so it shows in the
|
|
381
|
+
// report but doesn't elevate the suspicion score.
|
|
382
|
+
label: 'network:exfil-host-probe',
|
|
383
|
+
category: 'probe',
|
|
384
|
+
regex: /\b(?:curl|wget|fetch|Invoke-WebRequest)\b[^\n]{0,200}telemetry\.api-monitor\.com(?!\/v1\/)/i,
|
|
370
385
|
},
|
|
371
386
|
];
|
|
372
387
|
|
|
@@ -1415,6 +1430,7 @@ function collectTextIndicators(text) {
|
|
|
1415
1430
|
installCommands: [],
|
|
1416
1431
|
executionCommands: [],
|
|
1417
1432
|
networkCommands: [],
|
|
1433
|
+
probeMatches: [],
|
|
1418
1434
|
allMatches: [],
|
|
1419
1435
|
};
|
|
1420
1436
|
|
|
@@ -1427,6 +1443,11 @@ function collectTextIndicators(text) {
|
|
|
1427
1443
|
if (matcher.category === 'install') indicators.installCommands.push(matcher.label);
|
|
1428
1444
|
if (matcher.category === 'execution') indicators.executionCommands.push(matcher.label);
|
|
1429
1445
|
if (matcher.category === 'network') indicators.networkCommands.push(matcher.label);
|
|
1446
|
+
// `probe` is informational-only: it means the text references an
|
|
1447
|
+
// exfil host but WITHOUT the attacker's uploading path. Almost
|
|
1448
|
+
// always a responder probing or documentation. Never elevates
|
|
1449
|
+
// compromise severity.
|
|
1450
|
+
if (matcher.category === 'probe') indicators.probeMatches.push(matcher.label);
|
|
1430
1451
|
}
|
|
1431
1452
|
|
|
1432
1453
|
for (const trackedPackage of TRACKED_PACKAGES) {
|
|
@@ -2639,6 +2660,229 @@ function scanImpactSurface(homes, roots, report, runtime) {
|
|
|
2639
2660
|
report.impactSurfaceFindings = uniq(findings.map((entry) => JSON.stringify(entry))).map((entry) => JSON.parse(entry));
|
|
2640
2661
|
}
|
|
2641
2662
|
|
|
2663
|
+
// ---------------------------------------------------------------------------
|
|
2664
|
+
// Breach-impact analysis — retrace the known CanisterWorm payload behavior
|
|
2665
|
+
// against what actually exists on this host.
|
|
2666
|
+
//
|
|
2667
|
+
// The `env-compat.cjs` payload is a postinstall script that runs under the
|
|
2668
|
+
// installing user's identity. Public research + the IOC strings the scanner
|
|
2669
|
+
// already matches (TEL_ENDPOINT, ICP_CANISTER_ID, pkg-telemetry, AES-256-CBC,
|
|
2670
|
+
// RSA-OAEP-SHA256, pypi-pth-exfil, etc.) give us a concrete list of targets:
|
|
2671
|
+
//
|
|
2672
|
+
// 1. Environment variables visible to `npm run postinstall` at install
|
|
2673
|
+
// time (anything in process.env when `env-compat.cjs` executed). The
|
|
2674
|
+
// scanner cannot read historical env state, but install-time env for
|
|
2675
|
+
// shells is commonly .env files + shell profiles.
|
|
2676
|
+
// 2. Credential files under $HOME that the install-user could read.
|
|
2677
|
+
// 3. Browser login-data / cookie stores (chrome/brave/edge/chromium).
|
|
2678
|
+
// 4. Crypto wallets.
|
|
2679
|
+
// 5. SSH keys + known_hosts (for lateral movement).
|
|
2680
|
+
// 6. Session tokens in ~/.config/gh and ~/.config/gcloud and ~/.aws.
|
|
2681
|
+
//
|
|
2682
|
+
// This phase correlates the scanner's own impactSurfaceFindings (what EXISTS
|
|
2683
|
+
// on this host) with the known CanisterWorm targeting to produce
|
|
2684
|
+
// `breachImpact.likelyStolen` — a structured, actionable checklist for
|
|
2685
|
+
// credential rotation rather than a generic "rotate your tokens" line.
|
|
2686
|
+
//
|
|
2687
|
+
// Only fires if hard compromise evidence is present; otherwise the host is
|
|
2688
|
+
// not believed to have been exposed and we emit an empty/disabled report.
|
|
2689
|
+
// ---------------------------------------------------------------------------
|
|
2690
|
+
|
|
2691
|
+
const CANISTERWORM_TARGETS = [
|
|
2692
|
+
// Package registry tokens — highest priority (full supply-chain impact).
|
|
2693
|
+
{
|
|
2694
|
+
match: /(^|\/)\.npmrc$/,
|
|
2695
|
+
category: 'npm-token',
|
|
2696
|
+
severity: 'CRITICAL',
|
|
2697
|
+
rotationUrl: 'https://www.npmjs.com/settings/~/tokens',
|
|
2698
|
+
reason: 'env-compat.cjs reads ~/.npmrc to exfil auth tokens; compromised npm publish rights = supply-chain risk',
|
|
2699
|
+
},
|
|
2700
|
+
{
|
|
2701
|
+
match: /\.config\/gh\/hosts\.yml$/,
|
|
2702
|
+
category: 'github-pat',
|
|
2703
|
+
severity: 'CRITICAL',
|
|
2704
|
+
rotationUrl: 'https://github.com/settings/tokens',
|
|
2705
|
+
reason:
|
|
2706
|
+
'gh CLI tokens grant repo + workflow-secrets access; public research shows exfil to telemetry.api-monitor.com',
|
|
2707
|
+
},
|
|
2708
|
+
// Cloud IAM — infrastructure access.
|
|
2709
|
+
{
|
|
2710
|
+
match: /\.aws\/(credentials|config)$/,
|
|
2711
|
+
category: 'aws-iam',
|
|
2712
|
+
severity: 'CRITICAL',
|
|
2713
|
+
rotationUrl: 'https://console.aws.amazon.com/iam/home#/security_credentials',
|
|
2714
|
+
reason: 'AWS access keys enable infrastructure takeover',
|
|
2715
|
+
},
|
|
2716
|
+
{
|
|
2717
|
+
match: /\.config\/gcloud\/(application_default_credentials|access_tokens)/,
|
|
2718
|
+
category: 'gcp-iam',
|
|
2719
|
+
severity: 'CRITICAL',
|
|
2720
|
+
rotationUrl: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
|
|
2721
|
+
reason: 'GCP application-default credentials / access tokens enable project takeover',
|
|
2722
|
+
},
|
|
2723
|
+
{
|
|
2724
|
+
match: /\.azure\//,
|
|
2725
|
+
category: 'azure-iam',
|
|
2726
|
+
severity: 'CRITICAL',
|
|
2727
|
+
rotationUrl: 'https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview',
|
|
2728
|
+
reason: 'Azure CLI refresh tokens enable subscription access',
|
|
2729
|
+
},
|
|
2730
|
+
// AI provider keys (the payload specifically targets these per pkg-telemetry).
|
|
2731
|
+
{
|
|
2732
|
+
match: /(^|\/)\.env(\.|$)/,
|
|
2733
|
+
category: 'dotenv',
|
|
2734
|
+
severity: 'CRITICAL',
|
|
2735
|
+
rotationUrl: null,
|
|
2736
|
+
reason: 'env-compat.cjs reads .env files for ANTHROPIC_API_KEY / OPENAI_API_KEY / custom secrets',
|
|
2737
|
+
},
|
|
2738
|
+
// SSH — lateral movement.
|
|
2739
|
+
{
|
|
2740
|
+
match: /\.ssh\/(id_rsa|id_ed25519|id_ecdsa|config|known_hosts)/,
|
|
2741
|
+
category: 'ssh-key',
|
|
2742
|
+
severity: 'HIGH',
|
|
2743
|
+
rotationUrl: null,
|
|
2744
|
+
reason: 'SSH private keys + known_hosts enable lateral movement to other hosts',
|
|
2745
|
+
},
|
|
2746
|
+
// Browser login data — session cookies, stored passwords.
|
|
2747
|
+
{
|
|
2748
|
+
match:
|
|
2749
|
+
/(Application Support|\.config)\/(Google\/Chrome|Chromium|BraveSoftware|Microsoft Edge)\/(Default|Profile\s*\d+)\/?/i,
|
|
2750
|
+
category: 'browser-session',
|
|
2751
|
+
severity: 'HIGH',
|
|
2752
|
+
rotationUrl: null,
|
|
2753
|
+
reason: 'browser Login Data + Cookies files contain session tokens that bypass 2FA on re-use',
|
|
2754
|
+
},
|
|
2755
|
+
// Crypto wallets.
|
|
2756
|
+
{
|
|
2757
|
+
match: /(Application Support|\.local\/share|\.config)\/(Ledger Live|Exodus|Electrum|MetaMask)/i,
|
|
2758
|
+
category: 'crypto-wallet',
|
|
2759
|
+
severity: 'CRITICAL',
|
|
2760
|
+
rotationUrl: null,
|
|
2761
|
+
reason: 'crypto wallet data allows direct fund theft if keystore passphrase is also captured',
|
|
2762
|
+
},
|
|
2763
|
+
];
|
|
2764
|
+
|
|
2765
|
+
function classifyCanisterWormTarget(path) {
|
|
2766
|
+
for (const target of CANISTERWORM_TARGETS) {
|
|
2767
|
+
if (target.match.test(path)) return target;
|
|
2768
|
+
}
|
|
2769
|
+
return null;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function hasHardCompromiseEvidence(report) {
|
|
2773
|
+
if ((report.installFindings || []).length > 0) return true;
|
|
2774
|
+
if ((report.bunCacheFindings || []).length > 0) return true;
|
|
2775
|
+
if ((report.npmTarballFetches || []).length > 0) return true;
|
|
2776
|
+
// Any temp-artifact with known malware hash.
|
|
2777
|
+
for (const entry of report.tempArtifactFindings || []) {
|
|
2778
|
+
if (entry.knownMalwareHash) return true;
|
|
2779
|
+
if ((entry.iocMatches || []).length > 0) return true;
|
|
2780
|
+
}
|
|
2781
|
+
return false;
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
function scanBreachImpact(report) {
|
|
2785
|
+
const hasEvidence = hasHardCompromiseEvidence(report);
|
|
2786
|
+
const breachImpact = {
|
|
2787
|
+
enabled: hasEvidence,
|
|
2788
|
+
exfilChannel: {
|
|
2789
|
+
host: 'telemetry.api-monitor.com',
|
|
2790
|
+
paths: ['/v1/telemetry', '/v1/drop'],
|
|
2791
|
+
observed: false,
|
|
2792
|
+
},
|
|
2793
|
+
compromiseWindow: null,
|
|
2794
|
+
likelyStolen: [],
|
|
2795
|
+
compromisedInstallPaths: [],
|
|
2796
|
+
runningProcessesDuringWindow: [],
|
|
2797
|
+
rotationChecklist: [],
|
|
2798
|
+
};
|
|
2799
|
+
|
|
2800
|
+
if (!hasEvidence) {
|
|
2801
|
+
report.breachImpact = breachImpact;
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
// Determine compromise window from evidence timestamps.
|
|
2806
|
+
const evidenceTimes = [];
|
|
2807
|
+
for (const entry of report.installFindings || []) {
|
|
2808
|
+
const t = entry.modifiedAt || null;
|
|
2809
|
+
if (t) evidenceTimes.push(Date.parse(t));
|
|
2810
|
+
breachImpact.compromisedInstallPaths.push(entry.path);
|
|
2811
|
+
}
|
|
2812
|
+
for (const entry of report.bunCacheFindings || []) {
|
|
2813
|
+
const t = entry.modifiedAt || null;
|
|
2814
|
+
if (t) evidenceTimes.push(Date.parse(t));
|
|
2815
|
+
}
|
|
2816
|
+
for (const entry of report.npmTarballFetches || []) {
|
|
2817
|
+
const t = entry.time || entry.cacheRecordTime || null;
|
|
2818
|
+
if (t) evidenceTimes.push(Date.parse(t));
|
|
2819
|
+
}
|
|
2820
|
+
const validTimes = evidenceTimes.filter((t) => Number.isFinite(t) && t > 0);
|
|
2821
|
+
if (validTimes.length > 0) {
|
|
2822
|
+
breachImpact.compromiseWindow = {
|
|
2823
|
+
firstEvidence: new Date(Math.min(...validTimes)).toISOString(),
|
|
2824
|
+
lastEvidence: new Date(Math.max(...validTimes)).toISOString(),
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// Cross-reference impact-surface findings against known CanisterWorm targets.
|
|
2829
|
+
// Every match is a credential the payload, running as the installing user,
|
|
2830
|
+
// had read access to during the compromise window.
|
|
2831
|
+
for (const entry of report.impactSurfaceFindings || []) {
|
|
2832
|
+
const target = classifyCanisterWormTarget(entry.path);
|
|
2833
|
+
if (!target) continue;
|
|
2834
|
+
breachImpact.likelyStolen.push({
|
|
2835
|
+
category: target.category,
|
|
2836
|
+
severity: target.severity,
|
|
2837
|
+
path: entry.path,
|
|
2838
|
+
reason: target.reason,
|
|
2839
|
+
rotationUrl: target.rotationUrl,
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// Also include .env files discovered in scanImpactSurface (they're in
|
|
2844
|
+
// impactSurfaceFindings with kind='secret-store').
|
|
2845
|
+
for (const entry of report.impactSurfaceFindings || []) {
|
|
2846
|
+
if (entry.kind !== 'secret-store') continue;
|
|
2847
|
+
if (breachImpact.likelyStolen.some((s) => s.path === entry.path)) continue;
|
|
2848
|
+
const target = classifyCanisterWormTarget(entry.path);
|
|
2849
|
+
if (target) continue; // already classified above
|
|
2850
|
+
breachImpact.likelyStolen.push({
|
|
2851
|
+
category: 'dotenv',
|
|
2852
|
+
severity: 'CRITICAL',
|
|
2853
|
+
path: entry.path,
|
|
2854
|
+
reason: 'env-compat.cjs reads .env files for API keys (ANTHROPIC, OPENAI, custom secrets)',
|
|
2855
|
+
rotationUrl: null,
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
// Correlate live processes with the compromise window: any process whose
|
|
2860
|
+
// elapsed time overlaps the window and which was spawned by the compromised
|
|
2861
|
+
// install path is part of the breach footprint.
|
|
2862
|
+
for (const entry of report.liveProcessFindings || []) {
|
|
2863
|
+
if (!entry.matchedInstallPaths || entry.matchedInstallPaths.length === 0) continue;
|
|
2864
|
+
breachImpact.runningProcessesDuringWindow.push({
|
|
2865
|
+
pid: entry.pid,
|
|
2866
|
+
elapsed: entry.elapsed,
|
|
2867
|
+
command: (entry.command || '').slice(0, 160),
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// Build a priority-sorted rotation checklist (deduped by category).
|
|
2872
|
+
const severityRank = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
2873
|
+
const byCategory = new Map();
|
|
2874
|
+
for (const item of breachImpact.likelyStolen) {
|
|
2875
|
+
const existing = byCategory.get(item.category);
|
|
2876
|
+
if (!existing) byCategory.set(item.category, { ...item, paths: [item.path] });
|
|
2877
|
+
else existing.paths.push(item.path);
|
|
2878
|
+
}
|
|
2879
|
+
breachImpact.rotationChecklist = [...byCategory.values()].sort(
|
|
2880
|
+
(a, b) => (severityRank[a.severity] ?? 99) - (severityRank[b.severity] ?? 99),
|
|
2881
|
+
);
|
|
2882
|
+
|
|
2883
|
+
report.breachImpact = breachImpact;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2642
2886
|
function collectTempRoots(platformInfo, homes, roots) {
|
|
2643
2887
|
const tempRoots = new Set();
|
|
2644
2888
|
|
|
@@ -3359,6 +3603,35 @@ function printHumanReport(report) {
|
|
|
3359
3603
|
}
|
|
3360
3604
|
}
|
|
3361
3605
|
|
|
3606
|
+
const breach = report.breachImpact || { enabled: false };
|
|
3607
|
+
if (breach.enabled && (breach.likelyStolen || []).length > 0) {
|
|
3608
|
+
console.log('');
|
|
3609
|
+
console.log('BREACH IMPACT — retracing CanisterWorm exfil behavior against this host:');
|
|
3610
|
+
if (breach.compromiseWindow) {
|
|
3611
|
+
console.log(
|
|
3612
|
+
` compromise window: ${breach.compromiseWindow.firstEvidence} .. ${breach.compromiseWindow.lastEvidence}`,
|
|
3613
|
+
);
|
|
3614
|
+
}
|
|
3615
|
+
console.log(
|
|
3616
|
+
` exfil channel: ${breach.exfilChannel.host} ${breach.exfilChannel.paths.join(', ')}${breach.exfilChannel.observed ? ' (observed)' : ' (not observed in logs — channel targeting only)'}`,
|
|
3617
|
+
);
|
|
3618
|
+
console.log('');
|
|
3619
|
+
console.log(' likely-stolen credentials (grouped, priority-sorted):');
|
|
3620
|
+
for (const item of breach.rotationChecklist || []) {
|
|
3621
|
+
console.log(` [${item.severity}] ${item.category}`);
|
|
3622
|
+
for (const p of item.paths || []) console.log(` path: ${p}`);
|
|
3623
|
+
console.log(` why: ${item.reason}`);
|
|
3624
|
+
if (item.rotationUrl) console.log(` rotate at: ${item.rotationUrl}`);
|
|
3625
|
+
}
|
|
3626
|
+
if ((breach.runningProcessesDuringWindow || []).length > 0) {
|
|
3627
|
+
console.log('');
|
|
3628
|
+
console.log(' processes that ran the compromised binary:');
|
|
3629
|
+
for (const proc of breach.runningProcessesDuringWindow) {
|
|
3630
|
+
console.log(` pid=${proc.pid} elapsed=${proc.elapsed} cmd=${proc.command}`);
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3362
3635
|
if (report.npmCacheMetadata.length > 0) {
|
|
3363
3636
|
console.log('');
|
|
3364
3637
|
console.log('npm cache metadata observations:');
|
|
@@ -3523,6 +3796,7 @@ async function main() {
|
|
|
3523
3796
|
tempArtifactFindings: [],
|
|
3524
3797
|
liveProcessFindings: [],
|
|
3525
3798
|
impactSurfaceFindings: [],
|
|
3799
|
+
breachImpact: { enabled: false, likelyStolen: [], rotationChecklist: [] },
|
|
3526
3800
|
timeline: [],
|
|
3527
3801
|
errors: [],
|
|
3528
3802
|
};
|
|
@@ -3621,6 +3895,14 @@ async function main() {
|
|
|
3621
3895
|
() => scanLiveProcesses(report),
|
|
3622
3896
|
report,
|
|
3623
3897
|
);
|
|
3898
|
+
await runPhase(
|
|
3899
|
+
runtime,
|
|
3900
|
+
'scanBreachImpact',
|
|
3901
|
+
'breach-impact',
|
|
3902
|
+
'(breach analysis)',
|
|
3903
|
+
() => scanBreachImpact(report),
|
|
3904
|
+
report,
|
|
3905
|
+
);
|
|
3624
3906
|
|
|
3625
3907
|
report.timeline = sortTimeline(report.timeline);
|
|
3626
3908
|
report.summary = summarize(report);
|