fix(security): stabilize imported finding identities

This commit is contained in:
Kyle McCleary
2026-07-29 18:47:51 -07:00
parent b708b39915
commit 80bdfdcbd4
10 changed files with 266 additions and 27 deletions
@@ -1,10 +1,17 @@
#!/usr/bin/env bun
import * as path from "node:path";
import { compareSecurityProducers, parseSecurityScanBundle } from "../src/security";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { compareSecurityProducers, importCodexSecurityBundle, parseSecurityScanBundle } from "../src/security";
async function readBundle(directory: string) {
const root = path.resolve(directory);
const scan = JSON.parse(await Bun.file(path.join(root, "scan.json")).text()) as unknown;
let scan: unknown;
try {
scan = JSON.parse(await Bun.file(path.join(root, "scan.json")).text()) as unknown;
} catch (error) {
if (!isEnoent(error)) throw error;
return importCodexSecurityBundle(root, { repositoryRoot: root });
}
const findings = JSON.parse(await Bun.file(path.join(root, "findings.json")).text()) as unknown;
const report = await Bun.file(path.join(root, "report.md"))
.text()
+20 -7
View File
@@ -8,9 +8,24 @@ export interface ExactSecurityOAuthOptions {
account: SecurityAccountRef;
}
function assertIdentityMatches(account: SecurityAccountRef, resolvedAccountId: string | undefined): void {
if (account.accountId !== undefined && account.accountId !== resolvedAccountId) {
throw new Error("Security scan account mismatch: the pinned workspace identity changed during authentication");
function assertIdentityMatches(
account: SecurityAccountRef,
resolution: {
credentialId?: number;
accountId?: string;
email?: string;
orgId?: string;
orgName?: string;
},
): void {
if (
account.credentialId !== resolution.credentialId ||
(account.accountId !== undefined && account.accountId !== resolution.accountId) ||
(account.email !== undefined && account.email !== resolution.email) ||
(account.organizationId !== undefined && account.organizationId !== resolution.orgId) ||
(account.organizationName !== undefined && account.organizationName !== resolution.orgName)
) {
throw new Error("Security scan authentication identity mismatch");
}
}
@@ -27,9 +42,7 @@ export function createExactSecurityOAuthResolver(
const { account, authStorage } = options;
return model => {
if (model.provider !== account.provider) {
throw new Error(
`Security scan model provider ${model.provider} does not match pinned account provider ${account.provider}`,
);
throw new Error("Security scan authentication provider mismatch");
}
const resolver: ApiKeyResolver = async context => {
if (context.lastChance) return undefined;
@@ -40,10 +53,10 @@ export function createExactSecurityOAuthResolver(
if (!resolution) {
throw new Error("The pinned security OAuth credential is unavailable");
}
assertIdentityMatches(account, resolution);
if (!resolution.ok) {
throw new Error("The pinned security OAuth credential could not be resolved");
}
assertIdentityMatches(account, resolution.accountId);
return resolution.accessToken;
};
return resolver;
@@ -24,6 +24,24 @@ function normalizeFingerprintPath(value: string): string {
return value.replaceAll("\\", "/").replace(/^\.\//, "");
}
function compareNormalizedLocationValues(
left: string | number | undefined,
right: string | number | undefined,
): number {
if (left === right) return 0;
if (left === undefined) return -1;
if (right === undefined) return 1;
if (typeof left === "number" && typeof right === "number") {
const leftNaN = Number.isNaN(left);
const rightNaN = Number.isNaN(right);
if (leftNaN || rightNaN) return leftNaN ? (rightNaN ? 0 : -1) : 1;
return left - right;
}
return String(left) < String(right) ? -1 : 1;
}
const NORMALIZED_LOCATION_SORT_KEYS = ["path", "startLine", "endLine", "startColumn", "endColumn", "role"] as const;
function normalizedLocations(
locations: readonly SecurityLocation[],
): Array<Record<string, string | number | undefined>> {
@@ -37,9 +55,11 @@ function normalizedLocations(
role: location.role,
}))
.sort((left, right) => {
const byPath = String(left.path).localeCompare(String(right.path));
if (byPath !== 0) return byPath;
return Number(left.startLine) - Number(right.startLine);
for (const key of NORMALIZED_LOCATION_SORT_KEYS) {
const comparison = compareNormalizedLocationValues(left[key], right[key]);
if (comparison !== 0) return comparison;
}
return 0;
});
}
@@ -123,6 +123,22 @@ function locationsForFinding(finding: CodexFinding): SecurityLocation[] {
}
return locations.length > 0 ? locations : [{ path: "unknown", startLine: 1, role: "unknown" }];
}
const canonicalValidationStatuses: Record<string, true> = {
unvalidated: true,
validated: true,
rejected: true,
partial: true,
error: true,
};
function validationStatus(
validation: Record<string, unknown> | null | undefined,
): SecurityFinding["validation"]["status"] {
const status = validation?.status;
return typeof status === "string" && canonicalValidationStatuses[status] === true
? (status as SecurityFinding["validation"]["status"])
: "unvalidated";
}
function mapCoverage(document: CodexCoverageDocument): SecurityCoverage {
const allowedModes = new Set(["repository", "scoped_path", "diff", "working_tree", "deep_repository"]);
@@ -205,11 +221,18 @@ export async function importCodexSecurityBundle(
for (const source of findingsDocument.findings ?? []) {
const ruleId = source.ruleId || "codex-security.unknown";
const category = source.taxonomy?.category || ruleId.split(/[./-]/)[0] || "security";
const hasSourceLocations = (source.locations ?? []).some(
location => typeof location.path === "string" && typeof location.startLine === "number",
);
const locations = locationsForFinding(source);
const anchor =
source.identity?.anchor ||
source.fingerprints?.primary ||
(!hasSourceLocations ? source.findingId : undefined);
const fingerprint = createSecurityFindingFingerprint({
ruleId,
category,
anchor: source.identity?.anchor,
anchor,
locations,
});
const evidence: SecurityEvidence[] = (source.codeEvidence ?? []).map((item, index) => {
@@ -272,13 +295,13 @@ export async function importCodexSecurityBundle(
],
evidence,
validation: {
status: source.validation ? "validated" : "unvalidated",
status: validationStatus(source.validation),
evidenceIds: [],
},
disposition: { status: "open" },
provenance,
};
if (source.identity?.anchor !== undefined) finding.anchor = source.identity.anchor;
if (anchor !== undefined) finding.anchor = anchor;
if (source.severity?.score !== undefined) finding.severity.score = source.severity.score;
if (source.severity?.scoringSystem !== undefined) finding.severity.scoringSystem = source.severity.scoringSystem;
if (source.severity?.vector !== undefined) finding.severity.vector = source.severity.vector;
@@ -10,6 +10,7 @@ import type {
SecuritySeverityLevel,
} from "../contracts";
import {
canonicalSecurityJson,
createSecurityFindingFingerprint,
createSecurityFindingId,
createSecurityOccurrenceId,
@@ -119,6 +120,57 @@ function tagsForRule(rule: SarifRule | undefined): string[] {
const tags = rule?.properties?.tags;
return Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : [];
}
function selectFirstVendorFingerprint(vendorFingerprints: Record<string, string>): string | undefined {
for (const [, value] of Object.entries(vendorFingerprints).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
)) {
if (value) return value;
}
return undefined;
}
function semanticResultAnchor(
ruleId: string,
category: string,
message: string,
locations: readonly SecurityLocation[],
): string {
return `sarif-result/v1:sha256:${securitySha256(
canonicalSecurityJson({
ruleId,
category,
message,
locations,
}),
)}`;
}
const canonicalValidationStatuses: Record<string, true> = {
unvalidated: true,
validated: true,
rejected: true,
partial: true,
error: true,
};
const canonicalDispositionStatuses: Record<string, true> = {
open: true,
false_positive: true,
accepted_risk: true,
fixed: true,
wont_fix: true,
};
function importedValidationStatus(value: unknown): SecurityFinding["validation"]["status"] {
return typeof value === "string" && canonicalValidationStatuses[value] === true
? (value as SecurityFinding["validation"]["status"])
: "unvalidated";
}
function importedDispositionStatus(value: unknown): SecurityFinding["disposition"]["status"] {
return typeof value === "string" && canonicalDispositionStatuses[value] === true
? (value as SecurityFinding["disposition"]["status"])
: "open";
}
export async function importSarif(input: unknown, options: SarifImportOptions): Promise<SecurityScanBundle> {
const sarif = input as SarifLog;
@@ -129,6 +181,7 @@ export async function importSarif(input: unknown, options: SarifImportOptions):
const scanId = options.createScanId?.() ?? createSecurityScanId();
const createdAt = options.createdAt ?? new Date().toISOString();
const findings: SecurityFinding[] = [];
const seenFingerprints = new Set<string>();
let producerName = "SARIF importer";
let producerVersion: string | undefined;
@@ -145,17 +198,21 @@ export async function importSarif(input: unknown, options: SarifImportOptions):
...stringRecord(result.fingerprints),
...stringRecord(result.partialFingerprints),
};
const firstVendorFingerprint = Object.values(vendorFingerprints)[0];
const firstVendorFingerprint = selectFirstVendorFingerprint(vendorFingerprints);
const category =
typeof result.properties?.category === "string"
? result.properties.category
: ruleId.split(/[./-]/)[0] || "security";
const message = result.message?.text ?? result.message?.markdown ?? rule?.shortDescription?.text ?? ruleId;
const anchor = firstVendorFingerprint ?? semanticResultAnchor(ruleId, category, message, locations);
const fingerprint = createSecurityFindingFingerprint({
ruleId,
category,
anchor: firstVendorFingerprint,
anchor,
locations,
});
if (seenFingerprints.has(fingerprint)) continue;
seenFingerprints.add(fingerprint);
const tags = tagsForRule(rule);
const provenance: SecurityProvenance = {
producer: { kind: "sarif-import", name: producerName },
@@ -165,7 +222,6 @@ export async function importSarif(input: unknown, options: SarifImportOptions):
};
if (producerVersion !== undefined) provenance.producer.version = producerVersion;
if (options.sourcePath) provenance.metadata = { sourcePath: options.sourcePath };
const message = result.message?.text ?? result.message?.markdown ?? rule?.shortDescription?.text ?? ruleId;
const finding: SecurityFinding = {
id: createSecurityFindingId(fingerprint),
scanId,
@@ -182,11 +238,11 @@ export async function importSarif(input: unknown, options: SarifImportOptions):
},
occurrences: [{ id: createSecurityOccurrenceId(fingerprint, locations), locations, evidenceIds: [] }],
evidence: [],
validation: { status: "unvalidated", evidenceIds: [] },
disposition: { status: "open" },
validation: { status: importedValidationStatus(result.properties?.validation), evidenceIds: [] },
disposition: { status: importedDispositionStatus(result.properties?.disposition) },
provenance,
};
if (firstVendorFingerprint !== undefined) finding.anchor = firstVendorFingerprint;
finding.anchor = anchor;
const score = Number(result.properties?.["security-severity"]);
if (Number.isFinite(score)) finding.severity.score = score;
findings.push(finding);
+2 -1
View File
@@ -1,3 +1,4 @@
import { pathToFileURL } from "node:url";
import type { SecurityFinding, SecurityScanBundle } from "./contracts";
function sarifLevel(finding: SecurityFinding): "error" | "warning" | "note" | "none" {
@@ -69,7 +70,7 @@ export function exportSecurityBundleToSarif(bundle: SecurityScanBundle): Record<
},
})),
originalUriBaseIds: {
"%SRCROOT%": { uri: `file://${bundle.scan.target.repositoryRoot.replaceAll("\\", "/")}/` },
"%SRCROOT%": { uri: pathToFileURL(bundle.scan.target.repositoryRoot).href.replace(/\/?$/, "/") },
},
},
],
@@ -32,6 +32,60 @@ describe("exact security OAuth resolver", () => {
expect(getOAuthAccessByCredentialId.mock.calls.map(call => call[1])).toEqual([42, 42]);
});
test("rejects a model whose provider crosses the pinned OAuth boundary", async () => {
const getOAuthAccessByCredentialId = vi.fn(async () => ({
ok: true as const,
accessToken: "must-not-be-requested",
credentialId: 42,
accountId: "workspace-a",
}));
const authStorage = { getOAuthAccessByCredentialId } as unknown as AuthStorage;
const resolver = createExactSecurityOAuthResolver({
authStorage,
account: { provider: "openai-codex", credentialId: 42, accountId: "workspace-a" },
});
const wrongProviderModel = { ...model(), provider: "anthropic" } as unknown as Parameters<typeof resolver>[0];
expect(() => resolver(wrongProviderModel)).toThrow("provider mismatch");
expect(getOAuthAccessByCredentialId).not.toHaveBeenCalled();
});
test("fails closed when any durable account identity changes", async () => {
const account = {
provider: "openai-codex",
credentialId: 42,
accountId: "workspace-a",
email: "owner@example.com",
organizationId: "org-a",
organizationName: "Workspace A",
};
const resolved = {
credentialId: 42,
accountId: "workspace-a",
email: "owner@example.com",
orgId: "org-a",
orgName: "Workspace A",
};
for (const mismatch of [
{ credentialId: 99 },
{ accountId: "workspace-b" },
{ email: "other@example.com" },
{ orgId: "org-b" },
{ orgName: "Workspace B" },
]) {
const authStorage = {
getOAuthAccessByCredentialId: async () => ({
ok: true as const,
accessToken: "token",
...resolved,
...mismatch,
}),
} as unknown as AuthStorage;
const resolver = createExactSecurityOAuthResolver({ authStorage, account });
const exact = resolver(model()) as ApiKeyResolver;
await expect(exact({ lastChance: false, error: undefined })).rejects.toThrow("identity mismatch");
}
});
test("fails closed when the refreshed row loses its workspace identity", async () => {
const authStorage = {
getOAuthAccessByCredentialId: async () => ({
@@ -53,8 +107,8 @@ describe("exact security OAuth resolver", () => {
caught = error;
}
expect(caught).toBeInstanceOf(Error);
if (!(caught instanceof Error)) throw new Error("expected account mismatch");
expect(caught.message).toContain("account mismatch");
if (!(caught instanceof Error)) throw new Error("expected identity mismatch");
expect(caught.message).toContain("identity mismatch");
expect(caught.message).not.toContain("workspace-a");
expect(caught.message).not.toContain("undefined");
});
@@ -66,6 +66,29 @@ describe("security contracts", () => {
expect(createSecurityFindingId(first)).toBe(createSecurityFindingId(second));
});
test("finding fingerprints are stable across every location ordering", () => {
const locations = [
{ path: "src/entry.ts", startLine: 4, endLine: 8, startColumn: 2, endColumn: 4, role: "source" },
{ path: "src/entry.ts", startLine: 4, endLine: 8, startColumn: 2, endColumn: 4, role: "sink" },
{ path: "src/entry.ts", startLine: 4, endLine: 9, startColumn: 1, endColumn: 3, role: "propagation" },
{ path: "src/entry.ts", startLine: 4, endLine: 10, startColumn: 1, endColumn: 3, role: "source" },
] as const;
const baseline = createSecurityFindingFingerprint({
ruleId: "fixture.rule",
category: "fixture",
locations,
});
for (const ordered of [locations.toReversed(), [locations[2], locations[0], locations[3], locations[1]]]) {
expect(
createSecurityFindingFingerprint({
ruleId: "fixture.rule",
category: "fixture",
locations: ordered,
}),
).toBe(baseline);
}
});
test("scan IDs remain OMP-owned", () => {
expect(createSecurityScanId(() => "018f0000-0000-7000-8000-000000000001")).toBe(
"secscan_018f0000000070008000000000000001",
@@ -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 { importCodexSecurityBundle, importSarifFile, SecurityStore } from "../../src/security";
import { $ } from "bun";
import { importCodexSecurityBundle, importSarif, importSarifFile, SecurityStore } from "../../src/security";
const FIXTURE_ROOT = path.join(import.meta.dir, "..", "fixtures", "security");
let temporaryRoot = "";
@@ -41,6 +42,37 @@ describe("security importers and store", () => {
expect(sarif.scan.producer.kind).toBe("sarif-import");
});
test("resolves one canonical store for a nested repository cwd", async () => {
const nestedCwd = path.join(repositoryRoot, "packages", "app");
await fs.mkdir(nestedCwd, { recursive: true });
const initialized = await $`git init --initial-branch=main`.cwd(repositoryRoot).quiet().nothrow();
if (initialized.exitCode !== 0) throw new Error("git init failed");
const store = await SecurityStore.openForCwd(nestedCwd, { stateRoot: path.join(temporaryRoot, "state") });
expect(store.repositoryRoot).toBe(await fs.realpath(repositoryRoot));
});
test("locationless SARIF keeps distinct results while deduplicating repeats", async () => {
const input = {
version: "2.1.0",
runs: [
{
tool: { driver: { name: "Fixture scanner" } },
results: [
{ ruleId: "fixture.rule", message: { text: "first result" } },
{ ruleId: "fixture.rule", message: { text: "second result" } },
{ ruleId: "fixture.rule", message: { text: "second result" } },
],
},
],
};
const bundle = await importSarif(input, {
repositoryRoot,
createScanId: () => "secscan_locationless",
});
expect(bundle.findings.map(finding => finding.summary)).toEqual(["first result", "second result"]);
expect(new Set(bundle.findings.map(finding => finding.id)).size).toBe(2);
});
test("serializes concurrent index updates without losing scans", async () => {
const store = await SecurityStore.open(repositoryRoot, { stateRoot: path.join(temporaryRoot, "state") });
const bundles = await Promise.all(
@@ -15,9 +15,19 @@ describe("security seeded validation repository", () => {
expect(manifest.schemaVersion).toBe(1);
expect(manifest.nonProduction).toBeTrue();
expect(manifest.seeds).toHaveLength(8);
const expectedClasses: Record<string, string> = {
"command-injection": "command-injection",
"path-traversal": "path-traversal",
"sql-injection": "sql-injection",
ssrf: "ssrf",
"authorization-bypass": "authorization",
"unsafe-deserialization": "unsafe-deserialization",
"fake-secret": "hard-coded-secret",
"safe-lookalike": "path-traversal",
};
for (const seed of manifest.seeds) {
expect(seed.id.length).toBeGreaterThan(0);
expect(seed.expectedClass.length).toBeGreaterThan(0);
expect(seed.id in expectedClasses).toBeTrue();
expect(expectedClasses[seed.id]).toBe(seed.expectedClass);
expect(["finding", "no-finding"]).toContain(seed.expectedDisposition);
expect(await Bun.file(path.join(ROOT, seed.path)).exists()).toBeTrue();
}