125 lines
5.1 KiB
TypeScript
125 lines
5.1 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { and, eq, or, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { heartbeatRuns } from "@paperclipai/db";
|
|
import { REDACTED_EVENT_VALUE } from "../redaction.js";
|
|
import { getSecretProvider } from "../secrets/provider-registry.js";
|
|
import type { StoredSecretVersionMaterial } from "../secrets/types.js";
|
|
|
|
const REGISTRY_KEY = "paperclipSecretRedactions";
|
|
|
|
type RegistryEntry = {
|
|
fingerprintSha256: string;
|
|
material: StoredSecretVersionMaterial;
|
|
};
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function registryEntries(contextSnapshot: unknown): RegistryEntry[] {
|
|
const context = asRecord(contextSnapshot);
|
|
const raw = context?.[REGISTRY_KEY];
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw.flatMap((value) => {
|
|
const entry = asRecord(value);
|
|
const material = asRecord(entry?.material);
|
|
return typeof entry?.fingerprintSha256 === "string" && material
|
|
? [{ fingerprintSha256: entry.fingerprintSha256, material }]
|
|
: [];
|
|
});
|
|
}
|
|
|
|
function redactText(input: string, values: string[]) {
|
|
return values.reduce(
|
|
(result, value) => value.length > 0 ? result.split(value).join(REDACTED_EVENT_VALUE) : result,
|
|
input,
|
|
);
|
|
}
|
|
|
|
export function redactRegisteredSecretValues<T>(input: T, values: string[]): T {
|
|
if (typeof input === "string") return redactText(input, values) as T;
|
|
if (Array.isArray(input)) return input.map((value) => redactRegisteredSecretValues(value, values)) as T;
|
|
// Dates carry no redactable text; rebuilding them via Object.entries would
|
|
// collapse them to `{}` and break every timestamp in redacted responses.
|
|
if (input instanceof Date) return input;
|
|
const record = asRecord(input);
|
|
if (!record) return input;
|
|
return Object.fromEntries(
|
|
Object.entries(record)
|
|
.filter(([key]) => key !== REGISTRY_KEY)
|
|
.map(([key, value]) => [key, redactRegisteredSecretValues(value, values)]),
|
|
) as T;
|
|
}
|
|
|
|
export function createRunSecretRedactionRegistry(db: Db) {
|
|
const provider = getSecretProvider("local_encrypted");
|
|
|
|
async function valuesForRuns(rows: Array<{ contextSnapshot: unknown }>) {
|
|
const entries = rows.flatMap((row) => registryEntries(row.contextSnapshot));
|
|
const unique = new Map(entries.map((entry) => [entry.fingerprintSha256, entry]));
|
|
const values = await Promise.all(
|
|
[...unique.values()].map((entry) => provider.resolveVersion({
|
|
material: entry.material,
|
|
externalRef: null,
|
|
})),
|
|
);
|
|
return values.sort((left, right) => right.length - left.length);
|
|
}
|
|
|
|
async function valuesForRun(companyId: string, runId: string) {
|
|
const rows = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
|
.from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
|
return valuesForRuns(rows);
|
|
}
|
|
|
|
async function valuesForIssue(companyId: string, issueId: string) {
|
|
const rows = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
|
.from(heartbeatRuns)
|
|
.where(and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
or(
|
|
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
|
|
sql`${heartbeatRuns.contextSnapshot} -> 'paperclipIssue' ->> 'id' = ${issueId}`,
|
|
),
|
|
));
|
|
return valuesForRuns(rows);
|
|
}
|
|
|
|
return {
|
|
register: async (companyId: string, runId: string, value: string) => {
|
|
const fingerprintSha256 = createHash("sha256").update(value).digest("hex");
|
|
await db.transaction(async (tx) => {
|
|
const row = await tx.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
|
.from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) throw new Error("Heartbeat run redaction registration failed");
|
|
if (registryEntries(row.contextSnapshot).some((entry) => entry.fingerprintSha256 === fingerprintSha256)) {
|
|
return;
|
|
}
|
|
const prepared = await provider.createSecret({ value });
|
|
const entry: RegistryEntry = { fingerprintSha256, material: prepared.material };
|
|
const contextSnapshot = asRecord(row.contextSnapshot) ?? {};
|
|
const currentEntries = Array.isArray(contextSnapshot[REGISTRY_KEY])
|
|
? contextSnapshot[REGISTRY_KEY]
|
|
: [];
|
|
await tx.update(heartbeatRuns)
|
|
.set({
|
|
contextSnapshot: { ...contextSnapshot, [REGISTRY_KEY]: [...currentEntries, entry] },
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
|
});
|
|
},
|
|
redactForRun: async <T>(companyId: string, runId: string, value: T): Promise<T> =>
|
|
redactRegisteredSecretValues(value, await valuesForRun(companyId, runId)),
|
|
redactForIssue: async <T>(companyId: string, issueId: string, value: T): Promise<T> =>
|
|
redactRegisteredSecretValues(value, await valuesForIssue(companyId, issueId)),
|
|
};
|
|
}
|