diff --git a/CHANGELOG.md b/CHANGELOG.md index a5fbb20cc..71cad42b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ ## [Unreleased] -**/cso's retained Git history no longer brings back credential files the snapshot leaves out, and a repair whose external assertions fail is now reported as `ASSERTION_FAILED` instead of a misleading `INCOMPATIBLE_INPUT`.** The helper keeps `.env`, `master.key`, keys and agent configuration out of the readable tree, but `history.txt` captured `git log -p` in full, so a committed `.env` came back through its diff whenever the value wasn't credential-shaped enough for the redactor. Paid qualification run 39186328569 caught this when the private corpus's planted `.env` value showed up in the judge's helper state. +**/cso can verify Rails repairs in a read-only checkout, its retained Git history no longer brings back credential files the snapshot leaves out, and a repair whose external assertions fail is now reported as `ASSERTION_FAILED` instead of a misleading `INCOMPATIBLE_INPUT`.** The helper keeps `.env`, `master.key`, keys and agent configuration out of the readable tree, but `history.txt` captured `git log -p` in full, so a committed `.env` came back through its diff whenever the value wasn't credential-shaped enough for the redactor. Paid qualification run 39186328569 caught this when the private corpus's planted `.env` value showed up in the judge's helper state. ### Fixed +- **Rails `verify` on read-only sources:** the helper writes its own test `config/database.yml` (plus an initializer and `storage.yml`) into the execution copy. The copy keeps the source's file modes, so in a read-only checkout (the eval producer seals every source file at 0444, and Nix or Bazel trees are the same) the write failed with `EACCES`. That surfaced as the generic `INVALID_SCHEMA` "unexpected or unsafe input", and no Rails repair could reach `runtime_tested`. Each helper file now replaces the copied file instead of opening it for writing, so it also never writes through a copied symlink. - **`history.txt`:** every diff section that touches a path the snapshot excludes (`exclusion()`, the same rule the readable tree uses) is replaced with `diff withheld: ()`. This covers either side of a rename and any diff header that can't be parsed. Commit preambles and other files' diffs stay. - **`verify` with an authenticated witness:** a receipt or observation mismatch is still `INCOMPATIBLE_INPUT`. Receipts that bind correctly but record failed external assertions now raise `ASSERTION_FAILED`, with the phase and the booted, legitimate and security outcome. Nothing new is certified either way. In run 39186328569, three repairs that rejected the attack with a different status than the oracle expected surfaced as `INCOMPATIBLE_INPUT`. diff --git a/lib/cso/preparation-docker.ts b/lib/cso/preparation-docker.ts index e36e0a34d..7874d8762 100644 --- a/lib/cso/preparation-docker.ts +++ b/lib/cso/preparation-docker.ts @@ -1106,6 +1106,25 @@ export async function superviseRegistrySocket(options: { * Requires qualified runtime images to contain the fixed, compiled * /opt/cso/preparation@1.0.0 helper contract. */ +/** + * Write the helper's own files (Rails' test database.yml and friends) into an execution copy. The copy keeps the + * source's modes, and a sealed or read-only checkout makes those files 0444, so each copied file is replaced, never + * opened for writing (or written through). + */ +export function writeOfflineTransformations( + source: string, + transformations: OfflinePreparationRequest['transformations'], +): void { + for (const transformation of transformations) { + if (sha256(transformation.content) !== transformation.sha256) + fail('INCOMPATIBLE_INPUT', 'Offline transformation content hash changed'); + const file = contained(source, transformation.path); + secureDirectory(resolve(file, '..')); + fs.rmSync(file, { force: true }); + fs.writeFileSync(file, transformation.content, { mode: 0o600, flag: 'wx' }); + } +} + export class DockerPreparationSandboxRunner implements PreparationSandboxRunner { readonly qualification; private readonly runtime; @@ -1514,13 +1533,7 @@ export class DockerPreparationSandboxRunner implements PreparationSandboxRunner errorOnExist: false, preserveTimestamps: false, }); - for (const transformation of request.transformations) { - if (sha256(transformation.content) !== transformation.sha256) - fail('INCOMPATIBLE_INPUT', 'Offline transformation content hash changed'); - const file = contained(source, transformation.path); - secureDirectory(resolve(file, '..')); - fs.writeFileSync(file, transformation.content, { mode: 0o600 }); - } + writeOfflineTransformations(source, request.transformations); for (const item of request.metadata) { if (sha256(item.content) !== item.sha256) fail('INCOMPATIBLE_INPUT', `Offline metadata hash changed: ${item.path}`); diff --git a/test/cso-preparation-container.test.ts b/test/cso-preparation-container.test.ts index eb8b42581..6cde0dc9a 100644 --- a/test/cso-preparation-container.test.ts +++ b/test/cso-preparation-container.test.ts @@ -3,8 +3,9 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { createPreparedExport, recordNpmArchiveEntry } from '../lib/cso/preparation-container'; -import { materializePreparedExport } from '../lib/cso/preparation-docker'; +import { materializePreparedExport, writeOfflineTransformations } from '../lib/cso/preparation-docker'; import { heldApplicationReady, validateSingleContainerProcessOutput } from '../lib/cso/docker'; const roots: string[] = []; @@ -68,3 +69,28 @@ describe('CSO preparation container archive limits', () => { expect(heldApplicationReady('PID COMMAND\n1 sh\n')).toBe(false); }); }); + +describe('offline transformations in an execution copy', () => { + test.skipIf(process.platform === 'win32')('replace a read-only copied file under a read-only directory instead of writing through it', () => { + const source = temporary(), config = path.join(source, 'config'), database = path.join(config, 'database.yml'), outside = path.join(temporary(), 'outside.yml'); + fs.mkdirSync(config); + fs.writeFileSync(database, 'production: sealed\n'); + fs.chmodSync(database, 0o444); + fs.chmodSync(config, 0o555); + fs.writeFileSync(outside, 'untouched\n'); + const content = 'test:\n adapter: sqlite3\n', sha256 = createHash('sha256').update(content).digest('hex'); + writeOfflineTransformations(source, [ + { path: 'config/database.yml', content, sha256, reason: 'helper test configuration' }, + { path: 'config/initializers/zzzz_cso_test.rb', content, sha256, reason: 'helper test configuration' }, + ]); + expect(fs.readFileSync(database, 'utf8')).toBe(content); + expect(fs.statSync(database).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(path.join(config, 'initializers', 'zzzz_cso_test.rb'), 'utf8')).toBe(content); + expect(() => writeOfflineTransformations(source, [{ path: 'config/database.yml', content, sha256: 'f'.repeat(64), reason: 'tampered' }])).toThrow('Offline transformation content hash changed'); + fs.rmSync(database); + fs.symlinkSync(outside, database); + writeOfflineTransformations(source, [{ path: 'config/database.yml', content, sha256, reason: 'helper test configuration' }]); + expect(fs.lstatSync(database).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(outside, 'utf8')).toBe('untouched\n'); + }); +});