mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
* fix(cso): give snapshot.json its own 16 MiB cap and actionable capacity errors /cso refused any repository with more than about 3,100 tracked files before an audit started: the snapshot manifest was pretty-printed and both its writer and every reader capped it at the shared 1 MiB private-state limit (#3068, manifest half of #2993). - snapshot.json is written compactly and capped by SNAPSHOT_MANIFEST_LIMIT (16 MiB), a constant local to the snapshot module. - readJson takes a per-artifact cap; readSnapshotManifest is the only reader of snapshot.json and passes the snapshot cap through recovery and the private-file size check. MAX_STATE_FILE and MAX_OUTPUT stay 1 MiB. - The manifest-cap and 64 MiB source-cap errors name the bound cap, the measured value, what counts toward it, the next step and #2993. A single file that would cross 64 MiB now hits the same message before it is read. - Older pretty-printed manifests still load (recheck, inspect). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(cso): bound per-entry report and evidence lists with omitted counts Lifting the manifest cap alone moved the failure into the audit: report.json listed one transformation per transformed entry plus one coverage gap or exclusion per omitted entry (1.97 MB for 5,000 nested paths), and sensitive-evidence.json grew per scanner hit (1.68 MB), both under the 1 MiB private-state cap that saveReport and the start stdout share. - boundedList (state.ts) keeps the leading entries that fit a byte budget, measured as the pretty-printed JSON they add at their nesting depth, and ends the list with one summary carrying the omitted count. - report.json snapshot lists share one 256 KiB budget (gaps 128 KiB, transformations 96 KiB, exclusions 32 KiB); coverage counts and status are computed before trimming, so they stay exact. - sensitive-evidence.json keeps 512 KiB of per-file evidence plus an omitted-count record. - Snapshot coverage and publicSnapshotPath no longer scan the whole manifest per entry. Test: 5,000 long nested paths, mostly excluded or withheld, 999 files with scanner hits, driven through start, inspect, submit, finish and recheck. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(cso): admit Git path listings above 1 MiB during snapshot capture With the manifest cap lifted, the next file-count wall was the shared 1 MiB process-output cap on Git's path listings: ls-files/ls-tree --name-only failed at roughly 15-20k files (23,749-file grafana/grafana fails there) and the comparison-base ls-tree near 8-10k. runProcess lets raw callers, whose output is never printed or persisted, opt in to up to 16 MiB; git() forwards an optional cap, and the four snapshot listings pass SNAPSHOT_MANIFEST_LIMIT. Every other caller keeps the MAX_OUTPUT clamp. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(cso): explain the capacity refusals and file the follow-ups docs/troubleshooting.md#cso-capacity covers the snapshot-list cap, the 64 MiB source cap and the tracked-symlink refusal. TODOS.md records the symlink design call, the #2993 aggregate cap, the full-manifest inspect output and repair-bundle transformations. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(release): v1.91.42.0 /cso lifts its file-count ceiling Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
697 lines
25 KiB
TypeScript
697 lines
25 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import {
|
|
accessSync,
|
|
closeSync,
|
|
constants,
|
|
existsSync,
|
|
fstatSync,
|
|
lstatSync,
|
|
type Stats,
|
|
openSync,
|
|
readSync,
|
|
realpathSync,
|
|
statSync,
|
|
} from 'node:fs';
|
|
import { basename, dirname, join, isAbsolute, delimiter, resolve } from 'node:path';
|
|
import { redactFindingSpans } from '../redact-engine';
|
|
import { CsoError, MAX_OUTPUT } from './contracts';
|
|
import { windowsDockerUnavailable } from './windows-docker';
|
|
|
|
const SOURCE_RUNTIME = /^bun(?:\.exe)?$/i.test(basename(process.execPath));
|
|
const WINDOWS_GIT =
|
|
process.platform === 'win32'
|
|
? process.env.GSTACK_CSO_TRUSTED_GIT || (SOURCE_RUNTIME ? (Bun.which('git') ?? '') : '')
|
|
: '';
|
|
const WINDOWS_SYSTEM =
|
|
process.platform === 'win32' ? join(process.env.SystemRoot || 'C:\\Windows', 'System32') : '';
|
|
export const TRUSTED_DIRECTORIES =
|
|
process.platform === 'win32'
|
|
? [...new Set([WINDOWS_GIT ? dirname(WINDOWS_GIT) : '', WINDOWS_SYSTEM].filter(Boolean))]
|
|
: ['/usr/local/bin', '/usr/bin', '/bin', '/opt/homebrew/bin', '/usr/local/sbin', '/usr/sbin', '/sbin'];
|
|
export const TRUSTED_PATH = TRUSTED_DIRECTORIES.join(delimiter);
|
|
export function executable(name: string): string {
|
|
// Never consult the audited repository's PATH or executable overrides.
|
|
if (!/^[a-zA-Z0-9._-]+$/.test(name)) throw new CsoError('INVALID_ARGUMENT', 'Invalid executable name');
|
|
if (process.platform === 'win32' && name.toLowerCase() === 'git') {
|
|
try {
|
|
if (!WINDOWS_GIT || !isAbsolute(WINDOWS_GIT) || basename(WINDOWS_GIT).toLowerCase() !== 'git.exe')
|
|
throw new Error();
|
|
const stat = statSync(WINDOWS_GIT);
|
|
if (!stat.isFile()) throw new Error();
|
|
return realpathSync(WINDOWS_GIT);
|
|
} catch {
|
|
throw new CsoError(
|
|
'TOOL_UNAVAILABLE',
|
|
'git.exe is not the trusted executable bound during gstack setup',
|
|
);
|
|
}
|
|
}
|
|
if (process.platform === 'win32' && name.toLowerCase() === 'docker') windowsDockerUnavailable();
|
|
for (const directory of TRUSTED_DIRECTORIES) {
|
|
const candidates =
|
|
process.platform === 'win32'
|
|
? [join(directory, `${name}.exe`), join(directory, `${name}.cmd`), join(directory, name)]
|
|
: [join(directory, name)];
|
|
for (const p of candidates) {
|
|
try {
|
|
const stat = statSync(p);
|
|
accessSync(p, constants.X_OK);
|
|
if (stat.isFile() && (process.platform === 'win32' || stat.mode & 0o111)) return realpathSync(p);
|
|
} catch {}
|
|
}
|
|
}
|
|
throw new CsoError('TOOL_UNAVAILABLE', `${name} is not installed in a trusted system executable directory`);
|
|
}
|
|
export function childEnvironment(home: string): Record<string, string> {
|
|
return {
|
|
PATH: TRUSTED_PATH,
|
|
HOME: home,
|
|
LANG: 'C.UTF-8',
|
|
LC_ALL: 'C.UTF-8',
|
|
TZ: 'UTC',
|
|
GIT_CONFIG_NOSYSTEM: '1',
|
|
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
GIT_OPTIONAL_LOCKS: '0',
|
|
GIT_ATTR_NOSYSTEM: '1',
|
|
};
|
|
}
|
|
/**
|
|
* Redact each same-stream run of captured output in place. The result is
|
|
* released only when every view a reader could assemble from it (each channel,
|
|
* both concatenation orders, and the chronological interleaving) is free of
|
|
* findings; a span crossing a channel boundary or one that cannot be located
|
|
* therefore still withholds both channels.
|
|
*/
|
|
function spliceRedaction(
|
|
ordered: Buffer[],
|
|
streams: Array<Buffer[]>,
|
|
out: Buffer[],
|
|
): { stdout: string; stderr: string } | null {
|
|
const runs: Array<{ stdout: boolean; text: string }> = [];
|
|
for (let i = 0; i < ordered.length;) {
|
|
let j = i;
|
|
while (j < ordered.length && streams[j] === streams[i]) j++;
|
|
const bytes = Buffer.concat(ordered.slice(i, j)),
|
|
text = bytes.toString('utf8');
|
|
if (!Buffer.from(text, 'utf8').equals(bytes)) return null;
|
|
runs.push({ stdout: streams[i] === out, text });
|
|
i = j;
|
|
}
|
|
const released: Array<{ stdout: boolean; text: string }> = [];
|
|
for (const run of runs) {
|
|
const text = redactFindingSpans(run.text, { maxBytes: MAX_OUTPUT });
|
|
if (text === null) return null;
|
|
released.push({ stdout: run.stdout, text });
|
|
}
|
|
const stdout = released
|
|
.filter((run) => run.stdout)
|
|
.map((run) => run.text)
|
|
.join(''),
|
|
stderr = released
|
|
.filter((run) => !run.stdout)
|
|
.map((run) => run.text)
|
|
.join(''),
|
|
chronological = released.map((run) => run.text).join('');
|
|
const views = [stdout, stderr, stdout + stderr, stderr + stdout, chronological];
|
|
if (views.some((view) => redactFindingSpans(view, { maxBytes: 4 * MAX_OUTPUT }) !== view)) return null;
|
|
return { stdout, stderr };
|
|
}
|
|
export function redact(value: string): string {
|
|
// Scan the complete bounded stream, including across write/chunk boundaries.
|
|
const output = redactFindingSpans(value, { maxBytes: MAX_OUTPUT });
|
|
if (output === null)
|
|
throw new CsoError(
|
|
'REDACTION_FAILED',
|
|
'Payload withheld because redaction could not safely locate every secret',
|
|
);
|
|
return output;
|
|
}
|
|
const HASH_KEYS = new Set([
|
|
'planSha256',
|
|
'planHash',
|
|
'originalHash',
|
|
'executionHash',
|
|
'snapshotHash',
|
|
'sourceHash',
|
|
'beforeSha256',
|
|
'afterSha256',
|
|
'patchHash',
|
|
'reviewedPatchHash',
|
|
'harnessHash',
|
|
'fixturesHash',
|
|
'policyHash',
|
|
'auditPolicyHash',
|
|
'originalSourceHash',
|
|
'transformationsHash',
|
|
'archivesHash',
|
|
'inputHash',
|
|
'beforeSourceHash',
|
|
'afterSourceHash',
|
|
'beforeDependencies',
|
|
'afterDependencies',
|
|
'beforeConfiguration',
|
|
'afterConfiguration',
|
|
'requestHash',
|
|
'startPlanHash',
|
|
'testPlanHash',
|
|
'preparationHash',
|
|
'preparedManifestHash',
|
|
'preparedDependencyHash',
|
|
'sourceProjectionHash',
|
|
'executionEnvironmentHash',
|
|
'databaseHash',
|
|
'receiptHash',
|
|
'dependencyClosureHash',
|
|
'closureHash',
|
|
'acquisitionReceiptHash',
|
|
'registryResponseSha256',
|
|
'sha256',
|
|
'versionOutputSha256',
|
|
'isolationPolicyHash',
|
|
'contentSha256',
|
|
'sbomDigest',
|
|
'provenanceDigest',
|
|
'dependencyHash',
|
|
'configurationHash',
|
|
'assertionHash',
|
|
'commandsHash',
|
|
'minimumPassingTestsHash',
|
|
'commandHash',
|
|
'outputHash',
|
|
'observationHash',
|
|
'witnessHash',
|
|
'keyId',
|
|
]);
|
|
function safeMetadata(value: string, key: string): boolean {
|
|
if (HASH_KEYS.has(key) && /^[a-f0-9]{64}$/.test(value)) return true;
|
|
if (
|
|
[
|
|
'id',
|
|
'fingerprint',
|
|
'findingId',
|
|
'verificationId',
|
|
'reproductionAttemptId',
|
|
'artifactId',
|
|
'reviewArtifactId',
|
|
'bundleId',
|
|
'pathId',
|
|
].includes(key) &&
|
|
/^[a-f0-9]{32}$/.test(value)
|
|
)
|
|
return true;
|
|
if (key === 'path' && /^@cso-path\/\/[a-f0-9]{32}$/.test(value)) return true;
|
|
if (key === 'repoId' && /^[a-f0-9]{24}$/.test(value)) return true;
|
|
if (key === 'runId' && /^\d{13}-[a-f0-9]{16}$/.test(value)) return true;
|
|
if (key === 'replayId' && /^\d{13}-[a-f0-9]{16}$/.test(value)) return true;
|
|
if (['baseCommit', 'headCommit'].includes(key) && /^[a-f0-9]{40,64}$/.test(value)) return true;
|
|
if (
|
|
['createdAt', 'expiresAt', 'deadline', 'at', 'databaseUpdatedAt', 'qualifiedAt'].includes(key) &&
|
|
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value)
|
|
)
|
|
return true;
|
|
if (key === 'nonce' && /^[a-f0-9]{64}$/.test(value)) return true;
|
|
if (key === 'publicKey' && /^[a-f0-9]{88}$/.test(value)) return true;
|
|
if (key === 'signature' && /^[a-f0-9]{128}$/.test(value)) return true;
|
|
if (key === 'image' && /^[a-z0-9./:_-]+@sha256:[a-f0-9]{64}$/.test(value)) return true;
|
|
if (key === 'integrity' && /^(?:sha256|sha512)-[A-Za-z0-9+/]+={0,2}$/.test(value)) return true;
|
|
if (
|
|
['catalog', 'catalogRevision', 'scannerCatalog'].includes(key) &&
|
|
/^cso-(?:scanners|v3|eval)-[a-z0-9._-]{1,120}$/.test(value)
|
|
)
|
|
return true;
|
|
if (
|
|
key === 'workflow' &&
|
|
/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/actions\/runs\/[0-9]{1,20}$/.test(value)
|
|
)
|
|
return true;
|
|
return false;
|
|
}
|
|
// Run ids are public CI metadata to the shared redactor; here only the validated
|
|
// workflow slot may carry one.
|
|
const RUN_ID_SEGMENT = /(\/actions\/runs\/)[0-9]+/g;
|
|
function sanitizeJson(value: unknown, key: string, seen: WeakSet<object>, trustedMetadata: boolean): unknown {
|
|
if (typeof value === 'string') {
|
|
if (trustedMetadata && safeMetadata(value, key)) return value;
|
|
return redact(value).replace(RUN_ID_SEGMENT, '$1<REDACTED-ci-run-id>');
|
|
}
|
|
if (value === null || typeof value !== 'object') return value;
|
|
if (seen.has(value as object)) throw new CsoError('INVALID_SCHEMA', 'Cyclic JSON cannot be persisted');
|
|
seen.add(value as object);
|
|
if (Array.isArray(value)) {
|
|
const out = value.map((v) => sanitizeJson(v, key, seen, trustedMetadata));
|
|
seen.delete(value);
|
|
return out;
|
|
}
|
|
const out: Record<string, unknown> = Object.create(null);
|
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
if (['__proto__', 'prototype', 'constructor'].includes(k))
|
|
throw new CsoError('INVALID_SCHEMA', 'Unsafe JSON property');
|
|
out[k] = sanitizeJson(v, k, seen, trustedMetadata);
|
|
}
|
|
seen.delete(value as object);
|
|
return out;
|
|
}
|
|
/** Redact untrusted JSON content. Key names never make an untrusted value exempt. */
|
|
export function sanitizeForJson(value: unknown): unknown {
|
|
return sanitizeJson(value, '', new WeakSet<object>(), false);
|
|
}
|
|
/** Preserve only validated helper identifiers/hashes while redacting all content-bearing fields. */
|
|
export function sanitizeHelperForJson(value: unknown): unknown {
|
|
return sanitizeJson(value, '', new WeakSet<object>(), true);
|
|
}
|
|
export interface ProcessResult {
|
|
code: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
timedOut: boolean;
|
|
truncated: boolean;
|
|
capturedBytes: number;
|
|
}
|
|
interface GitConfigIdentity {
|
|
path: string;
|
|
exists: boolean;
|
|
dev?: number;
|
|
ino?: number;
|
|
mode?: number;
|
|
size?: number;
|
|
mtimeMs?: number;
|
|
ctimeMs?: number;
|
|
content?: string;
|
|
}
|
|
const GIT_CONFIG_LIMIT = 1024 * 1024;
|
|
interface BoundedMetadataFile {
|
|
dev: number;
|
|
ino: number;
|
|
mode: number;
|
|
nlink: number;
|
|
size: number;
|
|
mtimeMs: number;
|
|
ctimeMs: number;
|
|
content: string;
|
|
}
|
|
function sameMetadataFile(left: BoundedMetadataFile | Stats, right: BoundedMetadataFile | Stats): boolean {
|
|
return (
|
|
left.dev === right.dev &&
|
|
left.ino === right.ino &&
|
|
left.mode === right.mode &&
|
|
left.nlink === right.nlink &&
|
|
left.size === right.size &&
|
|
left.mtimeMs === right.mtimeMs &&
|
|
left.ctimeMs === right.ctimeMs
|
|
);
|
|
}
|
|
function boundedMetadataFile(
|
|
path: string,
|
|
maxBytes: number,
|
|
label: string,
|
|
optional = false,
|
|
): BoundedMetadataFile | undefined {
|
|
let before: Stats;
|
|
try {
|
|
before = lstatSync(path);
|
|
} catch (error: any) {
|
|
if (optional && error?.code === 'ENOENT') return;
|
|
throw new CsoError(
|
|
error?.code === 'ENOENT' ? 'SNAPSHOT_RACE' : 'UNSAFE_PATH',
|
|
`${label} is not a bounded regular file`,
|
|
);
|
|
}
|
|
if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1 || before.size > maxBytes)
|
|
throw new CsoError('UNSAFE_PATH', `${label} is not a bounded regular file`);
|
|
let fd: number | undefined;
|
|
try {
|
|
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
const opened = fstatSync(fd);
|
|
if (!opened.isFile() || opened.nlink !== 1 || opened.size > maxBytes || !sameMetadataFile(before, opened))
|
|
throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was opened`);
|
|
const buffer = Buffer.alloc(Math.min(maxBytes + 1, opened.size + 1));
|
|
let bytes = 0,
|
|
count = 0;
|
|
while (bytes < buffer.length && (count = readSync(fd, buffer, bytes, buffer.length - bytes, null)) > 0)
|
|
bytes += count;
|
|
const final = fstatSync(fd),
|
|
after = lstatSync(path);
|
|
if (
|
|
bytes !== opened.size ||
|
|
!final.isFile() ||
|
|
!after.isFile() ||
|
|
after.isSymbolicLink() ||
|
|
!sameMetadataFile(opened, final) ||
|
|
!sameMetadataFile(opened, after)
|
|
)
|
|
throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was read`);
|
|
return {
|
|
dev: opened.dev,
|
|
ino: opened.ino,
|
|
mode: opened.mode,
|
|
nlink: opened.nlink,
|
|
size: opened.size,
|
|
mtimeMs: opened.mtimeMs,
|
|
ctimeMs: opened.ctimeMs,
|
|
content: buffer.subarray(0, bytes).toString('utf8'),
|
|
};
|
|
} catch (error: any) {
|
|
if (error instanceof CsoError) throw error;
|
|
if (['ENOENT', 'ELOOP', 'ENXIO'].includes(error?.code))
|
|
throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was opened`);
|
|
throw new CsoError('UNSAFE_PATH', `${label} could not be read safely`);
|
|
} finally {
|
|
if (fd !== undefined)
|
|
try {
|
|
closeSync(fd);
|
|
} catch {}
|
|
}
|
|
}
|
|
function boundedConfig(path: string): GitConfigIdentity {
|
|
const file = boundedMetadataFile(path, GIT_CONFIG_LIMIT, 'Repository Git configuration', true);
|
|
if (!file) return { path, exists: false };
|
|
const { content } = file;
|
|
// There is no process-wide "--no-includes" switch for ordinary Git
|
|
// commands. Reject include directives before spawning Git so repository
|
|
// configuration cannot pull policy or executable settings from elsewhere.
|
|
if (/^\s*\[\s*include(?:if)?(?=[\s."\]])/im.test(content))
|
|
throw new CsoError(
|
|
'UNSAFE_PATH',
|
|
'Repository Git config includes are not allowed during a security snapshot',
|
|
);
|
|
return {
|
|
path,
|
|
exists: true,
|
|
dev: file.dev,
|
|
ino: file.ino,
|
|
mode: file.mode,
|
|
size: file.size,
|
|
mtimeMs: file.mtimeMs,
|
|
ctimeMs: file.ctimeMs,
|
|
content,
|
|
};
|
|
}
|
|
function gitDirectories(repo: string): { gitDir: string; commonDir: string } {
|
|
const marker = join(repo, '.git'),
|
|
stat = lstatSync(marker);
|
|
let gitDir: string;
|
|
if (stat.isDirectory() && !stat.isSymbolicLink()) gitDir = realpathSync(marker);
|
|
else if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size <= 8192) {
|
|
const value = boundedMetadataFile(marker, 8192, 'Repository .git pointer')!.content,
|
|
match = value.match(/^gitdir:\s*(.+?)\s*$/);
|
|
if (!match || value.includes('\0') || value.split(/\r?\n/).filter(Boolean).length !== 1)
|
|
throw new CsoError('UNSAFE_PATH', 'Repository .git pointer is invalid');
|
|
gitDir = realpathSync(resolve(dirname(marker), match[1]));
|
|
} else
|
|
throw new CsoError(
|
|
'UNSAFE_PATH',
|
|
'Repository .git metadata is not a regular directory or worktree pointer',
|
|
);
|
|
const commonMarker = join(gitDir, 'commondir'),
|
|
commonFile = boundedMetadataFile(commonMarker, 8192, 'Repository common Git directory pointer', true);
|
|
let commonDir = gitDir;
|
|
if (commonFile) {
|
|
const value = commonFile.content.trim();
|
|
if (!value || value.includes('\0') || value.includes('\n') || value.includes('\r'))
|
|
throw new CsoError('UNSAFE_PATH', 'Repository common Git directory pointer is invalid');
|
|
commonDir = realpathSync(resolve(gitDir, value));
|
|
}
|
|
return { gitDir, commonDir };
|
|
}
|
|
function gitConfigIdentities(repo: string): GitConfigIdentity[] {
|
|
const { gitDir, commonDir } = gitDirectories(repo);
|
|
// extensions.worktreeConfig makes config.worktree active in both linked and
|
|
// main worktrees. Bind even its absence so it cannot appear after inspection
|
|
// and feed Git an unchecked include or executable setting.
|
|
return [join(commonDir, 'config'), join(gitDir, 'config.worktree')].map(boundedConfig);
|
|
}
|
|
function assertGitConfigIdentities(expected: GitConfigIdentity[]): void {
|
|
for (const item of expected) {
|
|
const current = boundedConfig(item.path);
|
|
if (
|
|
current.exists !== item.exists ||
|
|
current.dev !== item.dev ||
|
|
current.ino !== item.ino ||
|
|
current.mode !== item.mode ||
|
|
current.size !== item.size ||
|
|
current.mtimeMs !== item.mtimeMs ||
|
|
current.ctimeMs !== item.ctimeMs ||
|
|
current.content !== item.content
|
|
)
|
|
throw new CsoError('SNAPSHOT_RACE', 'Repository Git configuration changed during a metadata operation');
|
|
}
|
|
}
|
|
function hardenGit(file: string, args: string[]): { args: string[]; configs?: GitConfigIdentity[] } {
|
|
if (!/^(?:git|git\.exe)$/i.test(basename(file))) return { args };
|
|
let trusted: string;
|
|
try {
|
|
trusted = executable('git');
|
|
} catch {
|
|
return { args };
|
|
}
|
|
if (realpathSync(file) !== trusted) return { args };
|
|
const positions = args.flatMap((value, index) => (value === '-C' ? [index] : []));
|
|
if (positions.length !== 1 || positions[0] + 1 >= args.length)
|
|
throw new CsoError(
|
|
'INVALID_ARGUMENT',
|
|
'CSO Git operations require exactly one audited working directory',
|
|
);
|
|
const position = positions[0],
|
|
requested = args[position + 1];
|
|
if (!isAbsolute(requested))
|
|
throw new CsoError(
|
|
'INVALID_ARGUMENT',
|
|
'CSO Git operations require an absolute audited working directory',
|
|
);
|
|
const repo = realpathSync(requested),
|
|
stat = statSync(repo);
|
|
if (!stat.isDirectory())
|
|
throw new CsoError('MISSING_INPUT', 'Audited Git working directory is not a directory');
|
|
const configs = gitConfigIdentities(repo),
|
|
nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null',
|
|
// Git for Windows accepts NUL for ordinary file-valued settings, but its
|
|
// config include machinery treats NUL as a failing include. Its MSYS path
|
|
// layer maps /dev/null correctly for this one directive.
|
|
includeNullPath = process.platform === 'win32' ? '/dev/null' : nullPath;
|
|
const prefix = args.slice(0, position),
|
|
command = args.slice(position + 2);
|
|
return {
|
|
configs,
|
|
args: [
|
|
...prefix,
|
|
'--no-replace-objects',
|
|
'-c',
|
|
'core.fsmonitor=false',
|
|
'-c',
|
|
`core.hooksPath=${nullPath}`,
|
|
'-c',
|
|
`core.attributesFile=${nullPath}`,
|
|
'-c',
|
|
`core.excludesFile=${nullPath}`,
|
|
'-c',
|
|
'core.ignoreCase=false',
|
|
'-c',
|
|
'core.precomposeUnicode=false',
|
|
'-c',
|
|
'core.untrackedCache=false',
|
|
'-c',
|
|
`include.path=${includeNullPath}`,
|
|
'-c',
|
|
'core.pager=cat',
|
|
'-C',
|
|
repo,
|
|
`--work-tree=${repo}`,
|
|
...command,
|
|
],
|
|
};
|
|
}
|
|
/** Ceiling for one supervised child command. */
|
|
export const COMMAND_TIMEOUT_MS = 300_000;
|
|
/**
|
|
* Ceiling for one dependency fetch or install command inside a preparation
|
|
* container. Rails lockfiles that pin only the `ruby` platform compile native
|
|
* gems offline, which takes about 385 s at the app role's CPU share. Every
|
|
* other command keeps COMMAND_TIMEOUT_MS, and both stay inside the caller's
|
|
* aggregate deadline.
|
|
*/
|
|
export const PREPARATION_COMMAND_TIMEOUT_MS = 900_000;
|
|
export function commandTimeoutMs(
|
|
deadline: number,
|
|
phase: 'command' | 'preparation',
|
|
now = Date.now(),
|
|
): number {
|
|
return Math.max(
|
|
1,
|
|
Math.min(phase === 'preparation' ? PREPARATION_COMMAND_TIMEOUT_MS : COMMAND_TIMEOUT_MS, deadline - now),
|
|
);
|
|
}
|
|
/** Raw output is never printed or persisted, so a caller may opt in to more than MAX_OUTPUT (snapshot path listings). */
|
|
const RAW_OUTPUT_LIMIT = 16 * 1024 * 1024;
|
|
export async function runProcess(
|
|
file: string,
|
|
args: string[],
|
|
opts: {
|
|
cwd: string;
|
|
env: Record<string, string>;
|
|
timeoutMs?: number;
|
|
maxBytes?: number;
|
|
input?: string;
|
|
raw?: boolean; // Only for inert Git framing or private helper/Docker control JSON that is validated before use. Never print or persist raw results.
|
|
/** `splice` replaces each located sensitive span with a marker instead of withholding both channels. */
|
|
redaction?: 'withhold' | 'splice';
|
|
/** Allows PREPARATION_COMMAND_TIMEOUT_MS; only preparation dependency commands set it. */
|
|
preparationCommand?: true;
|
|
},
|
|
): Promise<ProcessResult> {
|
|
if (!isAbsolute(file) || !isAbsolute(opts.cwd) || !existsSync(opts.cwd))
|
|
throw new CsoError(
|
|
'INVALID_ARGUMENT',
|
|
'Children require absolute executables and an existing trusted working directory',
|
|
);
|
|
if (!args.every((a) => typeof a === 'string' && !a.includes('\0')))
|
|
throw new CsoError('INVALID_ARGUMENT', 'Invalid child argument');
|
|
const hardened = hardenGit(file, args);
|
|
args = hardened.args;
|
|
const cap = Math.min(opts.maxBytes ?? MAX_OUTPUT, opts.raw ? RAW_OUTPUT_LIMIT : MAX_OUTPUT);
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(file, args, {
|
|
cwd: opts.cwd,
|
|
env: opts.env,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
detached: process.platform !== 'win32',
|
|
});
|
|
const out: Buffer[] = [],
|
|
err: Buffer[] = [],
|
|
ordered: Buffer[] = [],
|
|
streams: Array<Buffer[]> = [];
|
|
let bytes = 0,
|
|
timedOut = false,
|
|
truncated = false;
|
|
const kill = () => {
|
|
try {
|
|
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
else child.kill('SIGKILL');
|
|
} catch {}
|
|
};
|
|
const timer = setTimeout(
|
|
() => {
|
|
timedOut = true;
|
|
kill();
|
|
},
|
|
Math.max(
|
|
1,
|
|
Math.min(
|
|
opts.timeoutMs ?? 30_000,
|
|
opts.preparationCommand ? PREPARATION_COMMAND_TIMEOUT_MS : COMMAND_TIMEOUT_MS,
|
|
),
|
|
),
|
|
);
|
|
const capture = (target: Buffer[]) => (chunk: Buffer) => {
|
|
bytes += chunk.length;
|
|
if (bytes > cap) {
|
|
truncated = true;
|
|
kill();
|
|
return;
|
|
}
|
|
target.push(chunk);
|
|
ordered.push(chunk);
|
|
streams.push(target);
|
|
};
|
|
child.stdout.on('data', capture(out));
|
|
child.stderr.on('data', capture(err));
|
|
child.on('error', () => {
|
|
clearTimeout(timer);
|
|
reject(new CsoError('TOOL_UNAVAILABLE', 'Trusted child process could not start'));
|
|
});
|
|
child.on('close', (code) => {
|
|
clearTimeout(timer);
|
|
try {
|
|
if (hardened.configs) assertGitConfigIdentities(hardened.configs);
|
|
// Never expose a truncated tail: it might be the beginning of a secret.
|
|
const stdout = truncated ? '[output withheld: size limit]' : Buffer.concat(out).toString('utf8');
|
|
const stderr = truncated ? '' : Buffer.concat(err).toString('utf8');
|
|
if (opts.raw) {
|
|
resolve({ code: code ?? -1, stdout, stderr, timedOut, truncated, capturedBytes: bytes });
|
|
return;
|
|
}
|
|
// A token may be split across stdout/stderr. Stream ordering is not
|
|
// recoverable here, so scan both concatenation orders and withhold both
|
|
// channels when either reveals a cross-stream sensitive span.
|
|
const forward = stdout + stderr,
|
|
reverse = stderr + stdout,
|
|
chronological = Buffer.concat(ordered).toString('utf8');
|
|
const spliced =
|
|
opts.redaction === 'splice' && !truncated ? spliceRedaction(ordered, streams, out) : null;
|
|
if (spliced) {
|
|
resolve({ code: code ?? -1, ...spliced, timedOut, truncated, capturedBytes: bytes });
|
|
return;
|
|
}
|
|
if ([stdout, stderr, forward, reverse, chronological].some((value) => redact(value) !== value)) {
|
|
resolve({
|
|
code: code ?? -1,
|
|
stdout: '[sensitive process output redacted]',
|
|
stderr: '',
|
|
timedOut,
|
|
truncated,
|
|
capturedBytes: bytes,
|
|
});
|
|
return;
|
|
}
|
|
resolve({ code: code ?? -1, stdout, stderr, timedOut, truncated, capturedBytes: bytes });
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
child.stdin.on('error', () => {});
|
|
child.stdin.end(opts.input);
|
|
});
|
|
}
|
|
export async function git(repo: string, args: string[], home: string, maxBytes?: number): Promise<string> {
|
|
const result = await runProcess(executable('git'), ['--no-optional-locks', '-C', repo, ...args], {
|
|
cwd: home,
|
|
env: childEnvironment(home),
|
|
raw: true,
|
|
timeoutMs: 15_000,
|
|
maxBytes,
|
|
});
|
|
if (result.code || result.timedOut || result.truncated) {
|
|
// Git stderr and argv can contain repository paths, refs, and configured
|
|
// content. Name only the fixed helper-owned operation and bounded process
|
|
// outcome so native failures are actionable without exposing either.
|
|
const knownOperations = new Set([
|
|
'rev-parse',
|
|
'symbolic-ref',
|
|
'ls-files',
|
|
'ls-tree',
|
|
'log',
|
|
'merge-base',
|
|
]),
|
|
operation = args.find((value) => knownOperations.has(value)) ?? 'metadata',
|
|
phase =
|
|
operation === 'rev-parse' && args.includes('--show-object-format')
|
|
? 'object-format'
|
|
: operation === 'rev-parse' && args.includes('--is-inside-work-tree')
|
|
? 'worktree-probe'
|
|
: operation,
|
|
reason = /not a git repository|outside repository/i.test(result.stderr)
|
|
? 'repository unavailable'
|
|
: /dubious ownership/i.test(result.stderr)
|
|
? 'repository ownership rejected'
|
|
: /(?:bad|invalid|unable to read).*config|config (?:error|file)/i.test(result.stderr)
|
|
? 'configuration rejected'
|
|
: /unknown option|unknown switch|unrecognized option|usage:/i.test(result.stderr)
|
|
? 'unsupported invocation'
|
|
: /(?:cannot|could not|unable to) (?:chdir|change directory)|no such file or directory/i.test(
|
|
result.stderr,
|
|
)
|
|
? 'path unavailable'
|
|
: 'request rejected',
|
|
outcome = result.timedOut
|
|
? 'timed out'
|
|
: result.truncated
|
|
? 'exceeded the output limit'
|
|
: `exited ${result.code}`;
|
|
throw new CsoError(
|
|
'MISSING_INPUT',
|
|
`Could not read bounded Git metadata: ${phase} ${outcome} (${reason}); source may not be a Git repository`,
|
|
);
|
|
}
|
|
return result.stdout;
|
|
}
|