Files
oh-my-pi/packages/coding-agent/src/security/store.ts
T
can1357 455493dfad feat: introduced native file lock bindings for cross-process advisory locking
- Added native `FileLock` bindings supporting cross-process advisory locking on Linux, Unix, and Windows.
- Replaced directory-based file locking and custom stale-lock reclamation with OS-backed native locks.
- Updated TypeScript declarations, native bindings, and package documentation for the new API.
- Added comprehensive unit tests and fixtures validating single-owner constraints and process death handoff.
2026-08-03 16:09:09 +02:00

431 lines
16 KiB
TypeScript

import * as fs from "node:fs/promises";
import * as path from "node:path";
import { getSecurityProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
import * as git from "../utils/git";
import { compareSecurityLineage } from "./comparison";
import type {
SecurityComparisonReport,
SecurityDisposition,
SecurityEvidence,
SecurityFinding,
SecurityScan,
SecurityScanBundle,
SecurityScanPlan,
SecurityValidation,
} from "./contracts";
import {
encodeSecurityProjectKey,
parseSecurityFinding,
parseSecurityScan,
parseSecurityScanBundle,
parseSecurityScanPlan,
} from "./contracts";
import { createPublicSecurityScan, redactPrivateSecurityMetadata } from "./provenance";
import { exportSecurityBundleToSarif } from "./sarif";
const STORE_SCHEMA_VERSION = 1;
const PRIVATE_DIRECTORY_MODE = 0o700;
const PRIVATE_FILE_MODE = 0o600;
/** Serialize project-store read/modify/write transactions within this process. */
const SECURITY_STORE_WRITE_CHAINS = new Map<string, Promise<unknown>>();
async function withSecurityStoreWrite<T>(key: string, operation: () => Promise<T>): Promise<T> {
const lockTarget = path.join(key, "index.json");
const run = (SECURITY_STORE_WRITE_CHAINS.get(key) ?? Promise.resolve()).then(() =>
withFileLock(lockTarget, operation, { retries: 200, retryDelayMs: 50 }),
);
const guarded = run.catch(() => undefined);
SECURITY_STORE_WRITE_CHAINS.set(key, guarded);
try {
return await run;
} finally {
if (SECURITY_STORE_WRITE_CHAINS.get(key) === guarded) SECURITY_STORE_WRITE_CHAINS.delete(key);
}
}
interface SecurityStoreIndex {
schemaVersion: 1;
projectKey: string;
repositoryRoot: string;
scanIds: string[];
planIds: string[];
updatedAt: string;
}
export interface SecurityScanSummary {
id: string;
status: SecurityScan["status"];
createdAt: string;
completedAt?: string;
producer: SecurityScan["producer"];
findingCount: number;
target: SecurityScan["target"];
}
export interface SecurityStoreOptions {
stateRoot?: string;
signal?: AbortSignal;
}
async function ensurePrivateDirectory(directory: string): Promise<void> {
await fs.mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
if (process.platform !== "win32") await fs.chmod(directory, PRIVATE_DIRECTORY_MODE);
}
export interface SecurityFileWriteOptions {
hardenParent?: boolean;
}
export async function writeSecurityFileAtomic(
filePath: string,
content: string,
options: SecurityFileWriteOptions = {},
): Promise<void> {
if (options.hardenParent ?? true) {
await ensurePrivateDirectory(path.dirname(filePath));
} else {
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
}
const temporaryPath = `${filePath}.${process.pid}.${Bun.randomUUIDv7()}.tmp`;
try {
// Bun.write cannot create exclusively; `wx` keeps concurrent atomic writers from sharing a temp file.
await fs.writeFile(temporaryPath, content, { encoding: "utf-8", mode: PRIVATE_FILE_MODE, flag: "wx" });
if (process.platform !== "win32") await fs.chmod(temporaryPath, PRIVATE_FILE_MODE);
try {
await fs.rename(temporaryPath, filePath);
} catch (error) {
const code = error instanceof Error && "code" in error ? String(error.code) : "";
if (process.platform !== "win32" || (code !== "EEXIST" && code !== "EPERM")) throw error;
await fs.rm(filePath, { force: true });
await fs.rename(temporaryPath, filePath);
}
} finally {
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
}
}
export async function writeSecurityBundleToDirectory(directory: string, input: SecurityScanBundle): Promise<void> {
const bundle = parseSecurityScanBundle(input);
const root = path.resolve(directory);
await ensurePrivateDirectory(root);
await writeSecurityFileAtomic(path.join(root, "findings.json"), `${JSON.stringify(bundle.findings, null, 2)}\n`);
if (bundle.report !== undefined) {
await writeSecurityFileAtomic(path.join(root, "report.md"), bundle.report);
} else {
await fs.rm(path.join(root, "report.md"), { force: true });
}
if (bundle.sarif !== undefined) {
await writeSecurityFileAtomic(path.join(root, "results.sarif"), `${JSON.stringify(bundle.sarif, null, 2)}\n`);
} else {
await fs.rm(path.join(root, "results.sarif"), { force: true });
}
await writeSecurityFileAtomic(
path.join(root, "provenance.json"),
`${JSON.stringify(redactPrivateSecurityMetadata(bundle.scan.provenance), null, 2)}\n`,
);
// The scan manifest is the commit marker for directory consumers.
await writeSecurityFileAtomic(
path.join(root, "scan.json"),
`${JSON.stringify(createPublicSecurityScan(bundle.scan), null, 2)}\n`,
);
}
async function readJsonFile(filePath: string): Promise<unknown> {
return JSON.parse(await Bun.file(filePath).text()) as unknown;
}
async function readOptionalText(filePath: string): Promise<string | undefined> {
try {
return await Bun.file(filePath).text();
} catch (error) {
if (isEnoent(error)) return undefined;
throw error;
}
}
export class SecurityStore {
readonly #repositoryRoot: string;
readonly #projectKey: string;
readonly #projectDirectory: string;
constructor(repositoryRoot: string, projectKey: string, projectDirectory: string) {
this.#repositoryRoot = repositoryRoot;
this.#projectKey = projectKey;
this.#projectDirectory = projectDirectory;
}
static async open(repositoryRoot: string, options: SecurityStoreOptions = {}): Promise<SecurityStore> {
const canonicalRoot = await fs.realpath(path.resolve(repositoryRoot)).catch(() => path.resolve(repositoryRoot));
const projectKey = encodeSecurityProjectKey(canonicalRoot);
const projectDirectory = options.stateRoot
? path.join(path.resolve(options.stateRoot), projectKey)
: getSecurityProjectDir(projectKey);
await ensurePrivateDirectory(projectDirectory);
const store = new SecurityStore(canonicalRoot, projectKey, projectDirectory);
await withSecurityStoreWrite(projectDirectory, () => store.#ensureIndex());
return store;
}
static async openForCwd(cwd: string, options: SecurityStoreOptions = {}): Promise<SecurityStore> {
const resolvedCwd = path.resolve(cwd);
const repositoryRoot = (await git.repo.root(resolvedCwd, options.signal)) ?? resolvedCwd;
return SecurityStore.open(repositoryRoot, options);
}
get repositoryRoot(): string {
return this.#repositoryRoot;
}
get projectKey(): string {
return this.#projectKey;
}
get projectDirectory(): string {
return this.#projectDirectory;
}
#scanDirectory(scanId: string): string {
if (!/^secscan_[a-zA-Z0-9]+$/.test(scanId)) throw new Error(`Invalid security scan id: ${scanId}`);
return path.join(this.#projectDirectory, "scans", scanId);
}
#planPath(planId: string): string {
if (!/^secplan_[a-zA-Z0-9]+$/.test(planId)) throw new Error(`Invalid security plan id: ${planId}`);
return path.join(this.#projectDirectory, "plans", `${planId}.json`);
}
#indexPath(): string {
return path.join(this.#projectDirectory, "index.json");
}
async #ensureIndex(): Promise<void> {
try {
await this.#readIndex();
} catch (error) {
if (!isEnoent(error)) throw error;
await this.#writeIndex({
schemaVersion: STORE_SCHEMA_VERSION,
projectKey: this.#projectKey,
repositoryRoot: this.#repositoryRoot,
scanIds: [],
planIds: [],
updatedAt: new Date().toISOString(),
});
}
}
async #readIndex(): Promise<SecurityStoreIndex> {
const value = (await readJsonFile(this.#indexPath())) as Partial<SecurityStoreIndex>;
if (value.schemaVersion !== STORE_SCHEMA_VERSION || value.projectKey !== this.#projectKey) {
throw new Error(`Unsupported security store index at ${this.#indexPath()}`);
}
if (!Array.isArray(value.scanIds) || !value.scanIds.every(id => typeof id === "string")) {
throw new Error(`Invalid security store scan index at ${this.#indexPath()}`);
}
if (
value.planIds !== undefined &&
(!Array.isArray(value.planIds) || !value.planIds.every(id => typeof id === "string"))
) {
throw new Error(`Invalid security store plan index at ${this.#indexPath()}`);
}
return { ...value, planIds: value.planIds ?? [] } as SecurityStoreIndex;
}
async #writeIndex(index: SecurityStoreIndex): Promise<void> {
await writeSecurityFileAtomic(this.#indexPath(), `${JSON.stringify(index, null, 2)}\n`);
}
async #putBundleUnlocked(input: SecurityScanBundle): Promise<void> {
const bundle = parseSecurityScanBundle(input);
if (bundle.scan.projectKey !== this.#projectKey) {
throw new Error(`Security scan project key ${bundle.scan.projectKey} does not match ${this.#projectKey}`);
}
const scanDirectory = this.#scanDirectory(bundle.scan.id);
await ensurePrivateDirectory(scanDirectory);
await writeSecurityFileAtomic(
path.join(scanDirectory, "findings.json"),
`${JSON.stringify(bundle.findings, null, 2)}\n`,
);
if (bundle.report !== undefined) {
await writeSecurityFileAtomic(path.join(scanDirectory, "report.md"), bundle.report);
} else {
await fs.rm(path.join(scanDirectory, "report.md"), { force: true });
}
if (bundle.sarif !== undefined) {
await writeSecurityFileAtomic(
path.join(scanDirectory, "results.sarif"),
`${JSON.stringify(bundle.sarif, null, 2)}\n`,
);
} else {
await fs.rm(path.join(scanDirectory, "results.sarif"), { force: true });
}
// The scan manifest is the commit marker: readers never observe it before
// its findings and optional artifacts have been written atomically.
await writeSecurityFileAtomic(path.join(scanDirectory, "scan.json"), `${JSON.stringify(bundle.scan, null, 2)}\n`);
const index = await this.#readIndex();
if (!index.scanIds.includes(bundle.scan.id)) index.scanIds.push(bundle.scan.id);
index.updatedAt = new Date().toISOString();
await this.#writeIndex(index);
}
async putBundle(input: SecurityScanBundle): Promise<void> {
await withSecurityStoreWrite(this.#projectDirectory, () => this.#putBundleUnlocked(input));
}
async putPlan(input: SecurityScanPlan): Promise<void> {
await withSecurityStoreWrite(this.#projectDirectory, async () => {
const plan = parseSecurityScanPlan(input);
if (plan.repositoryRoot !== this.#repositoryRoot) {
throw new Error(`Security plan repository ${plan.repositoryRoot} does not match ${this.#repositoryRoot}`);
}
await writeSecurityFileAtomic(this.#planPath(plan.id), `${JSON.stringify(plan, null, 2)}\n`);
const index = await this.#readIndex();
if (!index.planIds.includes(plan.id)) index.planIds.push(plan.id);
index.updatedAt = new Date().toISOString();
await this.#writeIndex(index);
});
}
async getPlan(planId: string): Promise<SecurityScanPlan | null> {
try {
return parseSecurityScanPlan(await readJsonFile(this.#planPath(planId)));
} catch (error) {
if (isEnoent(error)) return null;
throw error;
}
}
async listPlans(): Promise<SecurityScanPlan[]> {
const index = await this.#readIndex();
const plans: SecurityScanPlan[] = [];
for (const planId of [...index.planIds].reverse()) {
const plan = await this.getPlan(planId);
if (plan) plans.push(plan);
}
return plans;
}
async getScan(scanId: string): Promise<SecurityScan | null> {
try {
return parseSecurityScan(await readJsonFile(path.join(this.#scanDirectory(scanId), "scan.json")));
} catch (error) {
if (isEnoent(error)) return null;
throw error;
}
}
async #getBundleUnlocked(scanId: string): Promise<SecurityScanBundle | null> {
const scan = await this.getScan(scanId);
if (!scan) return null;
const rawFindings = await readJsonFile(path.join(this.#scanDirectory(scanId), "findings.json"));
if (!Array.isArray(rawFindings)) throw new Error(`Invalid findings list for ${scanId}`);
const findings = rawFindings.map(parseSecurityFinding);
const report = await readOptionalText(path.join(this.#scanDirectory(scanId), "report.md"));
const sarifText = await readOptionalText(path.join(this.#scanDirectory(scanId), "results.sarif"));
const bundle: SecurityScanBundle = { scan, findings };
if (report !== undefined) bundle.report = report;
if (sarifText !== undefined) bundle.sarif = JSON.parse(sarifText) as Record<string, unknown>;
return parseSecurityScanBundle(bundle);
}
async getBundle(scanId: string): Promise<SecurityScanBundle | null> {
return withSecurityStoreWrite(this.#projectDirectory, () => this.#getBundleUnlocked(scanId));
}
async listScans(): Promise<SecurityScanSummary[]> {
const index = await this.#readIndex();
const summaries: SecurityScanSummary[] = [];
for (const scanId of [...index.scanIds].reverse()) {
const bundle = await this.getBundle(scanId);
if (!bundle) continue;
summaries.push({
id: bundle.scan.id,
status: bundle.scan.status,
createdAt: bundle.scan.createdAt,
completedAt: bundle.scan.completedAt,
producer: bundle.scan.producer,
findingCount: bundle.findings.length,
target: bundle.scan.target,
});
}
return summaries;
}
async getFinding(scanId: string, findingId: string): Promise<SecurityFinding | null> {
const bundle = await this.getBundle(scanId);
return bundle?.findings.find(finding => finding.id === findingId) ?? null;
}
async updateDisposition(
scanId: string,
findingId: string,
disposition: SecurityDisposition,
): Promise<SecurityFinding> {
return withSecurityStoreWrite(this.#projectDirectory, async () => {
const bundle = await this.#getBundleUnlocked(scanId);
if (!bundle) throw new Error(`Unknown security scan: ${scanId}`);
const index = bundle.findings.findIndex(finding => finding.id === findingId);
if (index < 0) throw new Error(`Unknown security finding: ${findingId}`);
const canonicalDisposition: SecurityDisposition = { status: disposition.status };
if (disposition.rationale !== undefined) canonicalDisposition.rationale = disposition.rationale;
if (disposition.updatedAt !== undefined) canonicalDisposition.updatedAt = disposition.updatedAt;
if (disposition.actor !== undefined) canonicalDisposition.actor = disposition.actor;
const updated = { ...bundle.findings[index], disposition: canonicalDisposition };
bundle.findings[index] = parseSecurityFinding(updated);
if (bundle.sarif !== undefined) bundle.sarif = exportSecurityBundleToSarif(bundle);
await this.#putBundleUnlocked(bundle);
return bundle.findings[index];
});
}
async updateValidation(
scanId: string,
findingId: string,
validation: SecurityValidation,
evidence: readonly SecurityEvidence[] = [],
): Promise<SecurityFinding> {
return withSecurityStoreWrite(this.#projectDirectory, async () => {
const bundle = await this.#getBundleUnlocked(scanId);
if (!bundle) throw new Error(`Unknown security scan: ${scanId}`);
const index = bundle.findings.findIndex(finding => finding.id === findingId);
if (index < 0) throw new Error(`Unknown security finding: ${findingId}`);
const finding = bundle.findings[index];
const evidenceById = new Map(finding.evidence.map(item => [item.id, item]));
for (const item of evidence) evidenceById.set(item.id, item);
const canonicalValidation: SecurityValidation = {
status: validation.status,
evidenceIds: [...new Set(validation.evidenceIds)],
};
if (validation.summary !== undefined) canonicalValidation.summary = validation.summary;
if (validation.validatedAt !== undefined) canonicalValidation.validatedAt = validation.validatedAt;
for (const evidenceId of canonicalValidation.evidenceIds) {
if (!evidenceById.has(evidenceId)) {
throw new Error(`Unknown security validation evidence: ${evidenceId}`);
}
}
bundle.findings[index] = parseSecurityFinding({
...finding,
evidence: [...evidenceById.values()],
validation: canonicalValidation,
});
if (bundle.sarif !== undefined) bundle.sarif = exportSecurityBundleToSarif(bundle);
await this.#putBundleUnlocked(bundle);
return bundle.findings[index];
});
}
async compare(beforeScanId: string, afterScanId: string): Promise<SecurityComparisonReport> {
const before = await this.getBundle(beforeScanId);
const after = await this.getBundle(afterScanId);
if (!before) throw new Error(`Unknown security scan: ${beforeScanId}`);
if (!after) throw new Error(`Unknown security scan: ${afterScanId}`);
return compareSecurityLineage(before, after);
}
async storeDigest(): Promise<string> {
const index = await this.#readIndex();
return Bun.SHA256.hash(JSON.stringify(index), "hex");
}
}