fix(security): harden scan runtime boundaries
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -55,6 +55,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
unregisterCustomApis(MOCK_SOURCE_ID);
|
||||
settings.cancelPendingSaves();
|
||||
credentialStore?.close();
|
||||
@@ -124,7 +125,6 @@ describe("native security coordinator", () => {
|
||||
const terminal = await coordinator.wait(started.operationId);
|
||||
expect(terminal.phase).toBe("completed");
|
||||
expect(terminal.findingCount).toBe(1);
|
||||
expect(mock.calls.length).toBeGreaterThan(0);
|
||||
const bundle = await (await storeFactory()).getBundle(terminal.scanId);
|
||||
expect(bundle?.scan.status).toBe("completed");
|
||||
expect(bundle?.findings).toHaveLength(1);
|
||||
@@ -136,6 +136,35 @@ describe("native security coordinator", () => {
|
||||
expect(reopened.getSessionId()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("records a terminal failure when initial scan persistence fails", async () => {
|
||||
const mock = createMockModel({ id: "security-mock", provider: "openai-codex" });
|
||||
const store = await storeFactory();
|
||||
const coordinator = new SecurityCoordinator(
|
||||
{
|
||||
cwd: repositoryRoot,
|
||||
settings,
|
||||
authStorage,
|
||||
modelRegistry: new ModelRegistry(authStorage, path.join(temporaryRoot, "models.yml")),
|
||||
activeModel: mock.model,
|
||||
},
|
||||
{
|
||||
openStore: async () => store,
|
||||
gitAdapter,
|
||||
createSession: async () => {
|
||||
throw new Error("session must not launch when persistence fails");
|
||||
},
|
||||
},
|
||||
);
|
||||
const plan = await coordinator.preflight({ credentialId, model: mock.model });
|
||||
vi.spyOn(store, "putBundle").mockRejectedValue(new Error("security store unavailable"));
|
||||
const started = await coordinator.start({ planId: plan.id });
|
||||
await expect(coordinator.wait(started.operationId)).rejects.toThrow("security store unavailable");
|
||||
expect(coordinator.status(started.operationId)).toMatchObject({
|
||||
phase: "failed",
|
||||
error: "security store unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
test("cancellation before session launch has no inference side effects", async () => {
|
||||
let sessionCreations = 0;
|
||||
const mock = createMockModel({ id: "security-mock", provider: "openai-codex" });
|
||||
|
||||
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { importSarifFile, SecurityStore } from "../../src/security";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { exportSecurityBundleToSarif, importSarif, importSarifFile, SecurityStore } from "../../src/security";
|
||||
|
||||
const FIXTURE = path.join(import.meta.dir, "..", "fixtures", "security", "generic-results.sarif");
|
||||
let temporaryRoot = "";
|
||||
@@ -65,5 +66,50 @@ describe("security history and dispositions", () => {
|
||||
actor: "test-operator",
|
||||
});
|
||||
expect((await store.getFinding(bundle.scan.id, original.id))?.disposition).toEqual(updated.disposition);
|
||||
const persisted = await store.getBundle(bundle.scan.id);
|
||||
const persistedResult = (
|
||||
persisted?.sarif?.runs as Array<{ results: Array<{ properties?: Record<string, unknown> }> }> | undefined
|
||||
)?.[0]?.results[0];
|
||||
expect(persistedResult?.properties?.disposition).toBe("false_positive");
|
||||
});
|
||||
|
||||
test("SARIF disposition round-trips without changing its finding identity", async () => {
|
||||
const bundle = await importSarif(
|
||||
{
|
||||
version: "2.1.0",
|
||||
runs: [
|
||||
{
|
||||
tool: { driver: { name: "Fixture scanner" } },
|
||||
results: [
|
||||
{
|
||||
ruleId: "fixture.rule",
|
||||
message: { text: "fixture finding" },
|
||||
properties: { disposition: "false_positive" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ repositoryRoot, createScanId: () => "secscan_sarifdisposition" },
|
||||
);
|
||||
const finding = bundle.findings[0];
|
||||
if (!finding) throw new Error("expected imported finding");
|
||||
expect(finding.disposition.status).toBe("false_positive");
|
||||
const exported = exportSecurityBundleToSarif(bundle);
|
||||
const result = (exported.runs as Array<{ results: Array<{ properties?: Record<string, unknown> }> }>)[0]
|
||||
?.results[0];
|
||||
expect(result?.properties?.disposition).toBe("false_positive");
|
||||
expect(finding.id).toBe(bundle.scan.findingIds[0]);
|
||||
});
|
||||
|
||||
test("SARIF base URI escapes repository path characters", async () => {
|
||||
const specialRoot = path.join(temporaryRoot, "repo with #hash");
|
||||
await fs.mkdir(specialRoot);
|
||||
const bundle = await importSarif({ version: "2.1.0", runs: [] }, { repositoryRoot: specialRoot });
|
||||
const exported = exportSecurityBundleToSarif(bundle);
|
||||
const run = (exported.runs as Array<{ originalUriBaseIds: Record<string, { uri: string }> }>)[0];
|
||||
expect(run?.originalUriBaseIds["%SRCROOT%"]?.uri).toBe(
|
||||
pathToFileURL(`${await fs.realpath(specialRoot)}${path.sep}`).href,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,6 +214,19 @@ describe("security preflight", () => {
|
||||
await expect(plan()).rejects.toThrow("symbolic link");
|
||||
});
|
||||
|
||||
test("a root-dot scoped target includes repository descendants", async () => {
|
||||
const scoped = await plan({ kind: "scoped_path", includePaths: ["."] });
|
||||
const repository = await plan();
|
||||
expect(scoped.target.includePaths).toEqual(["."]);
|
||||
expect(scoped.target.treeDigest).toBe(repository.target.treeDigest);
|
||||
});
|
||||
|
||||
test("an empty scoped target is rejected before planning", async () => {
|
||||
await expect(plan({ kind: "scoped_path", includePaths: [] })).rejects.toThrow(
|
||||
"scoped_path security scans require at least one include path",
|
||||
);
|
||||
});
|
||||
|
||||
test("scope traversal is rejected", async () => {
|
||||
for (const candidate of ["../outside", "src/../outside", "C:\\outside", "src\\..\\outside"]) {
|
||||
await expect(plan({ kind: "scoped_path", includePaths: [candidate] })).rejects.toThrow("repository-relative");
|
||||
|
||||
Reference in New Issue
Block a user