mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
fix(cso): Rails verify replaces read-only copied config instead of writing into it
This commit is contained in:
1 parent
abae830649
commit
2a3b8ccedd
3 files changed
+49
-9
No files matched your search
+2
-1
@@ -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: <path> (<reason>)`. 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`.
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user