mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
v1.91.154.0 fix(cso): safe-status sets, withheld credential history, ASSERTION_FAILED, Rails read-only verify (#3302)
* fix(cso): withhold excluded credential files from retained history; name failed witness assertions ASSERTION_FAILED * fix(cso): Rails verify replaces read-only copied config instead of writing into it * fix(cso): security expectations may list several safe statuses while excluding the vulnerable marker (cherry picked from commit aa9f45e0ece0e12152e7d02bf88e1559ee42b1eb) * docs(changelog): safe status sets * v1.91.154.0 stamp * style(cso): format merged snapshot history capture
This commit is contained in:
1 parent
6b57ededeb
commit
133cb36084
15 files changed
+211
-29
No files matched your search
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.91.154.0] - 2026-10-19
|
||||||
|
|
||||||
|
**/cso can verify Rails repairs in a read-only checkout, its retained Git history no longer brings back credential files the snapshot leaves out, a repair whose external assertions fail is now reported as `ASSERTION_FAILED` instead of a misleading `INCOMPATIBLE_INPUT`, and a security assertion can accept several safe rejection statuses while still requiring the secret to be absent.** 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`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **A security assertion's `expected.status` may list up to eight safe statuses** (for example `[403, 404]`), so a repair that refuses an attack with a different reasonable rejection still passes. A set is accepted only on a security assertion that also carries a `vulnerable` expectation, and only with `excludes` set, so the response must still withhold the vulnerable marker. The fixed and vulnerable oracles must stay provably mutually exclusive: a set that contains the vulnerable status is accepted only when the vulnerable response's `includes` contains the excluded marker. Legitimate controls and `vulnerable` expectations still take exactly one status, so a patch that answers every route with 403 still fails its controls. A no-op patch, a listed rejection that still leaks the marker, and an unlisted status all stay `inconclusive`.
|
||||||
|
|
||||||
## [1.91.153.0] - 2026-10-19
|
## [1.91.153.0] - 2026-10-19
|
||||||
|
|
||||||
**An outside review whose findings are untagged prose no longer reads `clean` because a "no issues" phrase appears somewhere in it, and a clean Codex review that says "no actionable regressions" no longer reads `unverified`.** Reported in #3294 by @SchizasNik, with the false-pass half pointed out by @xg-gh-25. `lib/outside-review-result.ts` matched its no-findings vocabulary anywhere in the reply, so "The parser change has no new issues. However, … drops users_email_idx" passed /ship's structured gate. It also lacked `regressions`, so `codex review --uncommitted`'s "I found no actionable regressions in the staged, unstaged, or untracked changes." was missing coverage.
|
**An outside review whose findings are untagged prose no longer reads `clean` because a "no issues" phrase appears somewhere in it, and a clean Codex review that says "no actionable regressions" no longer reads `unverified`.** Reported in #3294 by @SchizasNik, with the false-pass half pointed out by @xg-gh-25. `lib/outside-review-result.ts` matched its no-findings vocabulary anywhere in the reply, so "The parser change has no new issues. However, … drops users_email_idx" passed /ship's structured gate. It also lacked `regressions`, so `codex review --uncommitted`'s "I found no actionable regressions in the staged, unstaged, or untracked changes." was missing coverage.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# gstack digest v1.91.153.0 — regenerate/re-copy after upgrading gstack
|
# gstack digest v1.91.154.0 — regenerate/re-copy after upgrading gstack
|
||||||
|
|
||||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||||
for agent hosts without a full skill install. The full skills add workflows,
|
for agent hosts without a full skill install. The full skills add workflows,
|
||||||
|
|||||||
+30
-10
@@ -180,7 +180,7 @@ export interface HttpAssertion {
|
|||||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
expected: { status: number; includes?: string; excludes?: string };
|
expected: { status: number | number[]; includes?: string; excludes?: string };
|
||||||
vulnerable?: { status: number; includes?: string; excludes?: string };
|
vulnerable?: { status: number; includes?: string; excludes?: string };
|
||||||
}
|
}
|
||||||
export interface Command {
|
export interface Command {
|
||||||
@@ -648,16 +648,34 @@ export function validateVerificationObservation(value: unknown): VerificationObs
|
|||||||
inputHash: v.inputHash,
|
inputHash: v.inputHash,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function assertion(value: unknown, name: string): HttpAssertion {
|
const MAX_SAFE_STATUSES = 8;
|
||||||
|
function assertion(value: unknown, name: string, safeStatusSet = false): HttpAssertion {
|
||||||
const v = object(value, name),
|
const v = object(value, name),
|
||||||
expected = object(v.expected, `${name}.expected`);
|
expected = object(v.expected, `${name}.expected`);
|
||||||
exact(v, ['name', 'path', 'method', 'headers', 'body', 'expected', 'vulnerable'], name);
|
exact(v, ['name', 'path', 'method', 'headers', 'body', 'expected', 'vulnerable'], name);
|
||||||
const oracle = (x: Record<string, any>, n: string) => {
|
const httpStatus = (s: unknown) => Number.isInteger(s) && (s as number) >= 100 && (s as number) <= 599;
|
||||||
|
const oracle = (x: Record<string, any>, n: string, statusSet = false) => {
|
||||||
exact(x, ['status', 'includes', 'excludes'], n);
|
exact(x, ['status', 'includes', 'excludes'], n);
|
||||||
if (!Number.isInteger(x.status) || x.status < 100 || x.status > 599)
|
if (!Array.isArray(x.status)) {
|
||||||
throw new CsoError('INVALID_SCHEMA', `${n}.status must be an HTTP status`);
|
if (!httpStatus(x.status)) throw new CsoError('INVALID_SCHEMA', `${n}.status must be an HTTP status`);
|
||||||
|
} else if (!statusSet) throw new CsoError('INVALID_SCHEMA', `${n}.status must be an HTTP status`);
|
||||||
|
else if (
|
||||||
|
!x.status.length ||
|
||||||
|
x.status.length > MAX_SAFE_STATUSES ||
|
||||||
|
!x.status.every(httpStatus) ||
|
||||||
|
new Set(x.status).size !== x.status.length
|
||||||
|
)
|
||||||
|
throw new CsoError(
|
||||||
|
'INVALID_SCHEMA',
|
||||||
|
`${n}.status must be 1..${MAX_SAFE_STATUSES} distinct HTTP statuses`,
|
||||||
|
);
|
||||||
|
else if (typeof x.excludes !== 'string' || !x.excludes)
|
||||||
|
throw new CsoError(
|
||||||
|
'INVALID_SCHEMA',
|
||||||
|
`${n} with a set of safe statuses must exclude the vulnerable marker`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
status: x.status,
|
status: Array.isArray(x.status) ? [...x.status] : x.status,
|
||||||
...(x.includes === undefined ? {} : { includes: string(x.includes, `${n}.includes`) }),
|
...(x.includes === undefined ? {} : { includes: string(x.includes, `${n}.includes`) }),
|
||||||
...(x.excludes === undefined ? {} : { excludes: string(x.excludes, `${n}.excludes`) }),
|
...(x.excludes === undefined ? {} : { excludes: string(x.excludes, `${n}.excludes`) }),
|
||||||
};
|
};
|
||||||
@@ -683,7 +701,7 @@ function assertion(value: unknown, name: string): HttpAssertion {
|
|||||||
method: oneOf(v.method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], `${name}.method`),
|
method: oneOf(v.method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], `${name}.method`),
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
...(Object.keys(headers).length ? { headers } : {}),
|
||||||
...(v.body === undefined ? {} : { body: string(v.body, `${name}.body`, 65536) }),
|
...(v.body === undefined ? {} : { body: string(v.body, `${name}.body`, 65536) }),
|
||||||
expected: oracle(expected, `${name}.expected`),
|
expected: oracle(expected, `${name}.expected`, safeStatusSet && v.vulnerable !== undefined),
|
||||||
...(v.vulnerable === undefined ? {} : { vulnerable: oracle(object(v.vulnerable), `${name}.vulnerable`) }),
|
...(v.vulnerable === undefined ? {} : { vulnerable: oracle(object(v.vulnerable), `${name}.vulnerable`) }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -692,13 +710,13 @@ export function securityAssertions(security: VerificationRequest['security']): H
|
|||||||
return Array.isArray(security) ? security : [security];
|
return Array.isArray(security) ? security : [security];
|
||||||
}
|
}
|
||||||
function securityAssertion(value: unknown): VerificationRequest['security'] {
|
function securityAssertion(value: unknown): VerificationRequest['security'] {
|
||||||
if (!Array.isArray(value)) return assertion(value, 'security');
|
if (!Array.isArray(value)) return assertion(value, 'security', true);
|
||||||
if (!value.length || value.length > MAX_SECURITY_ASSERTIONS)
|
if (!value.length || value.length > MAX_SECURITY_ASSERTIONS)
|
||||||
throw new CsoError(
|
throw new CsoError(
|
||||||
'INVALID_SCHEMA',
|
'INVALID_SCHEMA',
|
||||||
`security must be one assertion or 1..${MAX_SECURITY_ASSERTIONS} assertions`,
|
`security must be one assertion or 1..${MAX_SECURITY_ASSERTIONS} assertions`,
|
||||||
);
|
);
|
||||||
const assertions = value.map((x, i) => assertion(x, `security[${i}]`));
|
const assertions = value.map((x, i) => assertion(x, `security[${i}]`, true));
|
||||||
if (new Set(assertions.map((x) => x.name)).size !== assertions.length)
|
if (new Set(assertions.map((x) => x.name)).size !== assertions.length)
|
||||||
throw new CsoError('INVALID_SCHEMA', 'security assertion names must be distinct');
|
throw new CsoError('INVALID_SCHEMA', 'security assertion names must be distinct');
|
||||||
return assertions;
|
return assertions;
|
||||||
@@ -805,7 +823,9 @@ export function validateVerificationRequest(input: unknown): VerificationRequest
|
|||||||
'Verification needs a legitimate control, distinct before/fixed security oracles, existing tests, immutable test files, and boundary files',
|
'Verification needs a legitimate control, distinct before/fixed security oracles, existing tests, immutable test files, and boundary files',
|
||||||
);
|
);
|
||||||
const mutuallyExclusive = ({ expected: secure, vulnerable }: HttpAssertion) =>
|
const mutuallyExclusive = ({ expected: secure, vulnerable }: HttpAssertion) =>
|
||||||
secure.status !== vulnerable!.status ||
|
(Array.isArray(secure.status)
|
||||||
|
? !secure.status.includes(vulnerable!.status)
|
||||||
|
: secure.status !== vulnerable!.status) ||
|
||||||
(secure.includes !== undefined &&
|
(secure.includes !== undefined &&
|
||||||
vulnerable!.excludes !== undefined &&
|
vulnerable!.excludes !== undefined &&
|
||||||
secure.includes.includes(vulnerable!.excludes)) ||
|
secure.includes.includes(vulnerable!.excludes)) ||
|
||||||
|
|||||||
@@ -57,6 +57,29 @@ export function gitDiffHeaderPaths(line: string): [string, string] | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace every diff section touching a path `withheld` names with one marker line, so a file the snapshot keeps
|
||||||
|
* out of the readable tree cannot come back through retained history. An unparseable diff header is withheld too.
|
||||||
|
*/
|
||||||
|
export function withholdHistory(raw: string, withheld: (path: string) => string | undefined): string {
|
||||||
|
const output: string[] = [];
|
||||||
|
let skipping = false;
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
if (line.startsWith('commit ')) skipping = false;
|
||||||
|
else if (line.startsWith('diff --git ')) {
|
||||||
|
const paths = gitDiffHeaderPaths(line)?.map((value) => value.replace(/^[ab]\//, '')),
|
||||||
|
reason = paths ? paths.map(withheld).find(Boolean) : 'unparseable diff header';
|
||||||
|
skipping = Boolean(reason);
|
||||||
|
if (reason) {
|
||||||
|
output.push(`diff withheld: ${paths?.[1] ?? 'unknown path'} (${reason})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!skipping) output.push(line);
|
||||||
|
}
|
||||||
|
return output.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/** Return only exact path hunks, keeping one commit preamble per matching commit. */
|
/** Return only exact path hunks, keeping one commit preamble per matching commit. */
|
||||||
export function historyForPath(raw: string, path: string): string | undefined {
|
export function historyForPath(raw: string, path: string): string | undefined {
|
||||||
const expected = new Set([`a/${path}`, `b/${path}`]),
|
const expected = new Set([`a/${path}`, `b/${path}`]),
|
||||||
|
|||||||
@@ -1106,6 +1106,25 @@ export async function superviseRegistrySocket(options: {
|
|||||||
* Requires qualified runtime images to contain the fixed, compiled
|
* Requires qualified runtime images to contain the fixed, compiled
|
||||||
* /opt/cso/preparation@1.0.0 helper contract.
|
* /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 {
|
export class DockerPreparationSandboxRunner implements PreparationSandboxRunner {
|
||||||
readonly qualification;
|
readonly qualification;
|
||||||
private readonly runtime;
|
private readonly runtime;
|
||||||
@@ -1514,13 +1533,7 @@ export class DockerPreparationSandboxRunner implements PreparationSandboxRunner
|
|||||||
errorOnExist: false,
|
errorOnExist: false,
|
||||||
preserveTimestamps: false,
|
preserveTimestamps: false,
|
||||||
});
|
});
|
||||||
for (const transformation of request.transformations) {
|
writeOfflineTransformations(source, 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 });
|
|
||||||
}
|
|
||||||
for (const item of request.metadata) {
|
for (const item of request.metadata) {
|
||||||
if (sha256(item.content) !== item.sha256)
|
if (sha256(item.content) !== item.sha256)
|
||||||
fail('INCOMPATIBLE_INPUT', `Offline metadata hash changed: ${item.path}`);
|
fail('INCOMPATIBLE_INPUT', `Offline metadata hash changed: ${item.path}`);
|
||||||
|
|||||||
+4
-1
@@ -14,6 +14,7 @@ import {
|
|||||||
snapshotPathId,
|
snapshotPathId,
|
||||||
MAX_OUTPUT,
|
MAX_OUTPUT,
|
||||||
} from './contracts';
|
} from './contracts';
|
||||||
|
import { withholdHistory } from './history';
|
||||||
import { childEnvironment, executable, git, redact, runProcess } from './process';
|
import { childEnvironment, executable, git, redact, runProcess } from './process';
|
||||||
import { boundedList, readJson, secureDirectory, writeHelperJson, writeJson } from './state';
|
import { boundedList, readJson, secureDirectory, writeHelperJson, writeJson } from './state';
|
||||||
import { scan } from '../redact-engine';
|
import { scan } from '../redact-engine';
|
||||||
@@ -952,7 +953,9 @@ export async function capture(
|
|||||||
if (!(error instanceof CsoError) || error.code !== 'MISSING_INPUT') throw error;
|
if (!(error instanceof CsoError) || error.code !== 'MISSING_INPUT') throw error;
|
||||||
}
|
}
|
||||||
guard();
|
guard();
|
||||||
const commits = (patched ?? '').split(/^(?=commit [0-9a-f]{40}(?:[0-9a-f]{24})?$)/m).filter(Boolean),
|
const commits = withholdHistory(patched ?? '', exclusion)
|
||||||
|
.split(/^(?=commit [0-9a-f]{40}(?:[0-9a-f]{24})?$)/m)
|
||||||
|
.filter(Boolean),
|
||||||
sizes = commits.map((commit) => Buffer.byteLength(commit)),
|
sizes = commits.map((commit) => Buffer.byteLength(commit)),
|
||||||
whole = sizes.reduce((sum, size) => sum + size, 0) <= MAX_OUTPUT;
|
whole = sizes.reduce((sum, size) => sum + size, 0) <= MAX_OUTPUT;
|
||||||
let kept = 0,
|
let kept = 0,
|
||||||
|
|||||||
+10
-3
@@ -1219,14 +1219,21 @@ export function certify(params: {
|
|||||||
beforeReceipt.observationHash !== witnessObservationHash(beforeObservation) ||
|
beforeReceipt.observationHash !== witnessObservationHash(beforeObservation) ||
|
||||||
afterReceipt.observationHash !== witnessObservationHash(afterObservation) ||
|
afterReceipt.observationHash !== witnessObservationHash(afterObservation) ||
|
||||||
params.before.existingTests !== beforeReceipt.diagnosticTestsPassed ||
|
params.before.existingTests !== beforeReceipt.diagnosticTestsPassed ||
|
||||||
params.after.existingTests !== afterReceipt.diagnosticTestsPassed ||
|
params.after.existingTests !== afterReceipt.diagnosticTestsPassed
|
||||||
!beforeReceipt.externalAssertionsPassed ||
|
|
||||||
!afterReceipt.externalAssertionsPassed
|
|
||||||
)
|
)
|
||||||
throw new CsoError(
|
throw new CsoError(
|
||||||
'INCOMPATIBLE_INPUT',
|
'INCOMPATIBLE_INPUT',
|
||||||
'Authenticated assertion witness receipts do not match the verifier observations',
|
'Authenticated assertion witness receipts do not match the verifier observations',
|
||||||
);
|
);
|
||||||
|
for (const [phase, receipt] of [
|
||||||
|
['before', beforeReceipt],
|
||||||
|
['after', afterReceipt],
|
||||||
|
] as const)
|
||||||
|
if (!receipt.externalAssertionsPassed)
|
||||||
|
throw new CsoError(
|
||||||
|
'ASSERTION_FAILED',
|
||||||
|
`External assertions failed in the ${phase} phase: booted ${params[phase].booted}, legitimate ${params[phase].legitimate}, security ${params[phase].security}`,
|
||||||
|
);
|
||||||
const stableExecutions = (receipt: AssertionWitnessReceipt) =>
|
const stableExecutions = (receipt: AssertionWitnessReceipt) =>
|
||||||
receipt.executions.map(({ outputHash: _, ...execution }) => execution);
|
receipt.executions.map(({ outputHash: _, ...execution }) => execution);
|
||||||
if (canonical(stableExecutions(beforeReceipt)) !== canonical(stableExecutions(afterReceipt)))
|
if (canonical(stableExecutions(beforeReceipt)) !== canonical(stableExecutions(afterReceipt)))
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ interface Config {
|
|||||||
}
|
}
|
||||||
function matches(status: number, body: string, oracle: HttpAssertion['expected']): boolean {
|
function matches(status: number, body: string, oracle: HttpAssertion['expected']): boolean {
|
||||||
return (
|
return (
|
||||||
status === oracle.status &&
|
(Array.isArray(oracle.status) ? oracle.status.includes(status) : status === oracle.status) &&
|
||||||
(oracle.includes === undefined || body.includes(oracle.includes)) &&
|
(oracle.includes === undefined || body.includes(oracle.includes)) &&
|
||||||
(oracle.excludes === undefined || !body.includes(oracle.excludes))
|
(oracle.excludes === undefined || !body.includes(oracle.excludes))
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.91.153",
|
"version": "1.91.154",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ const finding:any={title:'Cross-tenant read',rootCause:'Tenant query omits calle
|
|||||||
const request=(before:string,after:string)=>({findingId:'a'.repeat(32),runtimeProfile:'node',port:3456,start:{executable:'/usr/local/bin/node',args:['app.js']},legitimate:[{name:'health',path:'/health',method:'GET',expected:{status:200,includes:'ok'}}],security:{name:'tenant isolation',path:'/user?id=2',method:'GET',expected:{status:403},vulnerable:{status:200,includes:'tenant-b'}},existingTests:[{executable:'/usr/local/bin/node',args:['test.js']}],testFiles:['test.js'],fixtures:{'tenant.json':'{}'},boundaryFiles:['app.js'],changes:[{path:'app.js',beforeSha256:sha256(before),after,effect:'source'}],review:{reviewer:'independent-reviewer',independent:true,rootCauseRepaired:true,featurePreserved:true,boundaryMocks:false,rationale:'Predicate now binds the caller tenant and health behavior is preserved',reviewedPatchHash:''}});
|
const request=(before:string,after:string)=>({findingId:'a'.repeat(32),runtimeProfile:'node',port:3456,start:{executable:'/usr/local/bin/node',args:['app.js']},legitimate:[{name:'health',path:'/health',method:'GET',expected:{status:200,includes:'ok'}}],security:{name:'tenant isolation',path:'/user?id=2',method:'GET',expected:{status:403},vulnerable:{status:200,includes:'tenant-b'}},existingTests:[{executable:'/usr/local/bin/node',args:['test.js']}],testFiles:['test.js'],fixtures:{'tenant.json':'{}'},boundaryFiles:['app.js'],changes:[{path:'app.js',beforeSha256:sha256(before),after,effect:'source'}],review:{reviewer:'independent-reviewer',independent:true,rootCauseRepaired:true,featurePreserved:true,boundaryMocks:false,rationale:'Predicate now binds the caller tenant and health behavior is preserved',reviewedPatchHash:''}});
|
||||||
// Structural signed fixture for bundle-reader mutation tests. Production
|
// Structural signed fixture for bundle-reader mutation tests. Production
|
||||||
// issuance uses AssertionWitnessSession in verifyRepair instead.
|
// issuance uses AssertionWitnessSession in verifyRepair instead.
|
||||||
function authenticatedBundleFixture(certified:ReturnType<typeof certify>,params:any,outcomes?:Record<'before'|'after',{executedTests:number;passingTests:number}>):any{
|
function authenticatedBundleFixture(certified:ReturnType<typeof certify>,params:any,outcomes?:Record<'before'|'after',{executedTests:number;passingTests:number}>,externalAssertionsPassed:Record<'before'|'after',boolean>={before:true,after:true}):any{
|
||||||
const verification=structuredClone(certified.manifest),identityRequest=params.identityRequest??params.request,pair=crypto.generateKeyPairSync('ed25519'),publicKey=pair.publicKey.export({format:'der',type:'spki'}).toString('hex'),keyId=sha256(Buffer.from(publicKey,'hex')),createdAt=verification.createdAt,runner={testToolchain:params.testToolchain,startPlanHash:verification.startPlanHash,testPlanHash:verification.testPlanHash,commandsHash:sha256(canonical(params.request.existingTests)),minimumPassingTestsHash:sha256(canonical(params.request.existingTests.map(()=>1)))},make=(phase:'before'|'after')=>{const observation=verification[phase],counts=outcomes?.[phase]??{executedTests:1,passingTests:1},binding:any={schemaVersion:1,protocol:'gstack-cso-assertion-witness-v1',nonce:sha256(`${phase}:${createdAt}`),phase,issuedAt:createdAt,expiresAt:new Date(Date.parse(createdAt)+300_000).toISOString(),runId:params.runId,findingId:identityRequest.findingId,policyHash:verification.policyHash,auditPolicyHash:verification.auditPolicyHash,runtime:{image:params.runtime.image,verifierImage:params.verifier.image,platform:params.runtime.platform,profile:params.runtime.id},runner,sourceHash:phase==='before'?verification.beforeSourceHash:verification.afterSourceHash,dependencyHash:phase==='before'?verification.beforeDependencies:verification.afterDependencies,configurationHash:phase==='before'?verification.beforeConfiguration:verification.afterConfiguration,requestHash:verification.requestHash,patchHash:verification.patchHash,harnessHash:verification.harnessHash,assertionHash:sha256(canonical({legitimate:identityRequest.legitimate,security:identityRequest.security})),fixturesHash:verification.fixturesHash},executions=params.request.existingTests.map((command:any)=>({commandHash:sha256(canonical(command)),exitCode:0,outputHash:sha256(`structural signed fixture ${phase}`),minimumPassingTests:1,...counts,reportedPassed:counts.passingTests>=1})),unsigned:any={schemaVersion:1,binding,keyId,publicKey,observationHash:witnessObservationHash(observation),externalAssertionsPassed:true,diagnosticTestsPassed:executions.every((execution:any)=>execution.reportedPassed),executions};return{...unsigned,signature:crypto.sign(null,Buffer.from(canonical(unsigned)),pair.privateKey).toString('hex')}};const witness={before:make('before'),after:make('after')};verification.assertionAssurance='authenticated_out_of_process';verification.testCompletionAssurance='self_reported';verification.witnessHash=assertionWitnessPairHash(witness);verification.result='runtime_tested';verification.id='';const id=verificationIdentity(verification);verification.id=id;
|
const verification=structuredClone(certified.manifest),identityRequest=params.identityRequest??params.request,pair=crypto.generateKeyPairSync('ed25519'),publicKey=pair.publicKey.export({format:'der',type:'spki'}).toString('hex'),keyId=sha256(Buffer.from(publicKey,'hex')),createdAt=verification.createdAt,runner={testToolchain:params.testToolchain,startPlanHash:verification.startPlanHash,testPlanHash:verification.testPlanHash,commandsHash:sha256(canonical(params.request.existingTests)),minimumPassingTestsHash:sha256(canonical(params.request.existingTests.map(()=>1)))},make=(phase:'before'|'after')=>{const observation=verification[phase],counts=outcomes?.[phase]??{executedTests:1,passingTests:1},binding:any={schemaVersion:1,protocol:'gstack-cso-assertion-witness-v1',nonce:sha256(`${phase}:${createdAt}`),phase,issuedAt:createdAt,expiresAt:new Date(Date.parse(createdAt)+300_000).toISOString(),runId:params.runId,findingId:identityRequest.findingId,policyHash:verification.policyHash,auditPolicyHash:verification.auditPolicyHash,runtime:{image:params.runtime.image,verifierImage:params.verifier.image,platform:params.runtime.platform,profile:params.runtime.id},runner,sourceHash:phase==='before'?verification.beforeSourceHash:verification.afterSourceHash,dependencyHash:phase==='before'?verification.beforeDependencies:verification.afterDependencies,configurationHash:phase==='before'?verification.beforeConfiguration:verification.afterConfiguration,requestHash:verification.requestHash,patchHash:verification.patchHash,harnessHash:verification.harnessHash,assertionHash:sha256(canonical({legitimate:identityRequest.legitimate,security:identityRequest.security})),fixturesHash:verification.fixturesHash},executions=params.request.existingTests.map((command:any)=>({commandHash:sha256(canonical(command)),exitCode:0,outputHash:sha256(`structural signed fixture ${phase}`),minimumPassingTests:1,...counts,reportedPassed:counts.passingTests>=1})),unsigned:any={schemaVersion:1,binding,keyId,publicKey,observationHash:witnessObservationHash(observation),externalAssertionsPassed:externalAssertionsPassed[phase],diagnosticTestsPassed:executions.every((execution:any)=>execution.reportedPassed),executions};return{...unsigned,signature:crypto.sign(null,Buffer.from(canonical(unsigned)),pair.privateKey).toString('hex')}};const witness={before:make('before'),after:make('after')};verification.assertionAssurance='authenticated_out_of_process';verification.testCompletionAssurance='self_reported';verification.witnessHash=assertionWitnessPairHash(witness);verification.result='runtime_tested';verification.id='';const id=verificationIdentity(verification);verification.id=id;
|
||||||
return{schemaVersion:3,runId:params.runId,id,createdAt,expiresAt:new Date(Date.parse(createdAt)+30*86400_000).toISOString(),requiredInputs:{sourceHash:params.manifest.executionHash,originalHash:params.manifest.originalHash,runtimeImage:params.runtime.image,platform:params.runtime.platform,archives:params.archives,...(params.dependencyClosures?{dependencyClosures:params.dependencyClosures}:{})},request:identityRequest,verification,transformations:params.manifest.entries.filter((entry:any)=>entry.transformation),...(params.preparation?{preparation:params.preparation}:{}),witness};
|
return{schemaVersion:3,runId:params.runId,id,createdAt,expiresAt:new Date(Date.parse(createdAt)+30*86400_000).toISOString(),requiredInputs:{sourceHash:params.manifest.executionHash,originalHash:params.manifest.originalHash,runtimeImage:params.runtime.image,platform:params.runtime.platform,archives:params.archives,...(params.dependencyClosures?{dependencyClosures:params.dependencyClosures}:{})},request:identityRequest,verification,transformations:params.manifest.entries.filter((entry:any)=>entry.transformation),...(params.preparation?{preparation:params.preparation}:{}),witness};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +170,14 @@ describe('tested repair certificate gate',()=>{
|
|||||||
const params:any={runId:'run',manifest:{originalHash:'d'.repeat(64),executionHash:treeHash(before),entries:[]},request:req,runtime:{id:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'},verifier:{image:'runtime@sha256:'+'a'.repeat(64)},before:{booted:true,legitimate:true,security:'intended_failure',existingTests:true,output:'before',inputHash:''},after:{booted:true,legitimate:true,security:'pass',existingTests:true,output:'after',inputHash:''},beforeRoot:before,afterRoot:after,policyHash:'c'.repeat(64),auditPolicyHash:'e'.repeat(64),archives:[],testToolchain:'runtime'},unsigned=certify(params),witness=authenticatedBundleFixture(unsigned,params,{before:{executedTests:2,passingTests:2},after:{executedTests:2,passingTests:1}}).witness;
|
const params:any={runId:'run',manifest:{originalHash:'d'.repeat(64),executionHash:treeHash(before),entries:[]},request:req,runtime:{id:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'},verifier:{image:'runtime@sha256:'+'a'.repeat(64)},before:{booted:true,legitimate:true,security:'intended_failure',existingTests:true,output:'before',inputHash:''},after:{booted:true,legitimate:true,security:'pass',existingTests:true,output:'after',inputHash:''},beforeRoot:before,afterRoot:after,policyHash:'c'.repeat(64),auditPolicyHash:'e'.repeat(64),archives:[],testToolchain:'runtime'},unsigned=certify(params),witness=authenticatedBundleFixture(unsigned,params,{before:{executedTests:2,passingTests:2},after:{executedTests:2,passingTests:1}}).witness;
|
||||||
expect(()=>certify({...params,witness})).toThrow('existing-test execution count or outcome');
|
expect(()=>certify({...params,witness})).toThrow('existing-test execution count or outcome');
|
||||||
});
|
});
|
||||||
|
test('a witness whose external assertions failed is an assertion failure, and a mismatched observation stays incompatible input',()=>{
|
||||||
|
const root=tmp(),before=path.join(root,'before'),after=path.join(root,'after');fs.mkdirSync(before);fs.writeFileSync(path.join(before,'app.js'),'vulnerable\n');fs.writeFileSync(path.join(before,'test.js'),'one registered test\n');const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash=patchHash(raw);const req=validateVerificationRequest(raw);preparePatchedSource(before,after,req);
|
||||||
|
const params:any={runId:'run',manifest:{originalHash:'d'.repeat(64),executionHash:treeHash(before),entries:[]},request:req,runtime:{id:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'},verifier:{image:'runtime@sha256:'+'a'.repeat(64)},before:{booted:true,legitimate:true,security:'intended_failure',existingTests:true,output:'before',inputHash:''},after:{booted:true,legitimate:true,security:'inconclusive',existingTests:true,output:'after',inputHash:''},beforeRoot:before,afterRoot:after,policyHash:'c'.repeat(64),auditPolicyHash:'e'.repeat(64),archives:[],testToolchain:'runtime'},unsigned=certify(params);
|
||||||
|
const failed=authenticatedBundleFixture(unsigned,params,undefined,{before:true,after:false}).witness;
|
||||||
|
expect(()=>certify({...params,witness:failed})).toThrow(expect.objectContaining({code:'ASSERTION_FAILED',message:'External assertions failed in the after phase: booted true, legitimate true, security inconclusive'}));
|
||||||
|
const mismatched=authenticatedBundleFixture({...unsigned,manifest:{...unsigned.manifest,after:{...unsigned.manifest.after,output:'a different observation'}}},params,undefined,{before:true,after:false}).witness;
|
||||||
|
expect(()=>certify({...params,witness:mismatched})).toThrow(expect.objectContaining({code:'INCOMPATIBLE_INPUT'}));
|
||||||
|
});
|
||||||
test('canonical Python starts use direct prepared launchers and reject root framework shadows',()=>{
|
test('canonical Python starts use direct prepared launchers and reject root framework shadows',()=>{
|
||||||
const pin=(name:string)=>`${name}==1.0.0 --hash=sha256:${'a'.repeat(64)}\n`;
|
const pin=(name:string)=>`${name}==1.0.0 --hash=sha256:${'a'.repeat(64)}\n`;
|
||||||
const flask=tmp();fs.writeFileSync(path.join(flask,'requirements.txt'),pin('Flask'));fs.writeFileSync(path.join(flask,'app.py'),'from flask import Flask\napp = Flask(__name__)\n');const flaskCommand=canonicalStartPlan(flask,'python',3456).command;expect(flaskCommand.executable).toBe('/work/.venv/bin/python');expect(flaskCommand.args.slice(0,3)).toEqual(['-I','-S','-c']);expect(flaskCommand.args[3]).toContain('from flask.cli import main');expect(flaskCommand.args.slice(4)).toEqual(['--app','app:app','run','--host','127.0.0.1','--port','3456']);fs.writeFileSync(path.join(flask,'flask.py'),'raise RuntimeError("shadow")\n');expect(()=>canonicalStartPlan(flask,'python',3456)).toThrow('root import shadow');
|
const flask=tmp();fs.writeFileSync(path.join(flask,'requirements.txt'),pin('Flask'));fs.writeFileSync(path.join(flask,'app.py'),'from flask import Flask\napp = Flask(__name__)\n');const flaskCommand=canonicalStartPlan(flask,'python',3456).command;expect(flaskCommand.executable).toBe('/work/.venv/bin/python');expect(flaskCommand.args.slice(0,3)).toEqual(['-I','-S','-c']);expect(flaskCommand.args[3]).toContain('from flask.cli import main');expect(flaskCommand.args.slice(4)).toEqual(['--app','app:app','run','--host','127.0.0.1','--port','3456']);fs.writeFileSync(path.join(flask,'flask.py'),'raise RuntimeError("shadow")\n');expect(()=>canonicalStartPlan(flask,'python',3456)).toThrow('root import shadow');
|
||||||
@@ -186,6 +194,17 @@ describe('tested repair certificate gate',()=>{
|
|||||||
const runDir=tmp(),snapshot=path.join(runDir,'snapshot');fs.mkdirSync(snapshot);const files:Record<string,string>={'package.json':JSON.stringify({scripts:{test:'jest'}}),'package-lock.json':'{}\n','app.js':'server.listen(3456)\n','app.test.js':'test("ok",()=>{})\n'};for(const [name,body] of Object.entries(files))fs.writeFileSync(path.join(snapshot,name),body);const plan=canonicalTestPlan(snapshot,'node'),raw:any=request(files['app.js'],'server.listen(3456)\n');raw.start={executable:'/usr/local/bin/node',args:['app.js']};raw.existingTests=plan.commands;raw.testFiles=plan.files;raw.changes=[{path:'package-lock.json',beforeSha256:sha256(files['package-lock.json']),after:'{"changed":true}\n',effect:'dependency'}];raw.review.reviewedPatchHash=patchHash(raw);const entries=Object.entries(files).map(([path,body])=>({path,originalHash:sha256(body),executionHash:sha256(body),bytes:Buffer.byteLength(body),mode:0o600})),manifest:any={version:3,createdAt:'2026-01-01T00:00:00Z',expiresAt:'2026-01-08T00:00:00Z',root:'/repo',headCommit:'a'.repeat(40),originalHash:'b'.repeat(64),executionHash:treeHash(snapshot),entries};let executed=false;await expect(verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime:{id:'node',stack:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'} as any,verifier:{id:'node',stack:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'} as any,policyHash:'c'.repeat(64),archives:[],executor:{observe:async()=>{executed=true;throw new Error('must not run');}},persist:false})).rejects.toMatchObject({code:'PREREQUISITE'});expect(executed).toBe(false);
|
const runDir=tmp(),snapshot=path.join(runDir,'snapshot');fs.mkdirSync(snapshot);const files:Record<string,string>={'package.json':JSON.stringify({scripts:{test:'jest'}}),'package-lock.json':'{}\n','app.js':'server.listen(3456)\n','app.test.js':'test("ok",()=>{})\n'};for(const [name,body] of Object.entries(files))fs.writeFileSync(path.join(snapshot,name),body);const plan=canonicalTestPlan(snapshot,'node'),raw:any=request(files['app.js'],'server.listen(3456)\n');raw.start={executable:'/usr/local/bin/node',args:['app.js']};raw.existingTests=plan.commands;raw.testFiles=plan.files;raw.changes=[{path:'package-lock.json',beforeSha256:sha256(files['package-lock.json']),after:'{"changed":true}\n',effect:'dependency'}];raw.review.reviewedPatchHash=patchHash(raw);const entries=Object.entries(files).map(([path,body])=>({path,originalHash:sha256(body),executionHash:sha256(body),bytes:Buffer.byteLength(body),mode:0o600})),manifest:any={version:3,createdAt:'2026-01-01T00:00:00Z',expiresAt:'2026-01-08T00:00:00Z',root:'/repo',headCommit:'a'.repeat(40),originalHash:'b'.repeat(64),executionHash:treeHash(snapshot),entries};let executed=false;await expect(verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime:{id:'node',stack:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'} as any,verifier:{id:'node',stack:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'} as any,policyHash:'c'.repeat(64),archives:[],executor:{observe:async()=>{executed=true;throw new Error('must not run');}},persist:false})).rejects.toMatchObject({code:'PREREQUISITE'});expect(executed).toBe(false);
|
||||||
});
|
});
|
||||||
test('overlapping security oracles and no-op repairs cannot be certified',()=>{const overlapping:any=request('vulnerable\n','fixed\n');overlapping.review.reviewedPatchHash='pending';overlapping.security.vulnerable={...overlapping.security.expected};expect(()=>validateVerificationRequest(overlapping)).toThrow('mutually exclusive');const noop:any=request('vulnerable\n','vulnerable\n');noop.review.reviewedPatchHash='pending';expect(()=>validateVerificationRequest(noop)).toThrow('material patch');});
|
test('overlapping security oracles and no-op repairs cannot be certified',()=>{const overlapping:any=request('vulnerable\n','fixed\n');overlapping.review.reviewedPatchHash='pending';overlapping.security.vulnerable={...overlapping.security.expected};expect(()=>validateVerificationRequest(overlapping)).toThrow('mutually exclusive');const noop:any=request('vulnerable\n','vulnerable\n');noop.review.reviewedPatchHash='pending';expect(()=>validateVerificationRequest(noop)).toThrow('material patch');});
|
||||||
|
test('a security expectation may list several safe statuses only while it excludes the vulnerable marker',()=>{
|
||||||
|
const make=(edit:(raw:any)=>void)=>{const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash='pending';edit(raw);return raw;};
|
||||||
|
expect(validateVerificationRequest(make(raw=>{raw.security.expected={status:[403,404],excludes:'tenant-b'};})).security).toMatchObject({expected:{status:[403,404],excludes:'tenant-b'}});
|
||||||
|
expect(validateVerificationRequest(make(raw=>{raw.security.expected={status:[200,403],excludes:'tenant-b'};})).security).toMatchObject({expected:{status:[200,403]}});
|
||||||
|
expect(()=>validateVerificationRequest(make(raw=>{raw.security.expected={status:[200,403],excludes:'unrelated'};}))).toThrow('mutually exclusive');
|
||||||
|
expect(()=>validateVerificationRequest(make(raw=>{raw.security.expected={status:[403,404]};}))).toThrow('must exclude the vulnerable marker');
|
||||||
|
for(const status of [[],[403,403],[99,403],[400,401,403,404,405,409,410,422,429]])expect(()=>validateVerificationRequest(make(raw=>{raw.security.expected={status,excludes:'tenant-b'};}))).toThrow('distinct HTTP statuses');
|
||||||
|
expect(()=>validateVerificationRequest(make(raw=>{raw.security.vulnerable={status:[200,206],includes:'tenant-b'};}))).toThrow('security.vulnerable.status must be an HTTP status');
|
||||||
|
expect(()=>validateVerificationRequest(make(raw=>{raw.legitimate[0].expected={status:[200,403],excludes:'denied'};}))).toThrow('legitimate[0].expected.status must be an HTTP status');
|
||||||
|
expect(()=>validateVerificationRequest(make(raw=>{raw.security=[{...raw.security,expected:{status:[403,404],excludes:'tenant-b'}},{...raw.security,name:'second',expected:{status:[403,404]}}];}))).toThrow('security[1].expected with a set of safe statuses');
|
||||||
|
});
|
||||||
test('review is a separate content-bound but explicitly self-attested artifact',()=>{const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash=patchHash(raw);const req=validateVerificationRequest(raw),artifact=makeReviewArtifact('run',req,'producer-session');raw.review.artifactId=artifact.id;const bound=validateVerificationRequest(raw);expect(validateReviewArtifact(artifact,'run',bound)).toMatchObject({producer:'producer-session',assurance:'self_attested'});expect(()=>makeReviewArtifact('run',req,req.review.reviewer)).toThrow('distinct');expect(()=>validateReviewArtifact({...artifact,rationale:'changed'},'run',bound)).toThrow('does not bind');});
|
test('review is a separate content-bound but explicitly self-attested artifact',()=>{const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash=patchHash(raw);const req=validateVerificationRequest(raw),artifact=makeReviewArtifact('run',req,'producer-session');raw.review.artifactId=artifact.id;const bound=validateVerificationRequest(raw);expect(validateReviewArtifact(artifact,'run',bound)).toMatchObject({producer:'producer-session',assurance:'self_attested'});expect(()=>makeReviewArtifact('run',req,req.review.reviewer)).toThrow('distinct');expect(()=>validateReviewArtifact({...artifact,rationale:'changed'},'run',bound)).toThrow('does not bind');});
|
||||||
test('executor observations have a strict helper-owned shape',()=>{expect(()=>validateVerificationObservation({booted:true,legitimate:true,security:'pass',existingTests:true,output:'ok',inputHash:'',tested:true})).toThrow('Unexpected');expect(()=>validateVerificationObservation({booted:'yes',legitimate:true,security:'pass',existingTests:true,output:'ok',inputHash:''})).toThrow('booleans');});
|
test('executor observations have a strict helper-owned shape',()=>{expect(()=>validateVerificationObservation({booted:true,legitimate:true,security:'pass',existingTests:true,output:'ok',inputHash:'',tested:true})).toThrow('Unexpected');expect(()=>validateVerificationObservation({booted:'yes',legitimate:true,security:'pass',existingTests:true,output:'ok',inputHash:''})).toThrow('booleans');});
|
||||||
test('secret-bearing fixtures cannot enter a replayable verification bundle',async()=>{const runDir=tmp(),snapshot=path.join(runDir,'snapshot');fs.mkdirSync(snapshot);fs.writeFileSync(path.join(snapshot,'app.js'),'vulnerable\n');const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash=patchHash(raw);raw.fixtures={'secret.txt':CREDENTIAL_CANARY};const manifest:any={entries:[{path:'app.js',transformation:undefined}]};const runtime:any={id:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'};expect(verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime,verifier:runtime,policyHash:'c'.repeat(64),archives:[],executor:{observe:async()=>{throw new Error('must not execute');}},persist:false})).rejects.toMatchObject({code:'REDACTION_FAILED'});});
|
test('secret-bearing fixtures cannot enter a replayable verification bundle',async()=>{const runDir=tmp(),snapshot=path.join(runDir,'snapshot');fs.mkdirSync(snapshot);fs.writeFileSync(path.join(snapshot,'app.js'),'vulnerable\n');const raw:any=request('vulnerable\n','fixed\n');raw.review.reviewedPatchHash=patchHash(raw);raw.fixtures={'secret.txt':CREDENTIAL_CANARY};const manifest:any={entries:[{path:'app.js',transformation:undefined}]};const runtime:any={id:'node',image:'runtime@sha256:'+'a'.repeat(64),platform:'linux/amd64'};expect(verifyRepair({runId:'run',runDir,manifest,rawRequest:raw,runtime,verifier:runtime,policyHash:'c'.repeat(64),archives:[],executor:{observe:async()=>{throw new Error('must not execute');}},persist:false})).rejects.toMatchObject({code:'REDACTION_FAILED'});});
|
||||||
|
|||||||
@@ -92,6 +92,20 @@ describe('CSO Git metadata hardening',()=>{
|
|||||||
expect(history).not.toContain('replacement-history');
|
expect(history).not.toContain('replacement-history');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.skipIf(process.platform==='win32')('retained history withholds diffs of credential files the snapshot excludes',async()=>{
|
||||||
|
const {repo,runDir,git}=fixture();
|
||||||
|
fs.writeFileSync(path.join(repo,'.env'),'UPSTREAM_TOKEN=planted-history-value\n');fs.writeFileSync(path.join(repo,'tracked.ts'),'export const tracked = false\n');
|
||||||
|
git('add','.env','tracked.ts');git('commit','-qm','configure upstream');
|
||||||
|
|
||||||
|
await capture(repo,runDir);
|
||||||
|
const history=fs.readFileSync(path.join(runDir,'history.txt'),'utf8');
|
||||||
|
|
||||||
|
expect(history).not.toContain('planted-history-value');
|
||||||
|
expect(history).toContain('diff withheld: .env (credential or execution configuration)');
|
||||||
|
expect(history).toContain('+export const tracked = false');
|
||||||
|
expect(history).toContain('Subject: configure upstream');
|
||||||
|
});
|
||||||
|
|
||||||
test.skipIf(process.platform==='win32')('rejects repository config includes before Git can consume them',async()=>{
|
test.skipIf(process.platform==='win32')('rejects repository config includes before Git can consume them',async()=>{
|
||||||
const {root,repo,runDir}=fixture(),included=path.join(root,'included.conf');
|
const {root,repo,runDir}=fixture(),included=path.join(root,'included.conf');
|
||||||
fs.writeFileSync(included,'[core]\n\tworktree = /tmp/cso-decoy\n');
|
fs.writeFileSync(included,'[core]\n\tworktree = /tmp/cso-decoy\n');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe,expect,test } from 'bun:test';
|
import { describe,expect,test } from 'bun:test';
|
||||||
import { gitDiffHeaderPaths,historyForPath } from '../lib/cso/history';
|
import { gitDiffHeaderPaths,historyForPath,withholdHistory } from '../lib/cso/history';
|
||||||
|
|
||||||
describe('CSO retained Git history path filtering',()=>{
|
describe('CSO retained Git history path filtering',()=>{
|
||||||
test('matches exact paths without leaking a prefix sibling hunk',()=>{
|
test('matches exact paths without leaking a prefix sibling hunk',()=>{
|
||||||
@@ -25,6 +25,38 @@ diff --git a/foo-extra b/foo-extra
|
|||||||
expect(historyForPath(raw,'café file.ts')).toContain('+unicode-only');
|
expect(historyForPath(raw,'café file.ts')).toContain('+unicode-only');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('withholds every diff section touching an excluded path, keeping commit preambles and other files',()=>{
|
||||||
|
const raw=`commit ${'c'.repeat(40)}
|
||||||
|
Subject: add config
|
||||||
|
diff --git a/.env b/.env
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/.env
|
||||||
|
+PLANTED_VALUE=excluded-file-body
|
||||||
|
diff --git a/app.ts b/app.ts
|
||||||
|
--- a/app.ts
|
||||||
|
+++ b/app.ts
|
||||||
|
+kept-line
|
||||||
|
commit ${'d'.repeat(40)}
|
||||||
|
Subject: rename
|
||||||
|
diff --git a/config/old.txt b/config/master.key
|
||||||
|
rename from config/old.txt
|
||||||
|
rename to config/master.key
|
||||||
|
+renamed-into-excluded
|
||||||
|
diff --git "a/foo b/foo
|
||||||
|
+unparseable-body
|
||||||
|
`;
|
||||||
|
const kept=withholdHistory(raw,(path)=>/(^|\/)(\.env|master\.key)$/.test(path)?'credential or execution configuration':undefined);
|
||||||
|
expect(kept).not.toContain('excluded-file-body');
|
||||||
|
expect(kept).not.toContain('renamed-into-excluded');
|
||||||
|
expect(kept).not.toContain('unparseable-body');
|
||||||
|
expect(kept).toContain('diff withheld: .env (credential or execution configuration)');
|
||||||
|
expect(kept).toContain('diff withheld: config/master.key (credential or execution configuration)');
|
||||||
|
expect(kept).toContain('diff withheld: unknown path (unparseable diff header)');
|
||||||
|
expect(kept).toContain('+kept-line');
|
||||||
|
expect(kept).toContain('Subject: add config');
|
||||||
|
expect(kept).toContain('Subject: rename');
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects malformed quoted headers instead of broadening the match',()=>{
|
test('rejects malformed quoted headers instead of broadening the match',()=>{
|
||||||
expect(gitDiffHeaderPaths('diff --git "a/foo b/foo')).toBeUndefined();
|
expect(gitDiffHeaderPaths('diff --git "a/foo b/foo')).toBeUndefined();
|
||||||
expect(historyForPath('diff --git "a/foo b/foo\n+secret\n','foo')).toBeUndefined();
|
expect(historyForPath('diff --git "a/foo b/foo\n+secret\n','foo')).toBeUndefined();
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import * as fs from 'node:fs';
|
|||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { createPreparedExport, recordNpmArchiveEntry } from '../lib/cso/preparation-container';
|
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';
|
import { heldApplicationReady, validateSingleContainerProcessOutput } from '../lib/cso/docker';
|
||||||
|
|
||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
@@ -68,3 +69,28 @@ describe('CSO preparation container archive limits', () => {
|
|||||||
expect(heldApplicationReady('PID COMMAND\n1 sh\n')).toBe(false);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,19 @@ describe('CSO verifier security judgement',()=>{
|
|||||||
try{expect((await judgeSecurity('before',payloads,disproved.port)).security).toBe('pass');}finally{disproved.server.stop(true);}
|
try{expect((await judgeSecurity('before',payloads,disproved.port)).security).toBe('pass');}finally{disproved.server.stop(true);}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a set of safe statuses passes any listed rejection that withholds the marker and nothing else',async()=>{
|
||||||
|
const dotfile:HttpAssertion={name:'dotfile',path:'/.env',method:'GET',expected:{status:[403,404],excludes:'DOTFILE'},vulnerable:{status:200,includes:'DOTFILE'}};
|
||||||
|
const answer=async(status:number,body:string)=>{
|
||||||
|
const server=Bun.serve({port:0,hostname:'127.0.0.1',fetch:()=>new Response(body,{status})});
|
||||||
|
try{return [(await judgeSecurity('before',dotfile,Number(server.port))).security,(await judgeSecurity('after',dotfile,Number(server.port))).security];}finally{server.stop(true);}
|
||||||
|
};
|
||||||
|
expect(await answer(404,'not found')).toEqual(['pass','pass']);
|
||||||
|
expect(await answer(403,'denied')).toEqual(['pass','pass']);
|
||||||
|
expect(await answer(200,'DOTFILE=1')).toEqual(['intended_failure','inconclusive']);
|
||||||
|
expect(await answer(403,'denied DOTFILE=1')).toEqual(['inconclusive','inconclusive']);
|
||||||
|
expect(await answer(400,'bad request')).toEqual(['inconclusive','inconclusive']);
|
||||||
|
});
|
||||||
|
|
||||||
test('a single assertion keeps its original before and after outcomes',async()=>{
|
test('a single assertion keeps its original before and after outcomes',async()=>{
|
||||||
const vulnerable=serve(new Set()),fixed=serve(ALL);
|
const vulnerable=serve(new Set()),fixed=serve(ALL);
|
||||||
try{
|
try{
|
||||||
|
|||||||
Reference in new issue
Block a user