mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
v1.91.75.0 fix(cso-eval): fail producer receipts only on credential-class output, naming the rules (#3120)
Helper run IDs matched pii.phone.e164/pii.cc, so successful paid audits were withheld and failed. Credential-class matches (and source env.kv values such as a planted canary, quoted bare) still withhold and fail with rule IDs and counts; helper identifiers are masked and other PII/legal spans are redacted in place without changing status.
This commit is contained in:
1 parent
7552362196
commit
4e5bf1e454
7 files changed
+164
-17
No files matched your search
@@ -0,0 +1,69 @@
|
||||
/** Redaction of the producer's provider output and error text before a receipt binds them. */
|
||||
import { join } from 'node:path';
|
||||
import { readBoundedStable } from '../lib/cso/bounded-file';
|
||||
import { CsoError, MAX_OUTPUT } from '../lib/cso/contracts';
|
||||
import { redactFindingSpans, scan } from '../lib/redact-engine';
|
||||
|
||||
/**
|
||||
* Identifiers the CSO helper emits by schema, which the redactor's PII shapes misread: `<epoch ms>-<hex>` run and
|
||||
* replay IDs and the 13-digit epoch in them (pii.phone.e164), `cso-eval-<runId>-<sha12>` catalog revisions,
|
||||
* `<tool>-<hex16>-<hex16>` artifact IDs, and hex finding, review and content IDs (pii.wallet).
|
||||
*/
|
||||
const HELPER_ID = /(?<![A-Za-z0-9])(?:\d{13}-[a-f0-9]{16}|cso-eval-\d{1,20}-[a-f0-9]{12}|(?:[a-z]+-)+[a-f0-9]{16}-[a-f0-9]{16}|[a-f0-9]{16,64})(?![A-Za-z0-9])/g;
|
||||
|
||||
export interface OutputRedaction {
|
||||
/** The text as a receipt may bind it. */
|
||||
text: string;
|
||||
/** Matched rule IDs with their counts, e.g. `pii.phone.e164×2`; never the matched text. */
|
||||
rules: string[];
|
||||
/** True when a credential-class (`secret`) rule matched: the text must be withheld, not span-redacted. */
|
||||
secret: boolean;
|
||||
}
|
||||
|
||||
const tally = (ids: string[]): string[] => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
return [...counts].sort(([left], [right]) => left < right ? -1 : 1).map(([id, count]) => `${id}×${count}`);
|
||||
};
|
||||
|
||||
const ASSIGNMENT = /^\s*(?:export\s+)?[A-Za-z_][\w.-]*\s*[=:]\s*["']?([^\s"'#]{8,})/;
|
||||
|
||||
/**
|
||||
* Values assigned to credential-shaped names (the env.kv rule) in the cell's verified source, such as a planted
|
||||
* canary. Quoted without its name, such a value reads as prose to every rule, so the output check matches it exactly.
|
||||
*/
|
||||
export function sourceCredentialValues(root: string, paths: string[]): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const path of paths) {
|
||||
if (path === '.git' || path.startsWith('.git/')) continue;
|
||||
const contents = readBoundedStable(join(root, ...path.split('/')), 2 * 1024 * 1024, 'Producer source file');
|
||||
if (contents.includes(0)) continue;
|
||||
const text = contents.toString('utf8'), lines = text.split('\n');
|
||||
for (const finding of scan(text, { maxBytes: 2 * 1024 * 1024 }).findings) {
|
||||
const value = finding.id === 'env.kv' ? ASSIGNMENT.exec(lines[finding.line - 1] ?? '')?.[1] : undefined;
|
||||
if (value) values.add(value);
|
||||
}
|
||||
}
|
||||
return [...values];
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential classes are judged on the unmasked text, so masking can never hide one. Only when none matched are the
|
||||
* helper's identifiers set aside and the remaining PII, internal, legal and hygiene spans replaced in place.
|
||||
*/
|
||||
export function redactProducerText(value: string, sourceCredentials: string[] = []): OutputRedaction {
|
||||
const findings = scan(value, { maxBytes: MAX_OUTPUT });
|
||||
if (findings.oversize) throw new CsoError('REDACTION_FAILED', 'Producer output withheld because it exceeds the redaction limit');
|
||||
const secretIds = findings.findings.filter(finding => finding.category === 'secret').map(finding => finding.id);
|
||||
for (const credential of sourceCredentials) secretIds.push(...Array(value.split(credential).length - 1).fill('source.env.kv'));
|
||||
if (secretIds.length) return { text: '', rules: tally(secretIds), secret: true };
|
||||
const identifiers: string[] = [];
|
||||
const letters = (index: number): string => (index >= 26 ? letters(Math.floor(index / 26) - 1) : '') + String.fromCharCode(97 + (index % 26));
|
||||
const masked = value.replace(HELPER_ID, match => `GSTACKHELPERID${letters(identifiers.push(match) - 1)}Z`);
|
||||
const masking = scan(masked, { maxBytes: MAX_OUTPUT }).findings;
|
||||
const redacted = redactFindingSpans(masked, { maxBytes: MAX_OUTPUT });
|
||||
if (redacted === null) throw new CsoError('REDACTION_FAILED', 'Producer output withheld because redaction could not safely locate every span');
|
||||
const indexOf = (code: string): number => [...code].reduce((total, char) => total * 26 + char.charCodeAt(0) - 96, 0) - 1;
|
||||
const text = redacted.replace(/GSTACKHELPERID([a-z]+)Z/g, (whole, code: string) => identifiers[indexOf(code)] ?? whole);
|
||||
return { text, rules: tally(masking.map(finding => finding.id)), secret: false };
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { readBoundedStable } from '../lib/cso/bounded-file';
|
||||
import { CsoError } from '../lib/cso/contracts';
|
||||
import { executable, redact } from '../lib/cso/process';
|
||||
import { atomicWriteSync } from '../lib/fs-atomic';
|
||||
import { redactProducerText, sourceCredentialValues, type OutputRedaction } from './cso-eval-output-redaction';
|
||||
import { resolveClaudeCommand } from '../lib/claude-bin';
|
||||
import runtimeCatalog from '../lib/cso/runtime-catalog.json';
|
||||
import scannerCatalog from '../lib/cso/scanner-images/catalog.json';
|
||||
@@ -500,25 +501,34 @@ function writeExclusiveAtomic(path: string, value: unknown): void {
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('RECEIPT_EXISTS'); throw error; }
|
||||
}
|
||||
|
||||
/** Redact both channels and both concatenation orders before a receipt can bind them. */
|
||||
export function sanitizeProducerRun(run: RunResult): RunResult {
|
||||
/**
|
||||
* Redact both channels, and both concatenation orders, before a receipt can bind them. A credential-class match
|
||||
* anywhere withholds the output and fails the run, naming the rules. PII, internal, legal and hygiene matches are
|
||||
* replaced in place and leave the run's status alone: scoring reads the helper's report, the evaluator scans the
|
||||
* receipt for its canaries separately, and the helper's own identifiers are not personal data. `sourceCredentials` are
|
||||
* the source's credential-name values (see sourceCredentialValues), matched exactly because no rule sees them bare.
|
||||
*/
|
||||
export function sanitizeProducerRun(run: RunResult, sourceCredentials: string[] = []): RunResult {
|
||||
if (typeof run.output !== 'string' || (run.error && typeof run.error.reason !== 'string')) {
|
||||
throw new CsoError('REDACTION_FAILED', 'Producer output withheld because it was not valid text');
|
||||
}
|
||||
const reason = run.error?.reason ?? '';
|
||||
let views: OutputRedaction[];
|
||||
try {
|
||||
const views = [run.output, reason, run.output + reason, reason + run.output];
|
||||
if (views.some(value => redact(value) !== value)) {
|
||||
return {
|
||||
...run,
|
||||
output: '[sensitive producer output redacted]',
|
||||
error: { code: run.error?.code ?? 'unknown', reason: 'Sensitive producer output or error withheld' },
|
||||
};
|
||||
}
|
||||
return run;
|
||||
views = [run.output, reason, run.output + reason, reason + run.output].map(view => redactProducerText(view, sourceCredentials));
|
||||
} catch {
|
||||
throw new CsoError('REDACTION_FAILED', 'Producer output withheld because redaction could not safely inspect it');
|
||||
}
|
||||
const secret = views.find(view => view.secret);
|
||||
if (secret) {
|
||||
return {
|
||||
...run,
|
||||
output: '[sensitive producer output redacted]',
|
||||
error: { code: run.error?.code ?? 'unknown', reason: `Sensitive producer output or error withheld: credential-class rule ${secret.rules.join(', ')}` },
|
||||
};
|
||||
}
|
||||
const [output, error] = views;
|
||||
return { ...run, output: output.text, ...(run.error ? { error: { ...run.error, reason: error.text } } : {}) };
|
||||
}
|
||||
|
||||
export function producerFailureMessage(error: unknown): string {
|
||||
@@ -565,6 +575,7 @@ export async function runProducerCell(inputPath: string, receiptPath: string, op
|
||||
validateSource(sourceRoot, input.source, cell.sourceHash);
|
||||
sealProducerSource(sourceRoot);
|
||||
validateSource(sourceRoot, input.source, cell.sourceHash);
|
||||
const sourceCredentials = sourceCredentialValues(sourceRoot, input.source.map(entry => entry.path));
|
||||
const originalRepositoryIdentity = repositoryIdentity(sourceRoot);
|
||||
const stateRoot = join(jobRoot, 'state');
|
||||
const helperHome = join(stateRoot, 'cso-home');
|
||||
@@ -653,7 +664,7 @@ export async function runProducerCell(inputPath: string, receiptPath: string, op
|
||||
artifacts = emptyArtifactInventory();
|
||||
}
|
||||
if (!integrityFailure) {
|
||||
run = sanitizeProducerRun(run);
|
||||
run = sanitizeProducerRun(run, sourceCredentials);
|
||||
if(!run.error&&!run.output.trim())run={...run,error:{code:'unknown',reason:'empty output from provider CLI (exit 0)'}};
|
||||
}
|
||||
const tokensReported = run.tokens.input > 0 || run.tokens.output > 0 || (run.tokens.cached ?? 0) > 0;
|
||||
|
||||
Reference in new issue
Block a user