Skip to content

Commit 657287e

Browse files
authored
fix(security): strip credentials from migration snapshots and enforce blueprint digest (NVIDIA#769)
Reconciles NVIDIA#156 and NVIDIA#743 into a single comprehensive solution: - Filter auth-profiles.json at copy time via cpSync filter (from NVIDIA#743) - Recursive stripCredentials() with pattern-based field detection for deep config sanitization (from NVIDIA#156: CREDENTIAL_FIELDS set + CREDENTIAL_FIELD_PATTERN regex) - Remove gateway config section (contains auth tokens) from sandbox openclaw.json - Blueprint digest verification (SHA-256): recorded at snapshot time, validated on restore, empty/missing digest is a hard failure - computeFileDigest() throws when blueprint file is missing instead of silently returning null - Sanitize both snapshot-level and sandbox-bundle openclaw.json copies - Backward compatible: old snapshots without blueprintDigest skip validation - Bump SNAPSHOT_VERSION 2 → 3 Supersedes NVIDIA#156 and NVIDIA#743.
1 parent b775233 commit 657287e

2 files changed

Lines changed: 238 additions & 29 deletions

File tree

nemoclaw/src/commands/migration-state.test.ts

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -577,12 +577,17 @@ describe("commands/migration-state", () => {
577577
expect(configKeys.length).toBeGreaterThan(0);
578578
});
579579

580-
it("strips gateway key from sandbox openclaw.json", () => {
580+
it("strips gateway key and credential fields from sandbox openclaw.json", () => {
581581
const logger = makeLogger();
582582
addDir("/home/user/.openclaw");
583583
addFile(
584584
"/home/user/.openclaw/openclaw.json",
585-
JSON.stringify({ version: 1, gateway: { auth: { token: "secret123" } } }),
585+
JSON.stringify({
586+
version: 1,
587+
gateway: { auth: { token: "secret123" } },
588+
nvidia: { apiKey: "nvapi-test-key" },
589+
agents: { defaults: { model: { primary: "test-model" } } },
590+
}),
586591
);
587592

588593
const hostState: HostOpenClawState = {
@@ -614,8 +619,65 @@ describe("commands/migration-state", () => {
614619
return;
615620
}
616621
const sandboxConfig = JSON.parse(sandboxConfigEntry.content);
622+
// gateway key should be removed entirely
617623
expect(sandboxConfig).not.toHaveProperty("gateway");
618-
expect(sandboxConfig).toHaveProperty("version", 1);
624+
// credential fields should be stripped
625+
expect(sandboxConfig.nvidia.apiKey).toBe("[STRIPPED_BY_MIGRATION]");
626+
// non-credential fields should be preserved
627+
expect(sandboxConfig.version).toBe(1);
628+
expect(sandboxConfig.agents.defaults.model.primary).toBe("test-model");
629+
});
630+
631+
it("strips pattern-matched credential fields (accessToken, privateKey, etc.)", () => {
632+
const logger = makeLogger();
633+
addDir("/home/user/.openclaw");
634+
addFile(
635+
"/home/user/.openclaw/openclaw.json",
636+
JSON.stringify({
637+
version: 1,
638+
provider: {
639+
accessToken: "test-access-token",
640+
refreshToken: "test-refresh-token",
641+
privateKey: "test-private-key",
642+
clientSecret: "test-client-secret",
643+
displayName: "should-be-preserved",
644+
},
645+
}),
646+
);
647+
648+
const hostState: HostOpenClawState = {
649+
exists: true,
650+
homeDir: "/home/user",
651+
stateDir: "/home/user/.openclaw",
652+
configDir: "/home/user/.openclaw",
653+
configPath: "/home/user/.openclaw/openclaw.json",
654+
workspaceDir: null,
655+
extensionsDir: null,
656+
skillsDir: null,
657+
hooksDir: null,
658+
externalRoots: [],
659+
warnings: [],
660+
errors: [],
661+
hasExternalConfig: false,
662+
};
663+
664+
const bundle = createSnapshotBundle(hostState, logger, { persist: false });
665+
if (bundle === null) {
666+
expect.unreachable("bundle should not be null");
667+
return;
668+
}
669+
670+
const sandboxConfigEntry = store.get(bundle.preparedStateDir + "/openclaw.json");
671+
if (!sandboxConfigEntry?.content) {
672+
expect.unreachable("sandbox config entry should exist with content");
673+
return;
674+
}
675+
const sandboxConfig = JSON.parse(sandboxConfigEntry.content);
676+
expect(sandboxConfig.provider.accessToken).toBe("[STRIPPED_BY_MIGRATION]");
677+
expect(sandboxConfig.provider.refreshToken).toBe("[STRIPPED_BY_MIGRATION]");
678+
expect(sandboxConfig.provider.privateKey).toBe("[STRIPPED_BY_MIGRATION]");
679+
expect(sandboxConfig.provider.clientSecret).toBe("[STRIPPED_BY_MIGRATION]");
680+
expect(sandboxConfig.provider.displayName).toBe("should-be-preserved");
619681
});
620682

621683
it("records blueprintDigest when blueprintPath is provided", () => {
@@ -680,6 +742,82 @@ describe("commands/migration-state", () => {
680742
}
681743
expect(bundle.manifest.blueprintDigest).toBeUndefined();
682744
});
745+
746+
it("fails when blueprintPath is provided but file is missing", () => {
747+
const logger = makeLogger();
748+
addDir("/home/user/.openclaw");
749+
addFile("/home/user/.openclaw/openclaw.json", JSON.stringify({ version: 1 }));
750+
751+
const hostState: HostOpenClawState = {
752+
exists: true,
753+
homeDir: "/home/user",
754+
stateDir: "/home/user/.openclaw",
755+
configDir: "/home/user/.openclaw",
756+
configPath: "/home/user/.openclaw/openclaw.json",
757+
workspaceDir: null,
758+
extensionsDir: null,
759+
skillsDir: null,
760+
hooksDir: null,
761+
externalRoots: [],
762+
warnings: [],
763+
errors: [],
764+
hasExternalConfig: false,
765+
};
766+
767+
// /test/nonexistent.yaml does not exist in store
768+
const bundle = createSnapshotBundle(hostState, logger, {
769+
persist: false,
770+
blueprintPath: "/test/nonexistent.yaml",
771+
});
772+
expect(bundle).toBeNull();
773+
expect(logger.error).toHaveBeenCalled();
774+
});
775+
776+
it("sanitizes credentials in the snapshot directory itself (not just sandbox-bundle)", () => {
777+
const logger = makeLogger();
778+
addDir("/home/user/.openclaw");
779+
addFile(
780+
"/home/user/.openclaw/openclaw.json",
781+
JSON.stringify({
782+
version: 1,
783+
gateway: { auth: { token: "secret123" } },
784+
nvidia: { apiKey: "nvapi-test-key" },
785+
}),
786+
);
787+
788+
const hostState: HostOpenClawState = {
789+
exists: true,
790+
homeDir: "/home/user",
791+
stateDir: "/home/user/.openclaw",
792+
configDir: "/home/user/.openclaw",
793+
configPath: "/home/user/.openclaw/openclaw.json",
794+
workspaceDir: null,
795+
extensionsDir: null,
796+
skillsDir: null,
797+
hooksDir: null,
798+
externalRoots: [],
799+
warnings: [],
800+
errors: [],
801+
hasExternalConfig: false,
802+
};
803+
804+
const bundle = createSnapshotBundle(hostState, logger, { persist: false });
805+
if (bundle === null) {
806+
expect.unreachable("bundle should not be null");
807+
return;
808+
}
809+
810+
// Check the snapshot-level openclaw.json (not sandbox-bundle)
811+
const snapshotConfigEntry = store.get(bundle.snapshotDir + "/openclaw/openclaw.json");
812+
if (!snapshotConfigEntry?.content) {
813+
expect.unreachable("snapshot config entry should exist with content");
814+
return;
815+
}
816+
const snapshotConfig = JSON.parse(snapshotConfigEntry.content);
817+
expect(snapshotConfig).not.toHaveProperty("gateway");
818+
expect(snapshotConfig.nvidia.apiKey).toBe("[STRIPPED_BY_MIGRATION]");
819+
expect(snapshotConfig.version).toBe(1);
820+
});
683821
});
684822

685823
// -------------------------------------------------------------------------
@@ -1053,7 +1191,6 @@ describe("commands/migration-state", () => {
10531191
const origHome = process.env.HOME;
10541192
process.env.HOME = "/home/user";
10551193
try {
1056-
// Create a blueprint file and compute its expected digest
10571194
const blueprintContent = "version: 0.1.0\ndigest: ''\n";
10581195
addFile("/test/blueprint.yaml", blueprintContent);
10591196

nemoclaw/src/commands/migration-state.ts

Lines changed: 97 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -476,14 +476,9 @@ export function detectHostOpenClaw(env: NodeJS.ProcessEnv = process.env): HostOp
476476
};
477477
}
478478

479-
function computeFileDigest(filePath: string): string | null {
480-
try {
481-
if (!existsSync(filePath)) return null;
482-
return createHash("sha256").update(readFileSync(filePath)).digest("hex");
483-
} catch {
484-
return null;
485-
}
486-
}
479+
// ---------------------------------------------------------------------------
480+
// Credential sanitization
481+
// ---------------------------------------------------------------------------
487482

488483
/**
489484
* Basenames that MUST NOT be copied into snapshot bundles.
@@ -492,6 +487,77 @@ function computeFileDigest(filePath: string): string | null {
492487
*/
493488
const CREDENTIAL_SENSITIVE_BASENAMES = new Set(["auth-profiles.json"]);
494489

490+
/**
491+
* Credential field names that MUST be stripped from config files
492+
* before they enter the sandbox. Credentials should be injected
493+
* at runtime via OpenShell's provider credential mechanism.
494+
*/
495+
const CREDENTIAL_FIELDS = new Set([
496+
"apiKey",
497+
"api_key",
498+
"token",
499+
"secret",
500+
"password",
501+
"resolvedKey",
502+
]);
503+
504+
/**
505+
* Pattern-based detection for credential field names not covered by the
506+
* explicit set above. Matches common suffixes like accessToken, privateKey,
507+
* clientSecret, etc.
508+
*/
509+
const CREDENTIAL_FIELD_PATTERN =
510+
/(?:access|refresh|client|bearer|auth|api|private|public|signing|session)(?:Token|Key|Secret|Password)$/;
511+
512+
function isCredentialField(key: string): boolean {
513+
return CREDENTIAL_FIELDS.has(key) || CREDENTIAL_FIELD_PATTERN.test(key);
514+
}
515+
516+
/**
517+
* Recursively strip credential fields from a JSON-like object.
518+
* Returns a new object with sensitive values replaced by a placeholder.
519+
*/
520+
function stripCredentials(obj: unknown): unknown {
521+
if (obj === null || obj === undefined) return obj;
522+
if (typeof obj !== "object") return obj;
523+
if (Array.isArray(obj)) return obj.map(stripCredentials);
524+
525+
const result: Record<string, unknown> = {};
526+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
527+
if (isCredentialField(key)) {
528+
result[key] = "[STRIPPED_BY_MIGRATION]";
529+
} else {
530+
result[key] = stripCredentials(value);
531+
}
532+
}
533+
return result;
534+
}
535+
536+
/**
537+
* Strip credential fields from openclaw.json and remove the gateway
538+
* config section (contains auth tokens — regenerated by sandbox entrypoint).
539+
*/
540+
function sanitizeConfigFile(configPath: string): void {
541+
if (!existsSync(configPath)) return;
542+
const raw = readFileSync(configPath, "utf-8");
543+
const parsed: unknown = JSON5.parse(raw);
544+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
545+
const config = parsed as Record<string, unknown>;
546+
delete config["gateway"];
547+
const sanitized = stripCredentials(config) as Record<string, unknown>;
548+
writeFileSync(configPath, JSON.stringify(sanitized, null, 2));
549+
chmodSync(configPath, 0o600);
550+
}
551+
552+
function computeFileDigest(filePath: string): string {
553+
if (!existsSync(filePath)) {
554+
throw new Error(`Blueprint file not found: ${filePath}`);
555+
}
556+
return createHash("sha256").update(readFileSync(filePath)).digest("hex");
557+
}
558+
559+
// ---------------------------------------------------------------------------
560+
495561
function copyDirectory(
496562
sourcePath: string,
497563
destinationPath: string,
@@ -591,6 +657,13 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s
591657
const configPath = path.join(preparedStateDir, "openclaw.json");
592658
writeFileSync(configPath, JSON.stringify(config, null, 2));
593659
chmodSync(configPath, 0o600);
660+
661+
// SECURITY: Strip all credentials from the bundle before it enters the sandbox.
662+
// Credentials must be injected at runtime via OpenShell's provider credential
663+
// mechanism, not baked into the sandbox filesystem where a compromised agent
664+
// can read them.
665+
sanitizeConfigFile(configPath);
666+
594667
return preparedStateDir;
595668
}
596669

@@ -616,13 +689,14 @@ export function createSnapshotBundle(
616689
mkdirSync(parentDir, { recursive: true });
617690
const snapshotStateDir = path.join(parentDir, "openclaw");
618691
copyDirectory(hostState.stateDir, snapshotStateDir, { stripCredentials: true });
692+
sanitizeConfigFile(path.join(snapshotStateDir, "openclaw.json"));
619693

620694
if (hostState.configPath && hostState.hasExternalConfig) {
621695
const configSnapshotDir = path.join(parentDir, "config");
622696
mkdirSync(configSnapshotDir, { recursive: true });
623697
const configSnapshotPath = path.join(configSnapshotDir, "openclaw.json");
624698
copyFileSync(hostState.configPath, configSnapshotPath);
625-
chmodSync(configSnapshotPath, 0o600);
699+
sanitizeConfigFile(configSnapshotPath);
626700
}
627701

628702
const externalRoots: MigrationExternalRoot[] = [];
@@ -647,15 +721,8 @@ export function createSnapshotBundle(
647721
warnings: hostState.warnings,
648722
};
649723

650-
if (options.blueprintPath) {
651-
const digest = computeFileDigest(options.blueprintPath);
652-
if (!digest) {
653-
throw new Error(
654-
`Cannot compute blueprint digest for ${options.blueprintPath}. ` +
655-
"The file may be missing or unreadable.",
656-
);
657-
}
658-
manifest.blueprintDigest = digest;
724+
if (options.blueprintPath !== undefined) {
725+
manifest.blueprintDigest = computeFileDigest(options.blueprintPath);
659726
}
660727

661728
writeSnapshotManifest(parentDir, manifest);
@@ -783,18 +850,23 @@ export function restoreSnapshotToHost(
783850
}
784851
}
785852

786-
// SECURITY: Validate blueprint digest.
787-
// When a blueprintDigest is present in the manifest, it MUST be a non-empty
788-
// string and MUST match the current blueprint — fail closed on mismatch,
789-
// empty string, or null. Snapshots without a blueprintDigest (including all
790-
// legacy v2 manifests and v3 snapshots created without a blueprint) skip
791-
// verification.
853+
// SECURITY: Validate blueprint digest when present in manifest
792854
if ("blueprintDigest" in manifest) {
793855
if (!manifest.blueprintDigest || typeof manifest.blueprintDigest !== "string") {
794856
logger.error("Snapshot manifest has empty or invalid blueprintDigest. Refusing to restore.");
795857
return false;
796858
}
797-
const currentDigest = options?.blueprintPath ? computeFileDigest(options.blueprintPath) : null;
859+
let currentDigest: string | null = null;
860+
try {
861+
currentDigest = options?.blueprintPath ? computeFileDigest(options.blueprintPath) : null;
862+
} catch (err: unknown) {
863+
logger.error(
864+
`Failed to read blueprint for digest verification: ${
865+
err instanceof Error ? err.message : String(err)
866+
}`,
867+
);
868+
return false;
869+
}
798870
if (!currentDigest) {
799871
logger.error(
800872
"Snapshot contains a blueprintDigest but no blueprint is available for verification. " +

0 commit comments

Comments
 (0)