73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
// AK-UI-03 · fail-closed AV gate for compose attachments (Estate STANDARDS §13:
|
|
// shared ClamAV, INSTREAM, mesh-only — never a path scan). Unlike the v1-Files
|
|
// import path (which enqueues an async scan and gates DELIVERY on the verdict),
|
|
// compose attachments leave the tenant immediately via the provider adapter.
|
|
// There is no later gate to fall back on, so the scan runs SYNCHRONOUSLY before
|
|
// sendEmail and a scanner outage rejects the send rather than passing it through.
|
|
|
|
import type { ClamAVBufferScanner } from "@hivegrid/storage";
|
|
import { requireCleanMalwareScan, StorageScanRejectedError } from "~/utils/integrations/storageService";
|
|
import { auditRejectedUpload } from "~/utils/integrations/storageAudit.server";
|
|
|
|
/** Maps onto the Klartext keys emailInbox.compose.error.* (M002). */
|
|
export type ComposeAttachmentScanCode = "attachment_infected" | "attachment_scan_unavailable";
|
|
|
|
export class ComposeAttachmentScanError extends Error {
|
|
readonly code: ComposeAttachmentScanCode;
|
|
readonly filename: string;
|
|
constructor(code: ComposeAttachmentScanCode, filename: string) {
|
|
super(`Compose attachment rejected by the malware scanner: ${code} (${filename})`);
|
|
this.name = "ComposeAttachmentScanError";
|
|
this.code = code;
|
|
this.filename = filename;
|
|
}
|
|
}
|
|
|
|
/** Compose attachments are never persisted to storage — the bucket/key are the
|
|
* audit-trail labels the scan rejection is recorded under, not a real object. */
|
|
const COMPOSE_SCAN_BUCKET = "email-compose";
|
|
|
|
export type ComposeAttachmentScanTarget = {
|
|
readonly filename: string;
|
|
readonly bytes: Uint8Array;
|
|
};
|
|
|
|
/**
|
|
* Scans every compose attachment and resolves only when ALL of them come back
|
|
* clean. Throws ComposeAttachmentScanError otherwise — `attachment_infected`
|
|
* for a positive verdict, `attachment_scan_unavailable` when the scanner is
|
|
* unreachable, times out or returns an indeterminate result (fail-closed).
|
|
*/
|
|
export async function scanComposeAttachments({
|
|
request,
|
|
tenantId,
|
|
files,
|
|
scanner,
|
|
}: {
|
|
request: Request;
|
|
tenantId: string;
|
|
files: readonly ComposeAttachmentScanTarget[];
|
|
scanner?: ClamAVBufferScanner;
|
|
}): Promise<void> {
|
|
for (const [index, file] of files.entries()) {
|
|
try {
|
|
await requireCleanMalwareScan({
|
|
body: file.bytes,
|
|
bucket: COMPOSE_SCAN_BUCKET,
|
|
key: `${tenantId}/${String(index)}/${file.filename}`,
|
|
filename: file.filename,
|
|
onReject: auditRejectedUpload(request, "emailInbox.compose.attachment"),
|
|
scanner,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof StorageScanRejectedError) {
|
|
throw new ComposeAttachmentScanError(
|
|
error.scanResult.reason === "malware_detected" ? "attachment_infected" : "attachment_scan_unavailable",
|
|
file.filename
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|