fix(deploy): verify atomic-deploy backup integrity before restore (#1422)

* fix(deploy): verify atomic-deploy backup integrity before restore

Atomic deploy and the Rollback action restore a stack from a backup of
its compose file and .env. A backup truncated or corrupted at write time
(out of disk, interrupted copy) was copied back silently, overwriting a
working stack with bad content.

The backup now writes a .checksums manifest holding a SHA-256 of each
backed-up file, and a restore re-hashes every file and compares it before
touching the stack. A mismatch aborts the restore with a clear error and
leaves the live files unchanged. Backups without a manifest, and files
with no recorded checksum, are restored unverified so a rollback is never
blocked by missing integrity data.

* fix(deploy): guard backup source reads with an inline path barrier

The integrity change reads each managed file from the stack directory before
hashing it. Static analysis flags those reads because the source path derives
from the user-provided stack name and the containment check lived in a helper
it does not trace. Re-establish containment inline at each read sink, resolving
against the compose base and confirming the path stays within it, mirroring
snapshotStackFiles. Behavior is unchanged for valid stacks; the guard only
rejects a path that escapes the compose directory, which validation already
prevents.

* test(deploy): assert compose stays put when the backup .env is corrupt

Strengthen the .env-corruption test so it also mutates the live compose.yaml
and asserts it is left untouched, proving the integrity abort halts before any
file is copied back rather than relying on the backup happening to match. Also
note on the test hash oracle that it matches production for UTF-8 text fixtures.
This commit is contained in:
Anso
2026-06-24 19:46:59 -04:00
committed by GitHub
parent 401980ffa3
commit 96b3c49359
4 changed files with 284 additions and 15 deletions
@@ -9,6 +9,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'path';
import os from 'os';
import { promises as fsPromises } from 'fs';
import { createHash } from 'crypto';
// Mutable state the mocked NodeRegistry reads. Each test rewrites these
// before instantiating FileSystemService.
@@ -297,4 +298,189 @@ describe('FileSystemService backup location', () => {
const kept = await fsPromises.readFile(path.join(stackDir, 'notes.txt'), 'utf-8');
expect(kept).toBe('keep me\n');
});
// Integrity guard: a backup carries a .checksums manifest, and a restore
// verifies each backed-up file against it before touching the live stack, so a
// truncated or corrupted backup is rejected with a clear error instead of being
// copied back silently. These reuse the outer mkdtemp/DATA_DIR harness.
describe('backup integrity checksum', () => {
// Independent oracle for the manifest hashes. Production hashes the raw file
// Buffer; for the UTF-8 text fixtures used here that yields the same digest as
// hashing Buffer.from(s, 'utf-8').
const sha = (s: string) => createHash('sha256').update(Buffer.from(s, 'utf-8')).digest('hex');
it('writes a .checksums manifest with the SHA-256 of each backed-up file', async () => {
const stackName = 'sums';
const stackDir = path.join(composeDir, stackName);
await fsPromises.mkdir(stackDir, { recursive: true });
const composeBody = 'services:\n web: {}\n';
const envBody = 'FOO=bar\n';
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), composeBody, 'utf-8');
await fsPromises.writeFile(path.join(stackDir, '.env'), envBody, 'utf-8');
await FileSystemService.getInstance().backupStackFiles(stackName);
const backupDir = path.join(dataDir, 'backups', '1', stackName);
const manifest = JSON.parse(await fsPromises.readFile(path.join(backupDir, '.checksums'), 'utf-8'));
expect(manifest['compose.yaml']).toBe(sha(composeBody));
expect(manifest['.env']).toBe(sha(envBody));
});
it('aborts the restore and leaves the live file unchanged when a backed-up file is corrupt', async () => {
const stackName = 'corrupt';
const stackDir = path.join(composeDir, stackName);
await fsPromises.mkdir(stackDir, { recursive: true });
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: good\n', 'utf-8');
const service = FileSystemService.getInstance();
await service.backupStackFiles(stackName);
// Corrupt the backed-up copy on disk (truncation or bit-rot after a clean backup).
const backupDir = path.join(dataDir, 'backups', '1', stackName);
await fsPromises.writeFile(path.join(backupDir, 'compose.yaml'), 'name: go', 'utf-8');
// The live file is the post-deploy state a rollback would revert.
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: current\n', 'utf-8');
await expect(service.restoreStackFiles(stackName)).rejects.toThrow(/integrity|corrupt/i);
// The live file must be untouched, not overwritten with the corrupt bytes.
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('name: current\n');
// The manifest marker must never leak into the stack directory.
await expect(fsPromises.access(path.join(stackDir, '.checksums'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('verifies before removing orphans, so a corrupt backup mutates nothing', async () => {
const stackName = 'atomic';
const stackDir = path.join(composeDir, stackName);
await fsPromises.mkdir(stackDir, { recursive: true });
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: good\n', 'utf-8');
const service = FileSystemService.getInstance();
await service.backupStackFiles(stackName); // backup has compose.yaml, no .env
const backupDir = path.join(dataDir, 'backups', '1', stackName);
await fsPromises.writeFile(path.join(backupDir, 'compose.yaml'), 'trunc', 'utf-8');
// A post-backup deploy added a .env: a faithful restore would remove it as an
// orphan. The integrity check must run first, so the corrupt backup leaves both
// the orphan and the live compose exactly as they are.
await fsPromises.writeFile(path.join(stackDir, '.env'), 'SECRET=x\n', 'utf-8');
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: current\n', 'utf-8');
await expect(service.restoreStackFiles(stackName)).rejects.toThrow(/integrity|corrupt/i);
expect(await fsPromises.readFile(path.join(stackDir, '.env'), 'utf-8')).toBe('SECRET=x\n');
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('name: current\n');
});
it('restores faithfully when no .checksums manifest exists (pre-feature backup)', async () => {
const stackName = 'legacy';
const stackDir = path.join(composeDir, stackName);
await fsPromises.mkdir(stackDir, { recursive: true });
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: original\n', 'utf-8');
const service = FileSystemService.getInstance();
await service.backupStackFiles(stackName);
// A backup taken before the integrity feature existed has no manifest.
const backupDir = path.join(dataDir, 'backups', '1', stackName);
await fsPromises.rm(path.join(backupDir, '.checksums'));
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: mutated\n', 'utf-8');
await service.restoreStackFiles(stackName);
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('name: original\n');
});
it('restores faithfully when the manifest is unparseable (degrades to no verification)', async () => {
const stackName = 'garbled';
const stackDir = path.join(composeDir, stackName);
await fsPromises.mkdir(stackDir, { recursive: true });
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: original\n', 'utf-8');
const service = FileSystemService.getInstance();
const backupDir = path.join(dataDir, 'backups', '1', stackName);
// Both garbage JSON and an empty file are unparseable; neither proves the
// data files are bad, so a needed rollback must still proceed.
for (const garbage of ['not json', '']) {
await service.backupStackFiles(stackName);
await fsPromises.writeFile(path.join(backupDir, '.checksums'), garbage, 'utf-8');
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: mutated\n', 'utf-8');
await service.restoreStackFiles(stackName);
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('name: original\n');
}
});
it('copies a backup file that has no checksum entry without verifying it', async () => {
const stackName = 'partial';
const stackDir = path.join(composeDir, stackName);
await fsPromises.mkdir(stackDir, { recursive: true });
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: original\n', 'utf-8');
const service = FileSystemService.getInstance();
await service.backupStackFiles(stackName); // manifest records compose.yaml only
// A managed file present in the backup slot but absent from the manifest
// (e.g. its read failed during backup, so it was never recorded). It must
// still be restored: the check is never stricter than what was recorded.
const backupDir = path.join(dataDir, 'backups', '1', stackName);
await fsPromises.writeFile(path.join(backupDir, '.env'), 'TOKEN=restored\n', 'utf-8');
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: mutated\n', 'utf-8');
await service.restoreStackFiles(stackName);
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('name: original\n');
expect(await fsPromises.readFile(path.join(stackDir, '.env'), 'utf-8')).toBe('TOKEN=restored\n');
});
it('aborts the restore when the backed-up .env is corrupt, leaving the live .env intact', async () => {
const stackName = 'envcorrupt';
const stackDir = path.join(composeDir, stackName);
await fsPromises.mkdir(stackDir, { recursive: true });
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: good\n', 'utf-8');
await fsPromises.writeFile(path.join(stackDir, '.env'), 'TOKEN=good\n', 'utf-8');
const service = FileSystemService.getInstance();
await service.backupStackFiles(stackName); // manifest covers compose.yaml and .env
// Corrupt only the backed-up .env; compose.yaml stays valid. The .env's own
// manifest entry must be checked, so the restore aborts on it.
const backupDir = path.join(dataDir, 'backups', '1', stackName);
await fsPromises.writeFile(path.join(backupDir, '.env'), 'TOKEN=go', 'utf-8');
await fsPromises.writeFile(path.join(stackDir, '.env'), 'TOKEN=current\n', 'utf-8');
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: current\n', 'utf-8');
await expect(service.restoreStackFiles(stackName)).rejects.toThrow(/integrity|corrupt/i);
// The abort must touch nothing: the live .env keeps its current contents, and
// the valid-but-unrestored compose.yaml is left as-is rather than reverted to
// the backup, proving the .env mismatch halts before any file is copied back.
expect(await fsPromises.readFile(path.join(stackDir, '.env'), 'utf-8')).toBe('TOKEN=current\n');
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('name: current\n');
});
it('restores faithfully when a manifest entry is not a string (degrades to unverified)', async () => {
const stackName = 'nonstring';
const stackDir = path.join(composeDir, stackName);
await fsPromises.mkdir(stackDir, { recursive: true });
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: original\n', 'utf-8');
const service = FileSystemService.getInstance();
await service.backupStackFiles(stackName);
// A parseable manifest whose value is not a hex string (tampered or
// wrong-typed) must not block a rollback whose data files are intact: a
// manifest problem is not proof the backup is corrupt.
const backupDir = path.join(dataDir, 'backups', '1', stackName);
await fsPromises.writeFile(path.join(backupDir, '.checksums'), JSON.stringify({ 'compose.yaml': 123 }), 'utf-8');
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: mutated\n', 'utf-8');
await service.restoreStackFiles(stackName);
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('name: original\n');
});
});
});
+81 -10
View File
@@ -9,6 +9,7 @@ import { NodeRegistry } from './NodeRegistry';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { isBinaryBuffer } from '../utils/binaryDetect';
import { sanitizeForLog } from '../utils/safeLog';
import { sha256HexBuffer } from '../utils/hashing';
export interface FileEntry {
name: string;
@@ -56,6 +57,11 @@ const PROTECTED_STACK_FILES = new Set([
'.env',
]);
// Bookkeeping markers Sencho writes into the backup slot. They are never copied
// back into the stack directory on restore: `.timestamp` records when the backup
// was taken; `.checksums` is the integrity manifest verified before a restore.
const BACKUP_MARKER_FILES = new Set(['.timestamp', '.checksums']);
// Compose filenames Sencho recognizes, in resolution-priority order. Mirrors the
// list FileSystemService uses elsewhere; named here for the import scan.
const IMPORT_COMPOSE_FILENAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'] as const;
@@ -967,13 +973,29 @@ export class FileSystemService {
}
}
// Copy compose file
// Copy each managed file by reading it into memory, writing it to the backup
// slot, and recording the SHA-256 of the source bytes. Hashing the source
// (not the destination) means a truncated copy is caught when restore re-hashes
// the backup and finds it no longer matches. Managed files (compose, .env) are
// small; revisit this read-into-memory if a large file is ever added to
// PROTECTED_STACK_FILES.
// Canonical js/path-injection barrier inline with each source read sink:
// resolve against the compose base and confirm containment, mirroring
// snapshotStackFiles. stackDir is already validated by resolveStackDir, but
// re-establishing containment at the readFile sink itself lets static analysis
// credit the barrier, which it does not through the helper.
const baseResolved = path.resolve(this.baseDir);
const checksums: Record<string, string> = {};
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
const src = path.join(stackDir, file);
const src = path.resolve(baseResolved, path.join(stackDir, file));
if (!src.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
try {
await fsPromises.access(src);
await fsPromises.copyFile(src, path.join(backupDir, file));
const buf = await fsPromises.readFile(src);
await fsPromises.writeFile(path.join(backupDir, file), buf);
checksums[file] = sha256HexBuffer(buf);
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
@@ -982,11 +1004,15 @@ export class FileSystemService {
}
}
// Copy .env if it exists
const envSrc = path.join(stackDir, '.env');
// Copy .env if it exists (same inline containment barrier as above).
const envSrc = path.resolve(baseResolved, path.join(stackDir, '.env'));
if (!envSrc.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
try {
await fsPromises.access(envSrc);
await fsPromises.copyFile(envSrc, path.join(backupDir, '.env'));
const buf = await fsPromises.readFile(envSrc);
await fsPromises.writeFile(path.join(backupDir, '.env'), buf);
checksums['.env'] = sha256HexBuffer(buf);
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
@@ -994,6 +1020,13 @@ export class FileSystemService {
}
}
// Write the integrity manifest before the timestamp marker, so a crash
// between the two leaves the checksums present (a backup that restore can
// verify) rather than a timestamp with no integrity data. Only files that
// were actually written above appear here, so the manifest never claims a
// file the slot does not hold.
await fsPromises.writeFile(path.join(backupDir, '.checksums'), JSON.stringify(checksums), 'utf-8');
// Write timestamp marker
await fsPromises.writeFile(path.join(backupDir, '.timestamp'), Date.now().toString(), 'utf-8');
if (debug) console.debug(`[FileSystemService:debug] Backup completed in ${Date.now() - t0}ms`, { stackName });
@@ -1019,6 +1052,44 @@ export class FileSystemService {
const items = await fsPromises.readdir(backupDir);
const backedUp = new Set(items);
// Verify the backup's integrity before mutating the stack, so a corrupt or
// truncated backup is rejected rather than copied back silently. Each backed-up
// file is re-hashed and compared to the .checksums manifest written at backup
// time. This runs before the orphan removal and copy below, so a failed check
// leaves the live stack exactly as it was. A backup with no manifest (taken
// before this guard existed) or a file with no recorded checksum is left
// unverified: the check is never stricter than what the backup recorded, so it
// cannot block a rollback the backup can still serve.
let checksums: Record<string, unknown> | null = null;
try {
const parsed: unknown = JSON.parse(await fsPromises.readFile(path.join(backupDir, '.checksums'), 'utf-8'));
if (parsed !== null && typeof parsed === 'object') {
checksums = parsed as Record<string, unknown>;
}
} catch (e: unknown) {
// ENOENT is a pre-feature backup with no manifest: restore unverified. A
// present but unreadable or malformed manifest does not prove the data files
// are bad, and blocking would deny a needed rollback, so warn and proceed
// unverified rather than fail.
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
console.warn('[FileSystemService] Backup checksum manifest unreadable, restoring without integrity check:', (e as Error).message);
}
}
if (checksums) {
for (const item of items) {
if (BACKUP_MARKER_FILES.has(item)) continue;
const expected = checksums[item];
// A missing or non-string entry (a tampered or malformed-but-parseable
// manifest) is treated as no recorded checksum: skip rather than fail, so a
// manifest problem never blocks a rollback whose data files are intact.
if (typeof expected !== 'string') continue;
const actual = sha256HexBuffer(await fsPromises.readFile(path.join(backupDir, item)));
if (actual !== expected) {
throw new Error(`Rollback aborted: the backup of ${item} is corrupt (integrity check failed); the stack files were not changed.`);
}
}
}
// Remove managed files the backup does not contain before copying, so a
// rollback is a faithful revert rather than an additive overlay. If the
// failed deploy switched compose variants (e.g. compose.yaml ->
@@ -1056,10 +1127,10 @@ export class FileSystemService {
}
for (const item of items) {
if (item === '.timestamp') continue;
if (BACKUP_MARKER_FILES.has(item)) continue;
await fsPromises.copyFile(path.join(backupDir, item), path.join(stackDir, item));
}
if (debug) console.debug(`[FileSystemService:debug] Restore completed in ${Date.now() - t0}ms`, { stackName, restored: items.filter(i => i !== '.timestamp').length, removedOrphans });
if (debug) console.debug(`[FileSystemService:debug] Restore completed in ${Date.now() - t0}ms`, { stackName, restored: items.filter(i => !BACKUP_MARKER_FILES.has(i)).length, removedOrphans });
}
/**
+5
View File
@@ -4,3 +4,8 @@ import { createHash } from 'crypto';
export function sha256Hex(content: string): string {
return createHash('sha256').update(content, 'utf8').digest('hex');
}
/** Hex-encoded SHA-256 of raw bytes. Binary-safe; use for file-content integrity. */
export function sha256HexBuffer(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}