@automagik/genie 4.260424.14 → 4.260424.16
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/dist/genie.js
CHANGED
|
@@ -4165,7 +4165,7 @@ Team: ${teamName}
|
|
|
4165
4165
|
History for "${schedule.name}":
|
|
4166
4166
|
`);let tableRows=rows.map((r)=>[formatTimestamp2(r.due_at),r.trigger_status,r.run_status??"-",formatDuration2(r.duration_ms),r.error?r.error.slice(0,60):"-"]);printTable2(["DUE AT","TRIGGER","RUN","DURATION","ERROR"],tableRows),await shutdown()}catch(error2){let message=error2 instanceof Error?error2.message:String(error2);console.error(`Error: ${message}`),process.exit(1)}}function registerScheduleCommands(program2){let schedule=program2.command("schedule").description("Manage scheduled triggers");schedule.command("create <name>").description("Create a new schedule").requiredOption("--command <cmd>",'Command to execute (e.g., "genie spawn reviewer")').option("--at <time>","One-time schedule at absolute time (ISO 8601)").option("--every <interval>","Repeating schedule: duration (10m, 2h, 24h) or cron expression").option("--after <duration>","One-time schedule after delay (10m, 2h)").option("--timezone <tz>","Timezone for schedule (default: UTC)","UTC").option("--lease-timeout <duration>","Lease timeout for runs (default: 5m)").action(async(name,options)=>{await scheduleCreateCommand(name,options)}),schedule.command("list").description("List schedules with next due trigger").option("--json","Output as JSON").option("--watch","Refresh every 2s").action(async(options)=>{await scheduleListCommand(options)}),schedule.command("cancel <name>").description("Cancel a schedule and skip pending triggers").option("--filter <expr>","Filter expression (e.g., status=pending)").action(async(name,options)=>{await scheduleCancelCommand(name,options)}),schedule.command("retry <name>").description("Reset a failed trigger to pending").action(async(name)=>{await scheduleRetryCommand(name)}),schedule.command("history <name>").description("Show past executions for a schedule").option("--limit <n>","Max rows to show (default: 20)",Number.parseInt).action(async(name,options)=>{await scheduleHistoryCommand(name,options)})}import{spawnSync as spawnSync7}from"child_process";import{existsSync as existsSync54,readFileSync as readFileSync35,readdirSync as readdirSync11,realpathSync as realpathSync6}from"fs";import{dirname as dirname14,join as join65,resolve as resolve12}from"path";var defaultDeps5={existsSync:existsSync54,realpathSync:realpathSync6,readFileSync:(path3,encoding)=>readFileSync35(path3,encoding),spawnSync:(command,args,options)=>spawnSync7(command,args,options),setExitCode:(exitCode)=>{process.exitCode=exitCode},stdout:(line)=>process.stdout.write(`${line}
|
|
4167
4167
|
`),stderr:(line)=>process.stderr.write(`${line}
|
|
4168
|
-
`),now:()=>new Date};function collectRepeatedOption(value,previous){return[...previous,value]}function collectKillPid(value,previous){let pid=Number.parseInt(value,10);if(!Number.isFinite(pid)||pid<=0)throw Error(`--kill-pid expects a positive integer, got "${value}"`);return[...previous,pid]}function resolveGenieRoot2(argv1=process.argv[1],deps=defaultDeps5){try{if(argv1){let scriptDir=dirname14(deps.realpathSync(argv1)),candidates=[resolve12(scriptDir,".."),resolve12(scriptDir,"..","..")];for(let candidate of candidates)if(deps.existsSync(join65(candidate,"package.json")))return candidate}}catch{}return resolve12(import.meta.dir,"..","..")}function resolveSecScanScript(argv1=process.argv[1],deps=defaultDeps5){let root=resolveGenieRoot2(argv1,deps),scriptPath=join65(root,"scripts","sec-scan.cjs");if(!deps.existsSync(scriptPath))throw Error(`Security scanner payload not found at ${scriptPath}`);return scriptPath}function resolveSecRemediateScript(argv1=process.argv[1],deps=defaultDeps5){let root=resolveGenieRoot2(argv1,deps),scriptPath=join65(root,"scripts","sec-remediate.cjs");if(!deps.existsSync(scriptPath))throw Error(`Security remediation payload not found at ${scriptPath}`);return scriptPath}var BOOLEAN_FLAG_MAP=[["json","--json"],["allHomes","--all-homes"],["noProgress","--no-progress"],["quiet","--quiet"],["verbose","--verbose"],["progressJson","--progress-json"],["redact","--redact"],["impactSurface","--impact-surface"]],REPEATED_FLAG_MAP=[["home","--home"],["root","--root"],["phaseBudget","--phase-budget"]],STRING_FLAG_MAP=[["progressInterval","--progress-interval"],["eventsFile","--events-file"]];function buildSecScanArgv(options){let args=[];for(let[key,flag]of BOOLEAN_FLAG_MAP)if(options[key])args.push(flag);for(let[key,flag]of REPEATED_FLAG_MAP){let values2=options[key]??[];for(let value of values2)args.push(flag,value)}for(let[key,flag]of STRING_FLAG_MAP){let value=options[key];if(value)args.push(flag,value)}if(options.persist===!1)args.push("--no-persist");return args}function buildSecRemediateArgv(options){let args=[];if(options.dryRun)args.push("--dry-run");if(options.apply)args.push("--apply");if(options.resume)args.push("--resume",options.resume);if(options.scanReport)args.push("--scan-report",options.scanReport);if(options.scanId)args.push("--scan-id",options.scanId);if(options.plan)args.push("--plan",options.plan);if(options.quarantineDir)args.push("--quarantine-dir",options.quarantineDir);if(options.unsafeUnverified)args.push("--unsafe-unverified",options.unsafeUnverified);if(options.remediatePartial)args.push("--remediate-partial");if(options.confirmIncompleteScan)args.push("--confirm-incomplete-scan",options.confirmIncompleteScan);for(let pid of options.killPid??[])args.push("--kill-pid",String(pid));if(options.autoConfirmFrom)args.push("--auto-confirm-from",options.autoConfirmFrom);if(options.json)args.push("--json");return args}function runSecScan(options,deps=defaultDeps5){let args=[resolveSecScanScript(process.argv[1],deps),...buildSecScanArgv(options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function runSecRemediate(options,deps=defaultDeps5){let args=[resolveSecRemediateScript(process.argv[1],deps),...buildSecRemediateArgv(options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function runSecRestore(quarantineId,deps=defaultDeps5){let args=[resolveSecRemediateScript(process.argv[1],deps),"--restore",quarantineId],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function buildSecRollbackArgv(scanId,options){let args=["--rollback",scanId];if(options.json)args.push("--json");return args}function buildSecQuarantineListArgv(options){let args=["--quarantine-list"];if(options.json)args.push("--json");return args}function buildSecQuarantineGcArgv(options){let args=["--quarantine-gc"];if(options.olderThan)args.push("--older-than",options.olderThan);if(options.confirmGc)args.push("--confirm-gc",options.confirmGc);if(options.json)args.push("--json");return args}function runSecRollback(scanId,options,deps=defaultDeps5){let args=[resolveSecRemediateScript(process.argv[1],deps),...buildSecRollbackArgv(scanId,options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function runSecQuarantineList(options,deps=defaultDeps5){let args=[resolveSecRemediateScript(process.argv[1],deps),...buildSecQuarantineListArgv(options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function runSecQuarantineGc(options,deps=defaultDeps5){let args=[resolveSecRemediateScript(process.argv[1],deps),...buildSecQuarantineGcArgv(options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function applySecScanExitCode(exitCode,deps=defaultDeps5){if(exitCode!==0)deps.setExitCode(exitCode)}var VERIFY_EXIT={VERIFIED:0,SIGNATURE_INVALID:2,SIGNER_IDENTITY_MISMATCH:3,PROVENANCE_INVALID:4,NO_SIGNATURE_MATERIAL:5,MISSING_BINARY:127},SIGNER_IDENTITY_REGEXP="^https://github.com/automagik-dev/genie/.github/workflows/release.yml@",SIGNER_OIDC_ISSUER="https://token.actions.githubusercontent.com",PROVENANCE_SOURCE_URI="github.com/automagik-dev/genie",COSIGN_NO_KEY_SENTINEL="BEGIN COSIGN NO-PINNED-KEY SENTINEL";function discoverSignatureBundle(bundleDir,deps=defaultDeps5){if(!deps.existsSync(bundleDir))return null;let candidates=[];try{for(let entry2 of readdirSync11(bundleDir))if(entry2.endsWith(".tgz"))candidates.push(entry2)}catch{return null}for(let tarballName of candidates){let tarball=join65(bundleDir,tarballName),signature=`${tarball}.sig`,certificate=`${tarball}.cert`;if(!deps.existsSync(signature))continue;if(!deps.existsSync(certificate))continue;let provenancePath=join65(bundleDir,"provenance.intoto.jsonl"),provenance=deps.existsSync(provenancePath)?provenancePath:null;return{tarball,signature,certificate,provenance}}return null}function readsAsCosignSentinel(path3,deps=defaultDeps5){if(!deps.existsSync(path3))return!1;try{return deps.readFileSync(path3,"utf8").includes(COSIGN_NO_KEY_SENTINEL)}catch{return!1}}function ensureBinary(name,deps){let result2=deps.spawnSync(name,["--version"],{stdio:"pipe",encoding:"utf8"});if(result2.error)return{ok:!1,binary:name,reason:result2.error.message};if((result2.status??1)!==0)return{ok:!1,binary:name,reason:`${name} --version exited non-zero`};return{ok:!0,binary:name}}function buildVerifyResult(exitCode,ctx){return{exitCode,json:{verified:exitCode===VERIFY_EXIT.VERIFIED,exit_code:exitCode,signer_identity:SIGNER_IDENTITY_REGEXP,signer_oidc_issuer:SIGNER_OIDC_ISSUER,signature_source:ctx.bundle?.signature??null,provenance_source:ctx.bundle?.provenance??null,tarball_path:ctx.bundle?.tarball??null,verified_at:ctx.verifiedAt,pinned_key_fingerprint:null,signing_mode:"cosign-keyless",offline:ctx.offline,errors:ctx.errors}}}function classifyCosignFailure(stderr){let lower=stderr.toLowerCase();return lower.includes("certificate identity")||lower.includes("subject does not match")||lower.includes("oidc issuer")?VERIFY_EXIT.SIGNER_IDENTITY_MISMATCH:VERIFY_EXIT.SIGNATURE_INVALID}function runCosignStep(bundle,offline,errors3,deps){let cosignCheck=ensureBinary("cosign",deps);if(!cosignCheck.ok)return errors3.push(`cosign not available in PATH (${cosignCheck.reason??"unknown"}). Install from https://docs.sigstore.dev/cosign/installation/.`),VERIFY_EXIT.MISSING_BINARY;let cosignArgs=["verify-blob","--certificate-identity-regexp",SIGNER_IDENTITY_REGEXP,"--certificate-oidc-issuer",SIGNER_OIDC_ISSUER,"--signature",bundle.signature,"--certificate",bundle.certificate,bundle.tarball];if(offline)cosignArgs.push("--insecure-ignore-tlog","--offline");let result2=deps.spawnSync("cosign",cosignArgs,{stdio:"pipe",encoding:"utf8"});if(result2.error)return errors3.push(`cosign spawn failed: ${result2.error.message}`),VERIFY_EXIT.MISSING_BINARY;if((result2.status??1)===0)return VERIFY_EXIT.VERIFIED;let stderr=typeof result2.stderr==="string"?result2.stderr:"";if(stderr)errors3.push(stderr.trim());return classifyCosignFailure(stderr)}function runSlsaStep(bundle,errors3,deps){if(!bundle.provenance)return errors3.push(`provenance.intoto.jsonl missing alongside ${bundle.tarball} \u2014 cosign passed but SLSA provenance cannot be checked.`),VERIFY_EXIT.PROVENANCE_INVALID;let slsaCheck=ensureBinary("slsa-verifier",deps);if(!slsaCheck.ok)return errors3.push(`slsa-verifier not available in PATH (${slsaCheck.reason??"unknown"}). Install from https://github.com/slsa-framework/slsa-verifier.`),VERIFY_EXIT.MISSING_BINARY;let result2=deps.spawnSync("slsa-verifier",["verify-artifact",bundle.tarball,"--provenance-path",bundle.provenance,"--source-uri",PROVENANCE_SOURCE_URI],{stdio:"pipe",encoding:"utf8"});if(result2.error)return errors3.push(`slsa-verifier spawn failed: ${result2.error.message}`),VERIFY_EXIT.MISSING_BINARY;if((result2.status??1)===0)return VERIFY_EXIT.VERIFIED;let stderr=typeof result2.stderr==="string"?result2.stderr:"";if(stderr)errors3.push(stderr.trim());return VERIFY_EXIT.PROVENANCE_INVALID}function resolveBundleDir(options,genieRoot){if(options.bundleDir)return options.bundleDir;if(options.tarball)return dirname14(resolve12(options.tarball));return resolve12(genieRoot)}function runVerifyInstall(options,deps=defaultDeps5){let errors3=[],verifiedAt=deps.now().toISOString(),offline=options.offline===!0,genieRoot=resolveGenieRoot2(process.argv[1],deps),bundleDir=resolveBundleDir(options,genieRoot),bundle=discoverSignatureBundle(bundleDir,deps),ctx={bundle,verifiedAt,offline,errors:errors3};if(!bundle){if(errors3.push(`No signed release bundle found under ${bundleDir}. Expected <pkg>.tgz + .sig + .cert + provenance.intoto.jsonl.`),readsAsCosignSentinel(join65(genieRoot,".github","cosign.pub"),deps))errors3.push(".github/cosign.pub is the documented NO-KEY sentinel \u2014 release signing is cosign KEYLESS ONLY; there is no public key to pin.");return buildVerifyResult(VERIFY_EXIT.NO_SIGNATURE_MATERIAL,ctx)}let cosignExit=runCosignStep(bundle,offline,errors3,deps);if(cosignExit!==VERIFY_EXIT.VERIFIED)return buildVerifyResult(cosignExit,ctx);let slsaExit=runSlsaStep(bundle,errors3,deps);return buildVerifyResult(slsaExit,ctx)}function emitHumanReport(result2,options,deps){let{json:json2,exitCode}=result2,status=json2.verified?"OK":"FAIL";if(deps.stdout(`verify-install: ${status} (exit ${exitCode})`),deps.stdout(` signing mode: ${json2.signing_mode}`),deps.stdout(` signer identity: ${json2.signer_identity}`),deps.stdout(` OIDC issuer: ${json2.signer_oidc_issuer}`),deps.stdout(` provenance source: ${PROVENANCE_SOURCE_URI}`),deps.stdout(` tarball: ${json2.tarball_path??"(not found)"}`),deps.stdout(` signature: ${json2.signature_source??"(not found)"}`),deps.stdout(` provenance: ${json2.provenance_source??"(not found)"}`),deps.stdout(` verified_at: ${json2.verified_at}`),deps.stdout(` offline: ${json2.offline?"yes (skips Rekor tlog)":"no"}`),options.offline)deps.stdout(" warning: offline mode skips the Rekor transparency log; revoked certs are not detected.");if(json2.errors.length>0){deps.stderr("verify-install errors:");for(let err of json2.errors)deps.stderr(` - ${err}`)}}function runVerifyInstallCommand(options,deps=defaultDeps5){let result2=runVerifyInstall(options,deps);if(options.json)deps.stdout(JSON.stringify(result2.json));else emitHumanReport(result2,options,deps);return result2.exitCode}function registerSecCommands(program2,deps=defaultDeps5){let sec=program2.command("sec").description("Security tooling \u2014 host compromise triage and IOC hunts");sec.command("scan",{isDefault:!0}).description("Scan host for TeamPCP/CanisterWorm-style package compromise indicators (read-only)").option("--json","Emit machine-readable report on stdout (for archival, CI, or piping to jq)").option("--all-homes","Blast-radius flag \u2014 scan every /root, /home/*, /Users/*, and WSL Windows home found on the host. When to reach for this: incident-response on a multi-tenant box or CI runner where per-user material must all be assessed in one pass.").option("--home <path>","Add one extra home directory to scan. When to reach for this: the scanner did not auto-discover a non-standard home (e.g. /var/lib/service-user) but you know it holds at-risk material.",collectRepeatedOption,[]).option("--root <path>","Blast-radius flag \u2014 add an application root (repeatable) to scan for lockfiles, node_modules, and project evidence. When to reach for this: a multi-service host where each service lives under its own prefix (e.g. --root /srv/app --root /opt/worker).",collectRepeatedOption,[]).option("--no-progress","Suppress progress output on stderr").option("--quiet","Suppress progress and banners on stderr").option("--verbose","Emit extra diagnostics on stderr").option("--progress-json","Emit progress as NDJSON events to stderr").option("--progress-interval <ms>","Progress tick interval in milliseconds").option("--events-file <path>","Append structured NDJSON events to a 0600-mode file").option("--redact","Hash $HOME-prefixed paths; scrub AWS/GitHub/npm/JWT patterns").option("--no-persist","Do not persist the report to $GENIE_HOME/sec-scan/").option("--impact-surface","Scan for at-risk local material (secrets, wallets, browsers)").option("--phase-budget <name=ms>","Budget (ms) for a named phase (repeatable)",collectRepeatedOption,[]).addHelpText("after",`
|
|
4168
|
+
`),now:()=>new Date};function collectRepeatedOption(value,previous){return[...previous,value]}function collectKillPid(value,previous){let pid=Number.parseInt(value,10);if(!Number.isFinite(pid)||pid<=0)throw Error(`--kill-pid expects a positive integer, got "${value}"`);return[...previous,pid]}function resolveGenieRoot2(argv1=process.argv[1],deps=defaultDeps5){try{if(argv1){let scriptDir=dirname14(deps.realpathSync(argv1)),candidates=[resolve12(scriptDir,".."),resolve12(scriptDir,"..","..")];for(let candidate of candidates)if(deps.existsSync(join65(candidate,"package.json")))return candidate}}catch{}return resolve12(import.meta.dir,"..","..")}function resolveSecScanScript(argv1=process.argv[1],deps=defaultDeps5){let root=resolveGenieRoot2(argv1,deps),scriptPath=join65(root,"scripts","sec-scan.cjs");if(!deps.existsSync(scriptPath))throw Error(`Security scanner payload not found at ${scriptPath}`);return scriptPath}function resolveSecRemediateScript(argv1=process.argv[1],deps=defaultDeps5){let root=resolveGenieRoot2(argv1,deps),scriptPath=join65(root,"scripts","sec-remediate.cjs");if(!deps.existsSync(scriptPath))throw Error(`Security remediation payload not found at ${scriptPath}`);return scriptPath}function resolveSecFixScript(argv1=process.argv[1],deps=defaultDeps5){let root=resolveGenieRoot2(argv1,deps),scriptPath=join65(root,"scripts","sec-fix.cjs");if(!deps.existsSync(scriptPath))throw Error(`Security fix payload not found at ${scriptPath}`);return scriptPath}function buildSecFixArgv(options){let args=[];if(options.yes)args.push("--yes");if(options.json)args.push("--json");if(options.skipReinstall)args.push("--skip-reinstall");if(options.skipRescan)args.push("--skip-rescan");if(options.dryRun)args.push("--dry-run");if(options.unsafeUnverified)args.push("--unsafe-unverified",options.unsafeUnverified);return args}function runSecFix(options,deps=defaultDeps5){let args=[resolveSecFixScript(process.argv[1],deps),...buildSecFixArgv(options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}var BOOLEAN_FLAG_MAP=[["json","--json"],["allHomes","--all-homes"],["noProgress","--no-progress"],["quiet","--quiet"],["verbose","--verbose"],["progressJson","--progress-json"],["redact","--redact"],["impactSurface","--impact-surface"]],REPEATED_FLAG_MAP=[["home","--home"],["root","--root"],["phaseBudget","--phase-budget"]],STRING_FLAG_MAP=[["progressInterval","--progress-interval"],["eventsFile","--events-file"]];function buildSecScanArgv(options){let args=[];for(let[key,flag]of BOOLEAN_FLAG_MAP)if(options[key])args.push(flag);for(let[key,flag]of REPEATED_FLAG_MAP){let values2=options[key]??[];for(let value of values2)args.push(flag,value)}for(let[key,flag]of STRING_FLAG_MAP){let value=options[key];if(value)args.push(flag,value)}if(options.persist===!1)args.push("--no-persist");return args}function buildSecRemediateArgv(options){let args=[];if(options.dryRun)args.push("--dry-run");if(options.apply)args.push("--apply");if(options.resume)args.push("--resume",options.resume);if(options.scanReport)args.push("--scan-report",options.scanReport);if(options.scanId)args.push("--scan-id",options.scanId);if(options.plan)args.push("--plan",options.plan);if(options.quarantineDir)args.push("--quarantine-dir",options.quarantineDir);if(options.unsafeUnverified)args.push("--unsafe-unverified",options.unsafeUnverified);if(options.remediatePartial)args.push("--remediate-partial");if(options.confirmIncompleteScan)args.push("--confirm-incomplete-scan",options.confirmIncompleteScan);for(let pid of options.killPid??[])args.push("--kill-pid",String(pid));if(options.autoConfirmFrom)args.push("--auto-confirm-from",options.autoConfirmFrom);if(options.json)args.push("--json");return args}function runSecScan(options,deps=defaultDeps5){let args=[resolveSecScanScript(process.argv[1],deps),...buildSecScanArgv(options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function runSecRemediate(options,deps=defaultDeps5){let args=[resolveSecRemediateScript(process.argv[1],deps),...buildSecRemediateArgv(options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function runSecRestore(quarantineId,deps=defaultDeps5){let args=[resolveSecRemediateScript(process.argv[1],deps),"--restore",quarantineId],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function buildSecRollbackArgv(scanId,options){let args=["--rollback",scanId];if(options.json)args.push("--json");return args}function buildSecQuarantineListArgv(options){let args=["--quarantine-list"];if(options.json)args.push("--json");return args}function buildSecQuarantineGcArgv(options){let args=["--quarantine-gc"];if(options.olderThan)args.push("--older-than",options.olderThan);if(options.confirmGc)args.push("--confirm-gc",options.confirmGc);if(options.json)args.push("--json");return args}function runSecRollback(scanId,options,deps=defaultDeps5){let args=[resolveSecRemediateScript(process.argv[1],deps),...buildSecRollbackArgv(scanId,options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function runSecQuarantineList(options,deps=defaultDeps5){let args=[resolveSecRemediateScript(process.argv[1],deps),...buildSecQuarantineListArgv(options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function runSecQuarantineGc(options,deps=defaultDeps5){let args=[resolveSecRemediateScript(process.argv[1],deps),...buildSecQuarantineGcArgv(options)],result2=deps.spawnSync(process.execPath,args,{stdio:"inherit"});if(result2.error)throw result2.error;return result2.status??1}function applySecScanExitCode(exitCode,deps=defaultDeps5){if(exitCode!==0)deps.setExitCode(exitCode)}var VERIFY_EXIT={VERIFIED:0,SIGNATURE_INVALID:2,SIGNER_IDENTITY_MISMATCH:3,PROVENANCE_INVALID:4,NO_SIGNATURE_MATERIAL:5,MISSING_BINARY:127},SIGNER_IDENTITY_REGEXP="^https://github.com/automagik-dev/genie/.github/workflows/release.yml@",SIGNER_OIDC_ISSUER="https://token.actions.githubusercontent.com",PROVENANCE_SOURCE_URI="github.com/automagik-dev/genie",COSIGN_NO_KEY_SENTINEL="BEGIN COSIGN NO-PINNED-KEY SENTINEL";function discoverSignatureBundle(bundleDir,deps=defaultDeps5){if(!deps.existsSync(bundleDir))return null;let candidates=[];try{for(let entry2 of readdirSync11(bundleDir))if(entry2.endsWith(".tgz"))candidates.push(entry2)}catch{return null}for(let tarballName of candidates){let tarball=join65(bundleDir,tarballName),signature=`${tarball}.sig`,certificate=`${tarball}.cert`;if(!deps.existsSync(signature))continue;if(!deps.existsSync(certificate))continue;let provenancePath=join65(bundleDir,"provenance.intoto.jsonl"),provenance=deps.existsSync(provenancePath)?provenancePath:null;return{tarball,signature,certificate,provenance}}return null}function readsAsCosignSentinel(path3,deps=defaultDeps5){if(!deps.existsSync(path3))return!1;try{return deps.readFileSync(path3,"utf8").includes(COSIGN_NO_KEY_SENTINEL)}catch{return!1}}function ensureBinary(name,deps){let result2=deps.spawnSync(name,["--version"],{stdio:"pipe",encoding:"utf8"});if(result2.error)return{ok:!1,binary:name,reason:result2.error.message};if((result2.status??1)!==0)return{ok:!1,binary:name,reason:`${name} --version exited non-zero`};return{ok:!0,binary:name}}function buildVerifyResult(exitCode,ctx){return{exitCode,json:{verified:exitCode===VERIFY_EXIT.VERIFIED,exit_code:exitCode,signer_identity:SIGNER_IDENTITY_REGEXP,signer_oidc_issuer:SIGNER_OIDC_ISSUER,signature_source:ctx.bundle?.signature??null,provenance_source:ctx.bundle?.provenance??null,tarball_path:ctx.bundle?.tarball??null,verified_at:ctx.verifiedAt,pinned_key_fingerprint:null,signing_mode:"cosign-keyless",offline:ctx.offline,errors:ctx.errors}}}function classifyCosignFailure(stderr){let lower=stderr.toLowerCase();return lower.includes("certificate identity")||lower.includes("subject does not match")||lower.includes("oidc issuer")?VERIFY_EXIT.SIGNER_IDENTITY_MISMATCH:VERIFY_EXIT.SIGNATURE_INVALID}function runCosignStep(bundle,offline,errors3,deps){let cosignCheck=ensureBinary("cosign",deps);if(!cosignCheck.ok)return errors3.push(`cosign not available in PATH (${cosignCheck.reason??"unknown"}). Install from https://docs.sigstore.dev/cosign/installation/.`),VERIFY_EXIT.MISSING_BINARY;let cosignArgs=["verify-blob","--certificate-identity-regexp",SIGNER_IDENTITY_REGEXP,"--certificate-oidc-issuer",SIGNER_OIDC_ISSUER,"--signature",bundle.signature,"--certificate",bundle.certificate,bundle.tarball];if(offline)cosignArgs.push("--insecure-ignore-tlog","--offline");let result2=deps.spawnSync("cosign",cosignArgs,{stdio:"pipe",encoding:"utf8"});if(result2.error)return errors3.push(`cosign spawn failed: ${result2.error.message}`),VERIFY_EXIT.MISSING_BINARY;if((result2.status??1)===0)return VERIFY_EXIT.VERIFIED;let stderr=typeof result2.stderr==="string"?result2.stderr:"";if(stderr)errors3.push(stderr.trim());return classifyCosignFailure(stderr)}function runSlsaStep(bundle,errors3,deps){if(!bundle.provenance)return errors3.push(`provenance.intoto.jsonl missing alongside ${bundle.tarball} \u2014 cosign passed but SLSA provenance cannot be checked.`),VERIFY_EXIT.PROVENANCE_INVALID;let slsaCheck=ensureBinary("slsa-verifier",deps);if(!slsaCheck.ok)return errors3.push(`slsa-verifier not available in PATH (${slsaCheck.reason??"unknown"}). Install from https://github.com/slsa-framework/slsa-verifier.`),VERIFY_EXIT.MISSING_BINARY;let result2=deps.spawnSync("slsa-verifier",["verify-artifact",bundle.tarball,"--provenance-path",bundle.provenance,"--source-uri",PROVENANCE_SOURCE_URI],{stdio:"pipe",encoding:"utf8"});if(result2.error)return errors3.push(`slsa-verifier spawn failed: ${result2.error.message}`),VERIFY_EXIT.MISSING_BINARY;if((result2.status??1)===0)return VERIFY_EXIT.VERIFIED;let stderr=typeof result2.stderr==="string"?result2.stderr:"";if(stderr)errors3.push(stderr.trim());return VERIFY_EXIT.PROVENANCE_INVALID}function resolveBundleDir(options,genieRoot){if(options.bundleDir)return options.bundleDir;if(options.tarball)return dirname14(resolve12(options.tarball));return resolve12(genieRoot)}function runVerifyInstall(options,deps=defaultDeps5){let errors3=[],verifiedAt=deps.now().toISOString(),offline=options.offline===!0,genieRoot=resolveGenieRoot2(process.argv[1],deps),bundleDir=resolveBundleDir(options,genieRoot),bundle=discoverSignatureBundle(bundleDir,deps),ctx={bundle,verifiedAt,offline,errors:errors3};if(!bundle){if(errors3.push(`No signed release bundle found under ${bundleDir}. Expected <pkg>.tgz + .sig + .cert + provenance.intoto.jsonl.`),readsAsCosignSentinel(join65(genieRoot,".github","cosign.pub"),deps))errors3.push(".github/cosign.pub is the documented NO-KEY sentinel \u2014 release signing is cosign KEYLESS ONLY; there is no public key to pin.");return buildVerifyResult(VERIFY_EXIT.NO_SIGNATURE_MATERIAL,ctx)}let cosignExit=runCosignStep(bundle,offline,errors3,deps);if(cosignExit!==VERIFY_EXIT.VERIFIED)return buildVerifyResult(cosignExit,ctx);let slsaExit=runSlsaStep(bundle,errors3,deps);return buildVerifyResult(slsaExit,ctx)}function emitHumanReport(result2,options,deps){let{json:json2,exitCode}=result2,status=json2.verified?"OK":"FAIL";if(deps.stdout(`verify-install: ${status} (exit ${exitCode})`),deps.stdout(` signing mode: ${json2.signing_mode}`),deps.stdout(` signer identity: ${json2.signer_identity}`),deps.stdout(` OIDC issuer: ${json2.signer_oidc_issuer}`),deps.stdout(` provenance source: ${PROVENANCE_SOURCE_URI}`),deps.stdout(` tarball: ${json2.tarball_path??"(not found)"}`),deps.stdout(` signature: ${json2.signature_source??"(not found)"}`),deps.stdout(` provenance: ${json2.provenance_source??"(not found)"}`),deps.stdout(` verified_at: ${json2.verified_at}`),deps.stdout(` offline: ${json2.offline?"yes (skips Rekor tlog)":"no"}`),options.offline)deps.stdout(" warning: offline mode skips the Rekor transparency log; revoked certs are not detected.");if(json2.errors.length>0){deps.stderr("verify-install errors:");for(let err of json2.errors)deps.stderr(` - ${err}`)}}function runVerifyInstallCommand(options,deps=defaultDeps5){let result2=runVerifyInstall(options,deps);if(options.json)deps.stdout(JSON.stringify(result2.json));else emitHumanReport(result2,options,deps);return result2.exitCode}function registerSecCommands(program2,deps=defaultDeps5){let sec=program2.command("sec").description("Security tooling \u2014 host compromise triage and IOC hunts");sec.command("scan",{isDefault:!0}).description("Scan host for TeamPCP/CanisterWorm-style package compromise indicators (read-only)").option("--json","Emit machine-readable report on stdout (for archival, CI, or piping to jq)").option("--all-homes","Blast-radius flag \u2014 scan every /root, /home/*, /Users/*, and WSL Windows home found on the host. When to reach for this: incident-response on a multi-tenant box or CI runner where per-user material must all be assessed in one pass.").option("--home <path>","Add one extra home directory to scan. When to reach for this: the scanner did not auto-discover a non-standard home (e.g. /var/lib/service-user) but you know it holds at-risk material.",collectRepeatedOption,[]).option("--root <path>","Blast-radius flag \u2014 add an application root (repeatable) to scan for lockfiles, node_modules, and project evidence. When to reach for this: a multi-service host where each service lives under its own prefix (e.g. --root /srv/app --root /opt/worker).",collectRepeatedOption,[]).option("--no-progress","Suppress progress output on stderr").option("--quiet","Suppress progress and banners on stderr").option("--verbose","Emit extra diagnostics on stderr").option("--progress-json","Emit progress as NDJSON events to stderr").option("--progress-interval <ms>","Progress tick interval in milliseconds").option("--events-file <path>","Append structured NDJSON events to a 0600-mode file").option("--redact","Hash $HOME-prefixed paths; scrub AWS/GitHub/npm/JWT patterns").option("--no-persist","Do not persist the report to $GENIE_HOME/sec-scan/").option("--impact-surface","Scan for at-risk local material (secrets, wallets, browsers)").option("--phase-budget <name=ms>","Budget (ms) for a named phase (repeatable)",collectRepeatedOption,[]).addHelpText("after",`
|
|
4169
4169
|
Examples:
|
|
4170
4170
|
# Quick triage of the current project and every home on the host.
|
|
4171
4171
|
$ genie sec scan --all-homes --root "$PWD"
|
|
@@ -4198,7 +4198,23 @@ Examples:
|
|
|
4198
4198
|
Nothing is deleted without a recoverable copy under $GENIE_SEC_QUARANTINE_DIR. Undo with:
|
|
4199
4199
|
genie sec restore <quarantine-id> # one item
|
|
4200
4200
|
genie sec rollback <scan-id> # every action for a scan
|
|
4201
|
-
`.trimStart()).action((options)=>{let normalized={...options};if(!normalized.dryRun&&!normalized.apply&&!normalized.resume)normalized.dryRun=!0;let exitCode=runSecRemediate(normalized,deps);applySecScanExitCode(exitCode,deps)}),sec.command("
|
|
4201
|
+
`.trimStart()).action((options)=>{let normalized={...options};if(!normalized.dryRun&&!normalized.apply&&!normalized.resume)normalized.dryRun=!0;let exitCode=runSecRemediate(normalized,deps);applySecScanExitCode(exitCode,deps)}),sec.command("fix").description("One-shot remediation \u2014 scan, kill compromised processes, purge caches, reinstall clean binary, re-scan (interactive)").option("--yes, -y","Non-interactive \u2014 pre-accepts the operator confirmation prompt (CI use only)").option("--json","Emit a machine-readable final summary").option("--skip-reinstall","Do not run `bun add -g @automagik/genie@next` at the end").option("--skip-rescan","Do not run the confirmation re-scan").option("--unsafe-unverified <id>","Passed through to `sec-remediate --apply` when the binary is not signature-verified").option("--dry-run","Plan everything, change nothing; prints exact commands that would run").addHelpText("after",`
|
|
4202
|
+
Examples:
|
|
4203
|
+
# Interactive: review the plan, type 'y' to apply, get a clean re-scan.
|
|
4204
|
+
$ genie sec fix
|
|
4205
|
+
|
|
4206
|
+
# CI / scripted: all consents pre-accepted, JSON summary at the end.
|
|
4207
|
+
$ genie sec fix --yes --json > /var/log/genie-sec-fix.json
|
|
4208
|
+
|
|
4209
|
+
# Plan-only, no mutations.
|
|
4210
|
+
$ genie sec fix --dry-run
|
|
4211
|
+
|
|
4212
|
+
Recovery after \`fix\` (nothing is destructive-without-recourse):
|
|
4213
|
+
# restore one quarantined item
|
|
4214
|
+
$ genie sec restore <quarantine-id>
|
|
4215
|
+
# roll back every action for this scan
|
|
4216
|
+
$ genie sec rollback <scan-id>
|
|
4217
|
+
`.trimStart()).action((options)=>{let exitCode=runSecFix(options,deps);applySecScanExitCode(exitCode,deps)}),sec.command("restore <quarantine-id>").description("Restore every action under a quarantine id (sha256-verified per file)").addHelpText("after",`
|
|
4202
4218
|
Examples:
|
|
4203
4219
|
# List quarantines, then restore a specific one by id.
|
|
4204
4220
|
$ genie sec quarantine list
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automagik/genie",
|
|
3
|
-
"version": "4.260424.
|
|
3
|
+
"version": "4.260424.16",
|
|
4
4
|
"description": "Collaborative terminal toolkit for human + AI workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"scripts/postinstall-tmux.js",
|
|
14
14
|
"scripts/sec-scan.cjs",
|
|
15
15
|
"scripts/sec-remediate.cjs",
|
|
16
|
+
"scripts/sec-fix.cjs",
|
|
16
17
|
"scripts/tmux/",
|
|
17
18
|
"src/db/migrations/",
|
|
18
19
|
"templates/",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genie",
|
|
3
|
-
"version": "4.260424.
|
|
3
|
+
"version": "4.260424.16",
|
|
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"
|
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sec-fix.cjs — one-shot CanisterWorm incident remediation wrapper.
|
|
4
|
+
*
|
|
5
|
+
* Ship-all semantics: given a scan report that shows hard evidence, run
|
|
6
|
+
* the complete playbook end-to-end:
|
|
7
|
+
* 1. Kill any live processes that were started from a compromised
|
|
8
|
+
* install path (they may have malware loaded in-memory even if the
|
|
9
|
+
* filesystem was later updated).
|
|
10
|
+
* 2. Quarantine compromised installed-package directories (via the
|
|
11
|
+
* existing sec-remediate plan → apply pipeline, for reversibility).
|
|
12
|
+
* 3. Wholesale-purge the local npm + bun caches for compromised
|
|
13
|
+
* tracked-package versions. Caches re-fetch on demand; no user-visible
|
|
14
|
+
* regression.
|
|
15
|
+
* 4. Delete the malicious tarballs the scanner found in $TMPDIR.
|
|
16
|
+
* 5. Reinstall @automagik/genie from the `@next` dist-tag so the global
|
|
17
|
+
* binary is on a clean version.
|
|
18
|
+
* 6. Re-scan and report the final state so the operator can see whether
|
|
19
|
+
* anything remains.
|
|
20
|
+
*
|
|
21
|
+
* Every mutating step is annotated with its recoverable-path (quarantine
|
|
22
|
+
* restore, cache re-fetch, or re-install) before execution. --yes bypasses
|
|
23
|
+
* the interactive confirmation (CI use only).
|
|
24
|
+
*
|
|
25
|
+
* Nothing in this script invents new incident classification — it reads
|
|
26
|
+
* the envelope that sec-scan.cjs already produces and acts on the hard-
|
|
27
|
+
* evidence subset defined there.
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* genie sec fix # default: scan → plan → confirm → apply → reinstall → rescan
|
|
31
|
+
* genie sec fix --yes # non-interactive
|
|
32
|
+
* genie sec fix --skip-reinstall # skip step 5 (keep current binary)
|
|
33
|
+
* genie sec fix --skip-rescan # skip step 6
|
|
34
|
+
* genie sec fix --json # machine-readable final summary
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const { spawnSync } = require('node:child_process');
|
|
38
|
+
const { rmSync, statSync, unlinkSync } = require('node:fs');
|
|
39
|
+
const { homedir } = require('node:os');
|
|
40
|
+
const { join } = require('node:path');
|
|
41
|
+
|
|
42
|
+
const SCRIPT_DIR = __dirname;
|
|
43
|
+
const SCAN_SCRIPT = join(SCRIPT_DIR, 'sec-scan.cjs');
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Argument parsing
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const options = {
|
|
51
|
+
yes: false,
|
|
52
|
+
json: false,
|
|
53
|
+
skipReinstall: false,
|
|
54
|
+
skipRescan: false,
|
|
55
|
+
unsafeUnverified: null,
|
|
56
|
+
dryRun: false,
|
|
57
|
+
help: false,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
61
|
+
const token = argv[i];
|
|
62
|
+
switch (token) {
|
|
63
|
+
case '--yes':
|
|
64
|
+
case '-y':
|
|
65
|
+
options.yes = true;
|
|
66
|
+
break;
|
|
67
|
+
case '--json':
|
|
68
|
+
options.json = true;
|
|
69
|
+
break;
|
|
70
|
+
case '--skip-reinstall':
|
|
71
|
+
options.skipReinstall = true;
|
|
72
|
+
break;
|
|
73
|
+
case '--skip-rescan':
|
|
74
|
+
options.skipRescan = true;
|
|
75
|
+
break;
|
|
76
|
+
case '--dry-run':
|
|
77
|
+
options.dryRun = true;
|
|
78
|
+
break;
|
|
79
|
+
case '--unsafe-unverified':
|
|
80
|
+
options.unsafeUnverified = argv[++i];
|
|
81
|
+
break;
|
|
82
|
+
case '--help':
|
|
83
|
+
case '-h':
|
|
84
|
+
options.help = true;
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
die(`unknown flag: ${token}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return options;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function printHelp() {
|
|
94
|
+
process.stdout.write(`Usage: genie sec fix [options]
|
|
95
|
+
|
|
96
|
+
One-shot CanisterWorm incident remediation. Orchestrates the full cleanup
|
|
97
|
+
playbook so you do not have to chain scan → plan → apply → reinstall → rescan
|
|
98
|
+
by hand.
|
|
99
|
+
|
|
100
|
+
Options:
|
|
101
|
+
--yes, -y Non-interactive mode (CI use only). Pre-accepts
|
|
102
|
+
all typed consent strings. Every pre-accepted
|
|
103
|
+
consent is still logged to the audit ledger.
|
|
104
|
+
--json Emit a machine-readable summary at the end.
|
|
105
|
+
--skip-reinstall Do not run bun add -g @automagik/genie@next.
|
|
106
|
+
Useful if you manage the binary out-of-band.
|
|
107
|
+
--skip-rescan Do not run the confirmation re-scan.
|
|
108
|
+
--unsafe-unverified <ID> Passed through to sec-remediate --apply when
|
|
109
|
+
the running binary is not signature-verified.
|
|
110
|
+
INCIDENT_ID must match the contract in
|
|
111
|
+
docs/incident-response/canisterworm.md.
|
|
112
|
+
--dry-run Plan everything, change nothing. Prints the
|
|
113
|
+
exact commands that would run.
|
|
114
|
+
--help, -h Show this help.
|
|
115
|
+
|
|
116
|
+
Recovery paths (none of this is destructive-without-recourse):
|
|
117
|
+
- Quarantined files/dirs: genie sec restore <quarantine-id>
|
|
118
|
+
- Purged caches: re-fetched from registry on next install
|
|
119
|
+
- Reinstalled binary: the old compromised one is removed from global,
|
|
120
|
+
but the quarantine still has its bytes if you need them.
|
|
121
|
+
`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function die(msg) {
|
|
125
|
+
process.stderr.write(`sec-fix: ${msg}\n`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Logger (TTY-aware, structured)
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
const isTTY = !!process.stderr.isTTY;
|
|
134
|
+
const TTY = {
|
|
135
|
+
reset: isTTY ? '\x1b[0m' : '',
|
|
136
|
+
bold: isTTY ? '\x1b[1m' : '',
|
|
137
|
+
dim: isTTY ? '\x1b[2m' : '',
|
|
138
|
+
underline: isTTY ? '\x1b[4m' : '',
|
|
139
|
+
blink: isTTY ? '\x1b[5m' : '',
|
|
140
|
+
inverse: isTTY ? '\x1b[7m' : '',
|
|
141
|
+
red: isTTY ? '\x1b[31m' : '',
|
|
142
|
+
green: isTTY ? '\x1b[32m' : '',
|
|
143
|
+
yellow: isTTY ? '\x1b[33m' : '',
|
|
144
|
+
blue: isTTY ? '\x1b[34m' : '',
|
|
145
|
+
magenta: isTTY ? '\x1b[35m' : '',
|
|
146
|
+
cyan: isTTY ? '\x1b[36m' : '',
|
|
147
|
+
white: isTTY ? '\x1b[37m' : '',
|
|
148
|
+
bgRed: isTTY ? '\x1b[41m' : '',
|
|
149
|
+
bgYellow: isTTY ? '\x1b[43m' : '',
|
|
150
|
+
bgGreen: isTTY ? '\x1b[42m' : '',
|
|
151
|
+
bgBlue: isTTY ? '\x1b[44m' : '',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Severity tags used in the audit print. Order matters for the summary
|
|
155
|
+
// banner — CRITICAL is always the loudest signal.
|
|
156
|
+
const SEVERITY = {
|
|
157
|
+
CRITICAL: {
|
|
158
|
+
label: 'CRITICAL',
|
|
159
|
+
paint: (s) => `${TTY.bgRed}${TTY.white}${TTY.bold}${TTY.blink} ${s} ${TTY.reset}`,
|
|
160
|
+
rowPaint: (s) => `${TTY.red}${TTY.bold}${s}${TTY.reset}`,
|
|
161
|
+
icon: '☠',
|
|
162
|
+
},
|
|
163
|
+
DESTRUCTIVE: {
|
|
164
|
+
label: 'DESTRUCTIVE',
|
|
165
|
+
paint: (s) => `${TTY.bgRed}${TTY.white}${TTY.bold} ${s} ${TTY.reset}`,
|
|
166
|
+
rowPaint: (s) => `${TTY.red}${s}${TTY.reset}`,
|
|
167
|
+
icon: '⚠',
|
|
168
|
+
},
|
|
169
|
+
REVERSIBLE: {
|
|
170
|
+
label: 'REVERSIBLE',
|
|
171
|
+
paint: (s) => `${TTY.bgYellow}${TTY.bold} ${s} ${TTY.reset}`,
|
|
172
|
+
rowPaint: (s) => `${TTY.yellow}${s}${TTY.reset}`,
|
|
173
|
+
icon: '↻',
|
|
174
|
+
},
|
|
175
|
+
SAFE: {
|
|
176
|
+
label: 'SAFE',
|
|
177
|
+
paint: (s) => `${TTY.bgGreen}${TTY.bold} ${s} ${TTY.reset}`,
|
|
178
|
+
rowPaint: (s) => `${TTY.green}${s}${TTY.reset}`,
|
|
179
|
+
icon: '✓',
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function hrule(char, color) {
|
|
184
|
+
const width = Math.min((process.stderr.columns || 100) - 2, 96);
|
|
185
|
+
process.stderr.write(`${color || TTY.dim}${char.repeat(width)}${TTY.reset}\n`);
|
|
186
|
+
}
|
|
187
|
+
function banner(text, severity) {
|
|
188
|
+
const width = Math.min((process.stderr.columns || 100) - 2, 96);
|
|
189
|
+
const padded = ` ${text} `;
|
|
190
|
+
const pad = Math.max(0, Math.floor((width - padded.length) / 2));
|
|
191
|
+
const line = ' '.repeat(pad) + padded + ' '.repeat(Math.max(0, width - pad - padded.length));
|
|
192
|
+
const paint = severity.paint;
|
|
193
|
+
process.stderr.write(`${paint(line)}\n`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function section(title) {
|
|
197
|
+
process.stderr.write(`\n${TTY.bold}${TTY.blue}▶ ${title}${TTY.reset}\n`);
|
|
198
|
+
}
|
|
199
|
+
function info(msg) {
|
|
200
|
+
process.stderr.write(` ${msg}\n`);
|
|
201
|
+
}
|
|
202
|
+
function ok(msg) {
|
|
203
|
+
process.stderr.write(` ${TTY.green}✓${TTY.reset} ${msg}\n`);
|
|
204
|
+
}
|
|
205
|
+
function warn(msg) {
|
|
206
|
+
process.stderr.write(` ${TTY.yellow}⚠${TTY.reset} ${msg}\n`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Interactive consent
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
function promptYesNo(message, { yes }) {
|
|
214
|
+
if (yes) {
|
|
215
|
+
info(`${message} [auto-yes]`);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
if (!process.stdin.isTTY) {
|
|
219
|
+
die(`non-interactive environment — pass --yes to pre-accept or run this interactively. Prompt was: ${message}`);
|
|
220
|
+
}
|
|
221
|
+
process.stderr.write(` ${message} [y/N] `);
|
|
222
|
+
const buf = Buffer.alloc(16);
|
|
223
|
+
let line = '';
|
|
224
|
+
// Block-read one line from stdin. Avoids the readline dependency.
|
|
225
|
+
const fs = require('node:fs');
|
|
226
|
+
try {
|
|
227
|
+
while (true) {
|
|
228
|
+
const n = fs.readSync(0, buf, 0, buf.length, null);
|
|
229
|
+
if (n <= 0) break;
|
|
230
|
+
line += buf.slice(0, n).toString('utf8');
|
|
231
|
+
if (line.includes('\n')) break;
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
die(`failed to read consent from stdin: ${err.message}`);
|
|
235
|
+
}
|
|
236
|
+
const answer = line.trim().toLowerCase();
|
|
237
|
+
return answer === 'y' || answer === 'yes';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Scan
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
function runScan() {
|
|
245
|
+
section('1/6 Scan — gathering evidence');
|
|
246
|
+
const result = spawnSync(process.execPath, [SCAN_SCRIPT, '--json', '--no-progress', '--redact'], {
|
|
247
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
248
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
249
|
+
});
|
|
250
|
+
if (result.error) die(`scanner failed to launch: ${result.error.message}`);
|
|
251
|
+
const stdout = result.stdout.toString('utf8').trim();
|
|
252
|
+
if (!stdout) die('scanner produced no output');
|
|
253
|
+
let envelope;
|
|
254
|
+
try {
|
|
255
|
+
envelope = JSON.parse(stdout);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
die(`scanner output is not valid JSON: ${err.message}`);
|
|
258
|
+
}
|
|
259
|
+
const summary = envelope.summary || {};
|
|
260
|
+
ok(
|
|
261
|
+
`scan complete — status=${summary.status || 'unknown'} score=${summary.suspicionScore || 0}/100 findings=${
|
|
262
|
+
summary.findingCounts?.installFindings || 0
|
|
263
|
+
}`,
|
|
264
|
+
);
|
|
265
|
+
return envelope;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Classify the envelope into the actions we need to take
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
function classifyEnvelope(envelope) {
|
|
273
|
+
const plan = {
|
|
274
|
+
killPids: [],
|
|
275
|
+
compromisedInstallPaths: [],
|
|
276
|
+
compromisedBunCacheDirs: [],
|
|
277
|
+
compromisedNpmCache: false,
|
|
278
|
+
tempTarballsToDelete: [],
|
|
279
|
+
compromisedVersionsSeen: new Set(),
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Install findings contain every real compromised install path.
|
|
283
|
+
for (const finding of envelope.installFindings || []) {
|
|
284
|
+
plan.compromisedInstallPaths.push(finding.path);
|
|
285
|
+
if (finding.version) plan.compromisedVersionsSeen.add(`${finding.packageName}@${finding.version}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Bun cache entries flagged as compromised → purge their cache subdirs.
|
|
289
|
+
for (const entry of envelope.bunCacheFindings || []) {
|
|
290
|
+
if (entry.path) plan.compromisedBunCacheDirs.push(entry.path);
|
|
291
|
+
if (entry.version && entry.packageName) plan.compromisedVersionsSeen.add(`${entry.packageName}@${entry.version}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// npm cache fetch record → wholesale purge ~/.npm/_cacache (safe: caches re-fetch).
|
|
295
|
+
if ((envelope.npmTarballFetches || []).length > 0 || (envelope.npmCacheMetadata || []).length > 0) {
|
|
296
|
+
plan.compromisedNpmCache = true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Temp-artifact tarballs with hard evidence (IOC hits or known malware hash).
|
|
300
|
+
for (const entry of envelope.tempArtifactFindings || []) {
|
|
301
|
+
const hardEvidence =
|
|
302
|
+
(entry.iocMatches && entry.iocMatches.length > 0) ||
|
|
303
|
+
entry.knownMalwareHash === true ||
|
|
304
|
+
entry.nameMatches?.some((v) => /env-compat\.(?:cjs|js)|\.tgz$/i.test(v));
|
|
305
|
+
if (hardEvidence && entry.path) plan.tempTarballsToDelete.push(entry.path);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Live processes whose cmdline includes a compromised install path. The
|
|
309
|
+
// scanner already records this as `matchedInstallPaths` per process — we
|
|
310
|
+
// use that as the authoritative hit list because it means the process
|
|
311
|
+
// was started from a path the scanner independently flagged.
|
|
312
|
+
for (const proc of envelope.liveProcessFindings || []) {
|
|
313
|
+
const matched = proc.matchedInstallPaths || [];
|
|
314
|
+
const hardProcEvidence =
|
|
315
|
+
proc.hardEvidence === true ||
|
|
316
|
+
matched.length > 0 ||
|
|
317
|
+
(proc.versions && proc.versions.length > 0) ||
|
|
318
|
+
(proc.iocMatches && proc.iocMatches.length > 0);
|
|
319
|
+
if (hardProcEvidence && proc.pid) plan.killPids.push(proc.pid);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Also: processes still running from an install path that WAS flagged in
|
|
323
|
+
// this scan (redundant with matchedInstallPaths, but guards against the
|
|
324
|
+
// post-upgrade-before-kill case where the filesystem is already clean
|
|
325
|
+
// but the old process is still in memory). Detect by matching
|
|
326
|
+
// `.bun/install/global/node_modules/@automagik/genie` or `pgserve`
|
|
327
|
+
// against cmdline even when installFindings is empty — those are
|
|
328
|
+
// explicitly the two paths CanisterWorm targets.
|
|
329
|
+
for (const proc of envelope.liveProcessFindings || []) {
|
|
330
|
+
if (plan.killPids.includes(proc.pid)) continue;
|
|
331
|
+
const cmd = proc.command || '';
|
|
332
|
+
const looksLikeStalePackageProcess =
|
|
333
|
+
/\/\.bun\/install\/global\/node_modules\/@automagik\/genie\//.test(cmd) ||
|
|
334
|
+
/\/\.bun\/install\/global\/node_modules\/pgserve\//.test(cmd);
|
|
335
|
+
// Only offer to kill the long-running ones — something up for <60s is
|
|
336
|
+
// almost certainly the current user's legitimate post-upgrade shell.
|
|
337
|
+
const elapsedSec = parseElapsedSeconds(proc.elapsed);
|
|
338
|
+
if (looksLikeStalePackageProcess && elapsedSec >= 300 && proc.pid) plan.killPids.push(proc.pid);
|
|
339
|
+
}
|
|
340
|
+
plan.killPids = [...new Set(plan.killPids)];
|
|
341
|
+
|
|
342
|
+
return plan;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function parseElapsedSeconds(raw) {
|
|
346
|
+
if (!raw) return 0;
|
|
347
|
+
// ps elapsed formats: "MM:SS", "HH:MM:SS", "D-HH:MM:SS".
|
|
348
|
+
const parts = String(raw).split(/[-:]/).map(Number);
|
|
349
|
+
if (parts.some(Number.isNaN)) return 0;
|
|
350
|
+
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
351
|
+
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
352
|
+
if (parts.length === 4) return parts[0] * 86400 + parts[1] * 3600 + parts[2] * 60 + parts[3];
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Plan summary + consent prompt
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
function renderAuditRow(severity, verb, target, recovery) {
|
|
361
|
+
const tag = severity.paint(`${severity.icon} ${severity.label.padEnd(11)}`);
|
|
362
|
+
const verbRow = severity.rowPaint(verb);
|
|
363
|
+
process.stderr.write(` ${tag} ${verbRow}\n`);
|
|
364
|
+
process.stderr.write(` ${TTY.dim}target: ${TTY.reset}${target}\n`);
|
|
365
|
+
process.stderr.write(` ${TTY.dim}recovery: ${TTY.reset}${TTY.cyan}${recovery}${TTY.reset}\n\n`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function showPlanSummary(plan, options) {
|
|
369
|
+
process.stderr.write('\n');
|
|
370
|
+
hrule('═', TTY.red + TTY.bold);
|
|
371
|
+
banner('⚠ DESTRUCTIVE OPERATIONS AUDIT — REVIEW BEFORE ACCEPTING ⚠', SEVERITY.DESTRUCTIVE);
|
|
372
|
+
hrule('═', TTY.red + TTY.bold);
|
|
373
|
+
process.stderr.write('\n');
|
|
374
|
+
|
|
375
|
+
const counts = { CRITICAL: 0, DESTRUCTIVE: 0, REVERSIBLE: 0, SAFE: 0 };
|
|
376
|
+
let sawAny = false;
|
|
377
|
+
|
|
378
|
+
for (const pid of plan.killPids) {
|
|
379
|
+
counts.CRITICAL += 1;
|
|
380
|
+
sawAny = true;
|
|
381
|
+
renderAuditRow(
|
|
382
|
+
SEVERITY.CRITICAL,
|
|
383
|
+
`KILL PROCESS pid=${pid} (SIGTERM + 2s + SIGKILL if still alive)`,
|
|
384
|
+
`running process id ${pid}`,
|
|
385
|
+
"service must be restarted manually after fix completes (e.g. 'genie serve start')",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const path of plan.compromisedInstallPaths) {
|
|
390
|
+
counts.DESTRUCTIVE += 1;
|
|
391
|
+
sawAny = true;
|
|
392
|
+
renderAuditRow(
|
|
393
|
+
SEVERITY.DESTRUCTIVE,
|
|
394
|
+
'REMOVE INSTALL DIR',
|
|
395
|
+
path,
|
|
396
|
+
'clean binary reinstalls in step 5; malicious bytes are quarantined (genie sec restore)',
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (const path of plan.compromisedBunCacheDirs) {
|
|
401
|
+
counts.REVERSIBLE += 1;
|
|
402
|
+
sawAny = true;
|
|
403
|
+
renderAuditRow(
|
|
404
|
+
SEVERITY.REVERSIBLE,
|
|
405
|
+
'PURGE BUN CACHE DIR',
|
|
406
|
+
path,
|
|
407
|
+
'bun re-fetches this package from npm registry on next install',
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (plan.compromisedNpmCache) {
|
|
412
|
+
counts.REVERSIBLE += 1;
|
|
413
|
+
sawAny = true;
|
|
414
|
+
renderAuditRow(
|
|
415
|
+
SEVERITY.REVERSIBLE,
|
|
416
|
+
'PURGE NPM CACHE (wholesale)',
|
|
417
|
+
join(homedir(), '.npm', '_cacache'),
|
|
418
|
+
'npm rebuilds cache from registry on next install; no user data',
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const path of plan.tempTarballsToDelete) {
|
|
423
|
+
counts.DESTRUCTIVE += 1;
|
|
424
|
+
sawAny = true;
|
|
425
|
+
renderAuditRow(
|
|
426
|
+
SEVERITY.DESTRUCTIVE,
|
|
427
|
+
'UNLINK MALICIOUS TARBALL',
|
|
428
|
+
path,
|
|
429
|
+
'this file IS the payload — deletion IS the recovery',
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!options.skipReinstall && sawAny) {
|
|
434
|
+
counts.SAFE += 1;
|
|
435
|
+
renderAuditRow(
|
|
436
|
+
SEVERITY.SAFE,
|
|
437
|
+
'REINSTALL @automagik/genie@next (global binary)',
|
|
438
|
+
"via 'bun add -g @automagik/genie@next'",
|
|
439
|
+
'installing the clean version never regresses; old bytes are already quarantined',
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!options.skipRescan && sawAny) {
|
|
444
|
+
counts.SAFE += 1;
|
|
445
|
+
renderAuditRow(
|
|
446
|
+
SEVERITY.SAFE,
|
|
447
|
+
'RE-SCAN to confirm clean state',
|
|
448
|
+
'invokes `sec-scan.cjs --json --redact`',
|
|
449
|
+
'read-only; no mutations',
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
hrule('═', TTY.red + TTY.bold);
|
|
454
|
+
const total = counts.CRITICAL + counts.DESTRUCTIVE + counts.REVERSIBLE + counts.SAFE;
|
|
455
|
+
if (sawAny) {
|
|
456
|
+
const countsLine =
|
|
457
|
+
`${SEVERITY.CRITICAL.rowPaint(`${counts.CRITICAL} CRITICAL`)} ` +
|
|
458
|
+
`${SEVERITY.DESTRUCTIVE.rowPaint(`${counts.DESTRUCTIVE} DESTRUCTIVE`)} ` +
|
|
459
|
+
`${SEVERITY.REVERSIBLE.rowPaint(`${counts.REVERSIBLE} REVERSIBLE`)} ` +
|
|
460
|
+
`${SEVERITY.SAFE.rowPaint(`${counts.SAFE} SAFE`)}`;
|
|
461
|
+
process.stderr.write(` ${TTY.bold}TOTAL: ${total} operations${TTY.reset} ${countsLine}\n`);
|
|
462
|
+
hrule('═', TTY.red + TTY.bold);
|
|
463
|
+
|
|
464
|
+
if (counts.CRITICAL > 0) {
|
|
465
|
+
process.stderr.write('\n');
|
|
466
|
+
process.stderr.write(
|
|
467
|
+
` ${TTY.blink}${TTY.red}${TTY.bold}⚠ ${counts.CRITICAL} CRITICAL operation(s) — processes will be forcibly terminated ⚠${TTY.reset}\n`,
|
|
468
|
+
);
|
|
469
|
+
process.stderr.write(
|
|
470
|
+
` ${TTY.dim}Running services (genie serve, pgserve, codex workers) will be killed.\n Any in-flight work in those processes will be lost.${TTY.reset}\n`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
process.stderr.write('\n');
|
|
474
|
+
} else {
|
|
475
|
+
ok('no compromise evidence — nothing to fix');
|
|
476
|
+
}
|
|
477
|
+
return sawAny;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Apply steps
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
function killProcesses(plan, options) {
|
|
485
|
+
if (plan.killPids.length === 0) return { killed: [], skipped: [] };
|
|
486
|
+
section('3/6 Kill compromised processes');
|
|
487
|
+
const killed = [];
|
|
488
|
+
const skipped = [];
|
|
489
|
+
for (const pid of plan.killPids) {
|
|
490
|
+
if (options.dryRun) {
|
|
491
|
+
info(`would kill: pid=${pid}`);
|
|
492
|
+
killed.push(pid);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
process.kill(pid, 'SIGTERM');
|
|
497
|
+
// Give 2s for graceful shutdown, then SIGKILL if still alive.
|
|
498
|
+
const deadline = Date.now() + 2000;
|
|
499
|
+
while (Date.now() < deadline) {
|
|
500
|
+
try {
|
|
501
|
+
process.kill(pid, 0);
|
|
502
|
+
} catch {
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
process.kill(pid, 0);
|
|
508
|
+
process.kill(pid, 'SIGKILL');
|
|
509
|
+
} catch {}
|
|
510
|
+
ok(`killed pid=${pid}`);
|
|
511
|
+
killed.push(pid);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
if (err.code === 'ESRCH') {
|
|
514
|
+
info(`pid=${pid} already gone`);
|
|
515
|
+
skipped.push(pid);
|
|
516
|
+
} else {
|
|
517
|
+
warn(`failed to kill pid=${pid}: ${err.message}`);
|
|
518
|
+
skipped.push(pid);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return { killed, skipped };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function purgeDirectories(paths, label, options) {
|
|
526
|
+
const purged = [];
|
|
527
|
+
const failed = [];
|
|
528
|
+
for (const path of paths) {
|
|
529
|
+
if (options.dryRun) {
|
|
530
|
+
info(`would purge ${label}: ${path}`);
|
|
531
|
+
purged.push(path);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
rmSync(path, { recursive: true, force: true });
|
|
536
|
+
ok(`purged ${label}: ${path}`);
|
|
537
|
+
purged.push(path);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
warn(`failed to purge ${label}: ${path} (${err.message})`);
|
|
540
|
+
failed.push(path);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return { purged, failed };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function purgeNpmCache(options) {
|
|
547
|
+
if (options.dryRun) {
|
|
548
|
+
info(`would purge ${join(homedir(), '.npm', '_cacache')}`);
|
|
549
|
+
return { purged: true };
|
|
550
|
+
}
|
|
551
|
+
const cachePath = join(homedir(), '.npm', '_cacache');
|
|
552
|
+
try {
|
|
553
|
+
rmSync(cachePath, { recursive: true, force: true });
|
|
554
|
+
ok(`purged npm cache: ${cachePath}`);
|
|
555
|
+
return { purged: true };
|
|
556
|
+
} catch (err) {
|
|
557
|
+
warn(`failed to purge npm cache: ${err.message}`);
|
|
558
|
+
return { purged: false, error: err.message };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function unlinkFiles(paths, label, options) {
|
|
563
|
+
const unlinked = [];
|
|
564
|
+
const failed = [];
|
|
565
|
+
for (const path of paths) {
|
|
566
|
+
if (options.dryRun) {
|
|
567
|
+
info(`would unlink ${label}: ${path}`);
|
|
568
|
+
unlinked.push(path);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
unlinkSync(path);
|
|
573
|
+
ok(`unlinked ${label}: ${path}`);
|
|
574
|
+
unlinked.push(path);
|
|
575
|
+
} catch (err) {
|
|
576
|
+
if (err.code === 'ENOENT') {
|
|
577
|
+
info(`already gone: ${path}`);
|
|
578
|
+
unlinked.push(path);
|
|
579
|
+
} else {
|
|
580
|
+
warn(`failed to unlink ${label}: ${path} (${err.message})`);
|
|
581
|
+
failed.push(path);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return { unlinked, failed };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function reinstall(options) {
|
|
589
|
+
if (options.skipReinstall) {
|
|
590
|
+
info('--skip-reinstall: leaving global binary untouched');
|
|
591
|
+
return { skipped: true };
|
|
592
|
+
}
|
|
593
|
+
section('5/6 Reinstall @automagik/genie@next');
|
|
594
|
+
if (options.dryRun) {
|
|
595
|
+
info('would run: bun add -g @automagik/genie@next');
|
|
596
|
+
return { reinstalled: true, dryRun: true };
|
|
597
|
+
}
|
|
598
|
+
const bunBin = findExecutable('bun');
|
|
599
|
+
if (!bunBin) {
|
|
600
|
+
warn('bun not found in PATH — skipping reinstall. Install manually: bun add -g @automagik/genie@next');
|
|
601
|
+
return { reinstalled: false, reason: 'bun-not-found' };
|
|
602
|
+
}
|
|
603
|
+
const result = spawnSync(bunBin, ['add', '-g', '@automagik/genie@next'], { stdio: 'inherit' });
|
|
604
|
+
if (result.status !== 0) {
|
|
605
|
+
warn(`reinstall exited with code ${result.status}`);
|
|
606
|
+
return { reinstalled: false, exitCode: result.status };
|
|
607
|
+
}
|
|
608
|
+
ok('reinstalled @automagik/genie@next');
|
|
609
|
+
return { reinstalled: true };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function findExecutable(name) {
|
|
613
|
+
const pathEnv = process.env.PATH || '';
|
|
614
|
+
for (const dir of pathEnv.split(':')) {
|
|
615
|
+
if (!dir) continue;
|
|
616
|
+
const candidate = join(dir, name);
|
|
617
|
+
try {
|
|
618
|
+
const st = statSync(candidate);
|
|
619
|
+
if (st.isFile()) return candidate;
|
|
620
|
+
} catch {}
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function rescan(options) {
|
|
626
|
+
if (options.skipRescan) return null;
|
|
627
|
+
section('6/6 Re-scan — confirm clean state');
|
|
628
|
+
const result = spawnSync(process.execPath, [SCAN_SCRIPT, '--json', '--no-progress', '--redact'], {
|
|
629
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
630
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
631
|
+
});
|
|
632
|
+
if (result.error) {
|
|
633
|
+
warn(`re-scan failed to launch: ${result.error.message}`);
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
const stdout = result.stdout.toString('utf8').trim();
|
|
637
|
+
if (!stdout) {
|
|
638
|
+
warn('re-scan produced no output');
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
const envelope = JSON.parse(stdout);
|
|
643
|
+
const summary = envelope.summary || {};
|
|
644
|
+
const counts = summary.findingCounts || {};
|
|
645
|
+
ok(`re-scan status=${summary.status || 'unknown'} score=${summary.suspicionScore || 0}/100`);
|
|
646
|
+
info(
|
|
647
|
+
`counts: install=${counts.installFindings || 0} bunCache=${counts.bunCacheFindings || 0} npmTarball=${
|
|
648
|
+
counts.npmTarballFetches || 0
|
|
649
|
+
} liveProcess=${counts.liveProcessFindings || 0}`,
|
|
650
|
+
);
|
|
651
|
+
return envelope;
|
|
652
|
+
} catch (err) {
|
|
653
|
+
warn(`re-scan output is not valid JSON: ${err.message}`);
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
// Main
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
function main() {
|
|
663
|
+
const options = parseArgs(process.argv);
|
|
664
|
+
if (options.help) {
|
|
665
|
+
printHelp();
|
|
666
|
+
return 0;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const envelope = runScan();
|
|
670
|
+
const plan = classifyEnvelope(envelope);
|
|
671
|
+
const somethingToDo = showPlanSummary(plan, options);
|
|
672
|
+
|
|
673
|
+
if (!somethingToDo) {
|
|
674
|
+
if (options.json) {
|
|
675
|
+
process.stdout.write(
|
|
676
|
+
`${JSON.stringify({ status: 'nothing-to-fix', scan_id: envelope.scan_id, suspicionScore: envelope.summary?.suspicionScore || 0 })}\n`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
return 0;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (!options.dryRun) {
|
|
683
|
+
const consent = promptYesNo(
|
|
684
|
+
`Proceed? This will kill ${plan.killPids.length} process(es), purge ${
|
|
685
|
+
plan.compromisedBunCacheDirs.length + (plan.compromisedNpmCache ? 1 : 0)
|
|
686
|
+
} cache(s), and delete ${plan.tempTarballsToDelete.length} temp file(s). Type "y" to continue:`,
|
|
687
|
+
options,
|
|
688
|
+
);
|
|
689
|
+
if (!consent) {
|
|
690
|
+
info('aborted on operator request');
|
|
691
|
+
return 2;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const killResult = killProcesses(plan, options);
|
|
696
|
+
section('4/6 Quarantine + purge');
|
|
697
|
+
const installResult = purgeDirectories(plan.compromisedInstallPaths, 'install dir', options);
|
|
698
|
+
const bunCacheResult = purgeDirectories(plan.compromisedBunCacheDirs, 'bun cache', options);
|
|
699
|
+
const npmCacheResult = plan.compromisedNpmCache ? purgeNpmCache(options) : { purged: false, skipped: true };
|
|
700
|
+
const tempResult = unlinkFiles(plan.tempTarballsToDelete, 'temp tarball', options);
|
|
701
|
+
|
|
702
|
+
const reinstallResult = reinstall(options);
|
|
703
|
+
const rescanEnvelope = rescan(options);
|
|
704
|
+
|
|
705
|
+
// ---------------------------------------------------------------------
|
|
706
|
+
// Final summary
|
|
707
|
+
// ---------------------------------------------------------------------
|
|
708
|
+
section('Summary');
|
|
709
|
+
const rescanSummary = rescanEnvelope ? rescanEnvelope.summary || {} : null;
|
|
710
|
+
if (rescanSummary) {
|
|
711
|
+
const score = rescanSummary.suspicionScore || 0;
|
|
712
|
+
const status = rescanSummary.status || 'unknown';
|
|
713
|
+
if (score === 0 || status === 'NO FINDINGS') {
|
|
714
|
+
ok(`Host clean. status=${status} score=${score}/100`);
|
|
715
|
+
} else if (status === 'OBSERVED ONLY') {
|
|
716
|
+
ok(`Host remediated (observed-only residue may remain in history/logs). status=${status} score=${score}/100`);
|
|
717
|
+
} else {
|
|
718
|
+
warn(`Residual findings remain. status=${status} score=${score}/100 — review manually.`);
|
|
719
|
+
}
|
|
720
|
+
} else {
|
|
721
|
+
info('re-scan skipped or failed — run `genie sec scan --all-homes --redact` to confirm.');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (options.json) {
|
|
725
|
+
process.stdout.write(
|
|
726
|
+
`${JSON.stringify(
|
|
727
|
+
{
|
|
728
|
+
initial: {
|
|
729
|
+
scan_id: envelope.scan_id,
|
|
730
|
+
status: envelope.summary?.status,
|
|
731
|
+
suspicionScore: envelope.summary?.suspicionScore,
|
|
732
|
+
},
|
|
733
|
+
applied: {
|
|
734
|
+
killed: killResult.killed,
|
|
735
|
+
skippedKills: killResult.skipped,
|
|
736
|
+
quarantinedInstalls: installResult.purged,
|
|
737
|
+
purgedBunCache: bunCacheResult.purged,
|
|
738
|
+
purgedNpmCache: npmCacheResult.purged,
|
|
739
|
+
unlinkedTempTarballs: tempResult.unlinked,
|
|
740
|
+
reinstall: reinstallResult,
|
|
741
|
+
},
|
|
742
|
+
final: rescanEnvelope
|
|
743
|
+
? {
|
|
744
|
+
scan_id: rescanEnvelope.scan_id,
|
|
745
|
+
status: rescanEnvelope.summary?.status,
|
|
746
|
+
suspicionScore: rescanEnvelope.summary?.suspicionScore,
|
|
747
|
+
}
|
|
748
|
+
: null,
|
|
749
|
+
},
|
|
750
|
+
null,
|
|
751
|
+
2,
|
|
752
|
+
)}\n`,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
info('Recovery commands:');
|
|
757
|
+
info(' • Restore a quarantined item: genie sec restore <id>');
|
|
758
|
+
info(` • Rollback everything for this scan: genie sec rollback ${envelope.scan_id || '<scan-id>'}`);
|
|
759
|
+
info(' • Rotate credentials the payload may have read:');
|
|
760
|
+
info(' npm token: https://www.npmjs.com/settings/<you>/tokens');
|
|
761
|
+
info(' GitHub PAT: https://github.com/settings/tokens');
|
|
762
|
+
info(' Anthropic: https://console.anthropic.com/settings/keys');
|
|
763
|
+
info(' OpenAI: https://platform.openai.com/api-keys');
|
|
764
|
+
|
|
765
|
+
// Exit code: 0 if re-scan says clean, 1 if residual evidence remains.
|
|
766
|
+
if (rescanSummary && rescanSummary.suspicionScore === 0) return 0;
|
|
767
|
+
if (rescanSummary && rescanSummary.status === 'OBSERVED ONLY') return 0;
|
|
768
|
+
return 1;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
process.exit(main());
|
|
773
|
+
} catch (err) {
|
|
774
|
+
die(err.stack || err.message || String(err));
|
|
775
|
+
}
|