Protect private bank configuration from drift reconciliation (Refs #152)

This commit is contained in:
2026-10-12 23:32:20 -04:00
parent 4e3df81786
commit d9fa15cdf6
23 changed files with 597 additions and 28 deletions
+8
View File
@@ -1,3 +1,4 @@
import { isPrivateConfigPath } from '../fleet/private-config.js';
import * as fs from 'node:fs/promises';
import { join, posix, relative, resolve, sep } from 'node:path';
import { config } from '../config.js';
@@ -85,6 +86,13 @@ export async function nativeFileManifest(
}
export async function readFile(path: string): Promise<Buffer> {
for (const root of [config.serversRoot, config.t6ServersRoot]) {
const [, ...parts] = relative(resolve(root), resolve(path)).split(sep);
if (isPrivateConfigPath(parts.join('/')))
throw new Error(
'Private bank configuration is not available through shared file reads.'
);
}
const target = remote(path);
if (!target) return fs.readFile(path);
const result = await nativeRequest<{
+18
View File
@@ -1,3 +1,4 @@
import { isPrivateConfigEvidencePath } from '../fleet/private-config.js';
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import { db } from './index.js';
@@ -22,7 +23,18 @@ export function putFileBlob(
return sha256;
}
export function isPrivateFileBlob(sha256: string, handle: Database.Database = db()): boolean {
const rows = handle
.prepare(
`SELECT rel_path FROM deployment_files
WHERE content_before_hash=? OR content_after_hash=?`
)
.all(sha256, sha256) as { rel_path: string }[];
return rows.some((row) => isPrivateConfigEvidencePath(row.rel_path));
}
export function getFileBlob(sha256: string, handle: Database.Database = db()): Buffer {
if (isPrivateFileBlob(sha256, handle)) throw new Error('Private file content is unavailable.');
const row = handle.prepare('SELECT content FROM file_blobs WHERE sha256=?').get(sha256) as
{ content: Buffer } | undefined;
if (!row) throw new Error('File content is unavailable or has expired.');
@@ -58,6 +70,12 @@ export function packFileJson(
if (Array.isArray(value))
return { value: value.map((item) => packFileJson(item, handle, hashes).value), hashes };
if (!value || typeof value !== 'object') return { value, hashes };
if (
'path' in value &&
typeof value.path === 'string' &&
isPrivateConfigEvidencePath(value.path)
)
return { value: { path: value.path, redacted: true }, hashes };
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if (
+17 -5
View File
@@ -1,3 +1,4 @@
import { isPrivateConfigTarget } from './private-config.js';
import { putFileBlob } from '../db/file-blobs.js';
import { getVersionContent } from './scripts.js';
import {
@@ -213,6 +214,7 @@ async function scanServerFiles(server: string): Promise<DriftEntry[]> {
if (onDisk.length === 0) continue;
const untracked = await scanBatch(onDisk, async (relPath): Promise<DriftEntry | null> => {
if (isPrivateConfigTarget(server, kind, relPath)) return null;
if (managed.has(`${kind}:${relPath}`)) return null;
if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server))
return null;
@@ -438,6 +440,7 @@ function driftTargets(servers?: string[]): string[] {
async function describePlan(servers: string[], entries: DriftEntry[]): Promise<DriftPlan> {
const files: DriftPlanFile[] = [];
for (const entry of entries) {
if (isPrivateConfigTarget(entry.server, entry.kind, entry.relPath)) continue;
const before = await readFile(
join(destOfKind(entry.server, entry.kind), entry.relPath)
).catch((error: NodeJS.ErrnoException) => {
@@ -506,7 +509,9 @@ export interface PreparedDriftCampaign {
/** Capture a rebuild's file evidence without changing the running instance. */
export async function prepareDriftCampaign(server: string): Promise<PreparedDriftCampaign> {
const servers = driftTargets([server]);
const entries = await scanServerDrift(server);
const entries = (await scanServerDrift(server)).filter(
(entry) => !isPrivateConfigTarget(server, entry.kind, entry.relPath)
);
const files = await scanBatch(entries, async (entry) => {
const before = await readExistingFile(join(destOfKind(server, entry.kind), entry.relPath));
const after =
@@ -547,7 +552,11 @@ export function createDriftRebuildCampaign(
incidentId: string | number,
prepared: PreparedDriftCampaign
): string | null {
if (!prepared.files.length) return null;
// Prepared evidence may have been created before the private-file exemption.
const files = prepared.files.filter(
({ file }) => !isPrivateConfigTarget(file.server, file.kind, file.relPath)
);
if (!files.length) return null;
if (
prepared.plan.servers.length !== 1 ||
prepared.plan.servers[0] !== server ||
@@ -575,7 +584,7 @@ export function createDriftRebuildCampaign(
const insertFile = db().prepare(`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
for (const { file, before, after } of prepared.files) {
for (const { file, before, after } of files) {
insertFile.run(
id,
server,
@@ -592,7 +601,7 @@ export function createDriftRebuildCampaign(
`INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, provisioning_id)
VALUES (?, ?, ?, 'pending', ?)`
)
.run(id, server, prepared.files.length, operationId);
.run(id, server, files.length, operationId);
db()
.prepare(
"INSERT INTO provisioning_sources (operation_id, kind, source_id, server) VALUES (?, 'campaign', ?, ?)"
@@ -627,7 +636,9 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
entries: DriftEntry[];
}> = [];
for (const name of scanTargets) {
const entries = await scanServerDrift(name);
const entries = (await scanServerDrift(name)).filter(
(entry) => !isPrivateConfigTarget(name, entry.kind, entry.relPath)
);
perServer.push({ server: name, removed: [], restored: [], skipped: [], entries });
}
@@ -692,6 +703,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
continue;
}
for (const entry of s.entries) {
if (isPrivateConfigTarget(s.server, entry.kind, entry.relPath)) continue;
const dest = destOfKind(s.server, entry.kind);
const abs = join(dest, entry.relPath);
try {
+54
View File
@@ -0,0 +1,54 @@
import { posix } from 'node:path';
import { gameOfServer, relDestForKind, type ArtifactKind } from '../config.js';
export const BANK_CONFIG_FILE = 'electric_cherry_bank.cfg';
export function isPrivateConfigPath(path: string, game = 't5'): boolean {
return (
posix.normalize(path.replaceAll('\\', '/')) ===
posix.join(relDestForKind('config', game), BANK_CONFIG_FILE)
);
}
export function isPrivateConfigTarget(server: string, kind: string, path: string): boolean {
if (kind !== 'script' && kind !== 'config' && kind !== 'baseline' && kind !== 'plugin')
throw new Error('Invalid artifact kind');
const game = gameOfServer(server);
return isPrivateConfigPath(
posix.join(relDestForKind(kind, game), path.replaceAll('\\', '/')),
game
);
}
export function assertPublicFileTarget(server: string, kind: ArtifactKind, path: string): void {
if (isPrivateConfigTarget(server, kind, path))
throw new Error(
'Private bank configuration cannot be captured or changed by a file campaign.'
);
}
export function isReservedConfigFilename(path: string): boolean {
return posix.basename(path.replaceAll('\\', '/')).toLowerCase() === BANK_CONFIG_FILE;
}
// Legacy campaign evidence omits the kind and stores the path relative to its destination.
export function isPrivateConfigEvidencePath(path: string): boolean {
return (
posix.normalize(path.replaceAll('\\', '/')) === BANK_CONFIG_FILE ||
isPrivateConfigPath(path)
);
}
/** Credential injection uses bank.configPath, never a shared file's path field. */
export class PrivateFileOperationError extends Error {
constructor() {
super('Private bank configuration cannot be read or changed by file operations.');
}
}
export function assertPublicFilePayload(value: unknown): void {
if (!value || typeof value !== 'object') return;
if ('path' in value && typeof value.path === 'string' && isPrivateConfigPath(value.path))
throw new PrivateFileOperationError();
for (const item of Object.values(value)) assertPublicFilePayload(item);
}
+7
View File
@@ -1,3 +1,4 @@
import { isReservedConfigFilename } from './private-config.js';
import { scriptVersionError } from '../../script-version.js';
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { createHash } from 'node:crypto';
@@ -62,6 +63,7 @@ export class ScriptError extends Error {
| 'name-empty'
| 'name-taken'
| 'path-empty'
| 'path-reserved'
| 'no-such-script'
| 'no-such-version'
| 'invalid-scope'
@@ -249,6 +251,11 @@ export function createScript(input: CreateScriptInput): {
);
}
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
if (isReservedConfigFilename(targetRelPath))
throw new ScriptError(
'electric_cherry_bank.cfg is reserved for instance credentials.',
'path-reserved'
);
// Kind-specific validation.
if (kind === 'baseline') {
+2
View File
@@ -1,3 +1,4 @@
import { assertPublicFileTarget } from './private-config.js';
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, relative, resolve, isAbsolute } from 'node:path';
@@ -39,6 +40,7 @@ export interface StagedFileChange {
}
function targetPath(server: string, kind: ArtifactKind, relPath: string): string {
assertPublicFileTarget(server, kind, relPath);
const root = resolve(destOfKind(server, kind));
const target = resolve(root, relPath);
const rel = relative(root, target);
+32 -2
View File
@@ -1,3 +1,9 @@
import {
assertPublicFilePayload,
BANK_CONFIG_FILE,
PrivateFileOperationError,
isPrivateConfigPath
} from '../fleet/private-config.js';
import { packFileJson, unpackFileJson, referenceBlobs } from '../db/file-blobs.js';
import { pruneHistory } from '../retention.js';
import { recordAgentReceipt } from '../provisioning/agent-events.js';
@@ -1248,7 +1254,9 @@ function enqueueHostOperation(
)
throw new Error('Host is disabled. Enable it before requesting operations.');
const requestHash = createHash('sha256').update(payload).digest('hex');
const packed = packFileJson(JSON.parse(payload.toString()));
const parsedPayload = JSON.parse(payload.toString());
assertPublicFilePayload(parsedPayload);
const packed = packFileJson(parsedPayload);
payload = JSON.stringify(packed.value);
const id = options.id ?? randomUUID();
const timestamp = now();
@@ -1405,6 +1413,15 @@ export function pendingHostOperationRows(
)
: new Set<string>();
const admitted = rows.filter((operation) => {
// Withhold unsafe legacy commands without breaking the entire delivery stream.
if (operation.kind.startsWith('files.') || operation.kind === 'logs.read') {
try {
getHostOperationPayload(hostId, operation.id);
} catch (error) {
if (error instanceof PrivateFileOperationError) return false;
throw error;
}
}
if (
!libraryKinds.includes(operation.kind) ||
preparing.has(hostId) ||
@@ -1431,6 +1448,9 @@ export function getHostOperationPayload(hostId: string, id: string): Buffer {
)
.get(id, hostId) as { payload: Buffer } | undefined;
if (!row) throw new Error('Host operation was not found for this host.');
// Block old private-file commands before delivery to agents predating the guard.
if (row.payload.includes(BANK_CONFIG_FILE))
assertPublicFilePayload(JSON.parse(row.payload.toString()));
if (
!row.payload.includes('"contentSha256"') ||
getHost(hostId)?.inventory.capabilities?.includes('files.blobs')
@@ -1468,8 +1488,18 @@ export function reportHostOperationStatus(
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024)
throw new Error('Operation result exceeds the limit.');
if (result !== null) {
const target =
operation.kind === 'files.read'
? (database()
.prepare('SELECT target_path FROM ec_host_operations WHERE id=?')
.get(id) as { target_path: string | null })
: null;
const packed = packFileJson(
operation.kind === 'logs.read' ? logResultMetadata(input.result) : input.result
target?.target_path && isPrivateConfigPath(target.target_path)
? { redacted: true }
: operation.kind === 'logs.read'
? logResultMetadata(input.result)
: input.result
);
result = JSON.stringify(packed.value);
referenceBlobs('operation-result', id, packed.hashes);
+9 -1
View File
@@ -710,7 +710,15 @@ async function scanServer(server: string, forceScan: boolean): Promise<void> {
setSetting(driftKey, JSON.stringify({ identity, at: iso() }));
if (!prepared.plan.files.length) {
const current = active(server, 'drift');
if (current) transition(current.id, 'resolved', 'Server files match the promoted setup');
if (current) {
db()
.prepare('UPDATE watchdog_incidents SET details=? WHERE id=?')
.run(
JSON.stringify({ files: [], fingerprint: prepared.plan.fingerprint }),
current.id
);
transition(current.id, 'resolved', 'Server files match the promoted setup');
}
return;
}
const problem = detect(
@@ -1,3 +1,4 @@
import { assertPublicFileTarget } from '../fleet/private-config.js';
import { db } from '../db/index.js';
import { blobSize, getFileBlob } from '../db/file-blobs.js';
import type { LaunchRecipePlan } from '../fleet/launch-recipes.js';
@@ -22,6 +23,7 @@ export function applyActivationFiles(
version: number | null;
})[];
for (const change of changes) {
assertPublicFileTarget(change.server, change.kind, change.rel_path);
if (change.kind !== 'script' && change.kind !== 'config' && change.kind !== 'plugin')
throw new Error('File activation requires a runtime file kind.');
recipe.files = recipe.files.filter(
+8 -1
View File
@@ -1,3 +1,4 @@
import { assertPublicFileTarget, isPrivateConfigPath } from '../fleet/private-config.js';
import { assertScalingOwnership } from '../autoscaling/ownership.js';
import { removalAuthorization } from '../autoscaling/authorization.js';
import { scalingIntent } from '../autoscaling/admission.js';
@@ -509,6 +510,7 @@ function filesFor(job: Job): FilePlan[] {
'id' | 'kind' | 'rel_path' | 'check_before' | 'expected_before_sha'
> & { content_hash: string | null }
>;
for (const row of rows) assertPublicFileTarget(job.server, row.kind, row.rel_path);
return rows.map((row) => ({
id: row.id,
path: posix.join(
@@ -532,7 +534,12 @@ function storedFiles(job: Job): FilePlan[] {
const row = db()
.prepare('SELECT files_json FROM native_provisioning WHERE deployment_id=?')
.get(job.id) as { files_json: string | null } | undefined;
return (JSON.parse(row?.files_json ?? '[]') as FilePlan[]).map(fileMetadata);
const files = (JSON.parse(row?.files_json ?? '[]') as FilePlan[]).map(fileMetadata);
if (files.some((file) => isPrivateConfigPath(file.path)))
throw new Error(
'Private bank configuration cannot be captured or changed by a file campaign.'
);
return files;
}
/** Agent receipts are the authoritative before images; do not read a controller mount. */