Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/lab/ledger/purge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
closeSync,
existsSync,
fsyncSync,
lstatSync,
openSync,
readdirSync,
renameSync,
Expand Down Expand Up @@ -128,6 +129,13 @@ function deleteArtifactsFailClosed(dir: TrustedArtifactDir, digests: string[]):

function purgeBoundedDirectory(dirPath: string): void {
if (!existsSync(dirPath)) return;
const metadata = lstatSync(dirPath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pin the purge directory before deleting its contents

When another process can modify the Lab directory concurrently, this lstatSync() check is subject to a TOCTOU race: after validation, the real scratch or export directory can be renamed and replaced with a symlink before readdirSync(dirPath), causing the subsequent path-based rmSync() calls to delete children of the symlink target. Open the directory without following links, retain and verify its descriptor/identity, and perform enumeration and deletion relative to that pinned directory (as the artifact secure-fs code does) so replacing the pathname cannot redirect the purge.

Useful? React with 👍 / 👎.

if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new PurgeError(
"scratch_export_unsafe_directory",
`refusing to purge non-directory or symbolic-link path: ${dirPath}`,
);
}
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const full = join(dirPath, entry.name);
Expand Down
10 changes: 8 additions & 2 deletions src/lab/paths.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { chmodSync, mkdirSync, statSync } from "node:fs";
import { chmodSync, lstatSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config";

function ensureRestrictedDir(dir: string): void {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const metadata = lstatSync(dir);
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error(`lab state path must be a real directory: ${dir}`);
}
if (process.platform === "win32") return;
const mode = statSync(dir).mode & 0o777;
const mode = metadata.mode & 0o777;
if (mode !== 0o700) chmodSync(dir, 0o700);
}

Expand Down Expand Up @@ -54,6 +58,8 @@ export function ensureLabDirs(configDir = getConfigDir()): {
const exportDir = labExportDir(configDir);
ensureRestrictedDir(root);
ensureRestrictedDir(artifactsDir);
ensureRestrictedDir(scratchDir);
ensureRestrictedDir(exportDir);
return {
root,
ledgerPath: labLedgerPath(configDir),
Expand Down
35 changes: 34 additions & 1 deletion tests/lab-evidence-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,39 @@ describe("CL-02 phase-2 review regressions", () => {
});
});

test.each(["scratch", "export"] as const)(
"%s purge refuses a symbolic-link directory root",
(action) => {
withHome((home) => {
const authority = loadCaseAuthority();
const caseRecord = discoverScenarios(authority, ["responses-core"])[0]!;
const { event } = persistConformanceResult(
syntheticPassResult(caseRecord),
caseRecord,
authority,
{ configDir: home, recordedAt: 1000 },
);
const outside = join(home, `outside-${action}`);
const labDir = join(home, "lab", action);
mkdirSync(outside);
writeFileSync(join(outside, "keep.txt"), "keep");
rmSync(labDir, { recursive: true });
symlinkSync(outside, labDir, process.platform === "win32" ? "junction" : "dir");

expect(() =>
purgeSensitiveEvidence({
configDir: home,
targetEventIds: [event.eventId],
targetArtifactDigests: [],
purgeActions: [action],
recordedAt: 5000,
}),
).toThrow(/must be a real directory/);
expect(existsSync(join(outside, "keep.txt"))).toBe(true);
});
},
);

test("behavior fingerprint includes frozen runtime keys deterministically", () => {
const authority = loadCaseAuthority();
const caseRecord = discoverScenarios(authority, ["responses-core"])[0]!;
Expand All @@ -1158,4 +1191,4 @@ describe("CL-02 phase-2 review regressions", () => {
closeTrustedArtifactDir(dir);
});
});
});
});
Loading