115 lines
3.8 KiB
TypeScript
115 lines
3.8 KiB
TypeScript
import type { SecurityLocation } from "./types";
|
|
|
|
function canonicalize(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(canonicalize);
|
|
if (!value || typeof value !== "object") return value;
|
|
const record = value as Record<string, unknown>;
|
|
const result: Record<string, unknown> = {};
|
|
for (const key of Object.keys(record).sort()) {
|
|
const item = record[key];
|
|
if (item !== undefined) result[key] = canonicalize(item);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function canonicalSecurityJson(value: unknown): string {
|
|
return JSON.stringify(canonicalize(value));
|
|
}
|
|
|
|
export function securitySha256(value: string | Uint8Array): string {
|
|
return new Bun.CryptoHasher("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
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>> {
|
|
return locations
|
|
.map(location => ({
|
|
path: normalizeFingerprintPath(location.path),
|
|
startLine: location.startLine,
|
|
endLine: location.endLine,
|
|
startColumn: location.startColumn,
|
|
endColumn: location.endColumn,
|
|
role: location.role,
|
|
}))
|
|
.sort((left, right) => {
|
|
for (const key of NORMALIZED_LOCATION_SORT_KEYS) {
|
|
const comparison = compareNormalizedLocationValues(left[key], right[key]);
|
|
if (comparison !== 0) return comparison;
|
|
}
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
export interface SecurityFindingFingerprintInput {
|
|
ruleId: string;
|
|
category: string;
|
|
anchor?: string;
|
|
locations: readonly SecurityLocation[];
|
|
}
|
|
|
|
export function createSecurityFindingFingerprint(input: SecurityFindingFingerprintInput): string {
|
|
const digest = securitySha256(
|
|
canonicalSecurityJson({
|
|
ruleId: input.ruleId.trim().toLowerCase(),
|
|
category: input.category.trim().toLowerCase(),
|
|
anchor: input.anchor?.trim().toLowerCase() || undefined,
|
|
locations: normalizedLocations(input.locations),
|
|
}),
|
|
);
|
|
return `omp-security/v1:sha256:${digest}`;
|
|
}
|
|
|
|
export function createSecurityFindingId(fingerprint: string): string {
|
|
return `secf_${securitySha256(fingerprint).slice(0, 24)}`;
|
|
}
|
|
|
|
export function createSecurityOccurrenceId(fingerprint: string, locations: readonly SecurityLocation[]): string {
|
|
const material = canonicalSecurityJson({ fingerprint, locations: normalizedLocations(locations) });
|
|
return `seco_${securitySha256(material).slice(0, 24)}`;
|
|
}
|
|
|
|
export function createSecurityEvidenceId(fingerprint: string, label: string, ordinal: number): string {
|
|
return `sece_${securitySha256(canonicalSecurityJson({ fingerprint, label, ordinal })).slice(0, 24)}`;
|
|
}
|
|
|
|
export function createSecurityScanId(randomUuid: () => string = () => Bun.randomUUIDv7()): string {
|
|
return `secscan_${randomUuid().replaceAll("-", "")}`;
|
|
}
|
|
|
|
export function createSecurityPlanId(fingerprint: string): string {
|
|
return `secplan_${securitySha256(fingerprint).slice(0, 24)}`;
|
|
}
|
|
|
|
export function encodeSecurityProjectKey(repositoryRoot: string): string {
|
|
const normalized = repositoryRoot.replaceAll("\\", "/").replace(/\/$/, "");
|
|
const readable = normalized
|
|
.replace(/^\//, "")
|
|
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(-80);
|
|
return `${readable || "project"}-${securitySha256(normalized).slice(0, 12)}`;
|
|
}
|