Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/emailInbox/services/EmailAttachmentScan.server.ts
T

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;
}
}
}