diff --git a/supabase/functions/_shared/edge-authz-manifest.ts b/supabase/functions/_shared/edge-authz-manifest.ts index b7ecab90b..20e335540 100644 --- a/supabase/functions/_shared/edge-authz-manifest.ts +++ b/supabase/functions/_shared/edge-authz-manifest.ts @@ -69,6 +69,7 @@ export const EDGE_AUTHZ_MANIFEST: Record = { "detect-new-device": { category: "public", rationale: "Anti-fraude pré-login" }, "bi-share-dossier": { category: "public", rationale: "Dossiê compartilhado por token" }, "dropbox-list": { category: "public", rationale: "Listagem pública de arquivos curados" }, + "secure-upload": { category: "public", rationale: "Upload com scan VirusTotal anti-malware (aceita anônimo; uso real em áreas autenticadas — considerar promover a authenticated em PR futuro)" }, // ---------------- Webhooks (assinatura própria) ---------------- "webhook-inbound": { category: "scoped", rationale: "HMAC-SHA256 inline" }, diff --git a/supabase/functions/secure-upload/index.ts b/supabase/functions/secure-upload/index.ts index ba098bf2d..8ef8a79e0 100644 --- a/supabase/functions/secure-upload/index.ts +++ b/supabase/functions/secure-upload/index.ts @@ -1,148 +1,196 @@ -import { serve } from "https://deno.land/std@0.168.0/http/server.ts" -import { createClient } from "https://esm.sh/@supabase/supabase-js@2" +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createStructuredLogger } from "../_shared/structured-logger.ts"; +import { getOrCreateRequestId } from "../_shared/request-id.ts"; const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', -} + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; interface ScanLog { user_id: string | null; bucket: string; path: string; hash: string; - scan_result: any; + scan_result: Record; status_code: number; } -serve(async (req) => { - if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) +Deno.serve(async (req) => { + const requestId = getOrCreateRequestId(req); + const log = createStructuredLogger({ fn: "secure-upload", requestId, req }); + + if (req.method === "OPTIONS") { + return log.respond(new Response("ok", { headers: corsHeaders })); + } + + log.info("request_start"); const supabaseAdmin = createClient( - Deno.env.get('SUPABASE_URL') ?? '', - Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' - ) + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "", + ); // Variáveis para auditoria persistente mesmo em caso de erro let auditData: Partial = { status_code: 500, - scan_result: { message: 'Iniciando processamento' } + scan_result: { message: "Iniciando processamento" }, }; try { - const formData = await req.formData() - const file = formData.get('file') as File - const folder = formData.get('folder') as string || 'uploads' - - const authHeader = req.headers.get('Authorization') - let user = null + const formData = await req.formData(); + const file = formData.get("file") as File; + const folder = (formData.get("folder") as string) || "uploads"; + + const authHeader = req.headers.get("Authorization"); + let user = null; if (authHeader) { - const { data: { user: authUser } } = await supabaseAdmin.auth.getUser(authHeader.replace('Bearer ', '')) - user = authUser + const { data: { user: authUser } } = await supabaseAdmin.auth.getUser( + authHeader.replace("Bearer ", ""), + ); + user = authUser; } - if (!file) throw new Error('Arquivo obrigatório') + if (!file) throw new Error("Arquivo obrigatório"); - const fileBuffer = await file.arrayBuffer() - const hashBuffer = await crypto.subtle.digest('SHA-256', fileBuffer) - const hashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('') + const fileBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest("SHA-256", fileBuffer); + const hashHex = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); auditData = { user_id: user?.id ?? null, - bucket: 'personalization-images', + bucket: "personalization-images", path: `verified/${folder}/${file.name}`, hash: hashHex, status_code: 200, - scan_result: { message: 'Arquivo recebido para análise' } + scan_result: { message: "Arquivo recebido para análise" }, }; - let isSuspicious = false - let scanDetails: any = { source: 'VirusTotal', checked_at: new Date().toISOString() } - let targetBucket = 'personalization-images' - let targetPrefix = 'verified' - const vtApiKey = Deno.env.get('VIRUSTOTAL_API_KEY') + let isSuspicious = false; + let scanDetails: Record = { + source: "VirusTotal", + checked_at: new Date().toISOString(), + }; + let targetBucket = "personalization-images"; + let targetPrefix = "verified"; + const vtApiKey = Deno.env.get("VIRUSTOTAL_API_KEY"); if (vtApiKey) { try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 10000) - const vtRes = await fetch(`https://www.virustotal.com/api/v3/files/${hashHex}`, { - headers: { 'x-apikey': vtApiKey }, - signal: controller.signal - }) - clearTimeout(timeoutId) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + const vtRes = await fetch( + `https://www.virustotal.com/api/v3/files/${hashHex}`, + { + headers: { "x-apikey": vtApiKey }, + signal: controller.signal, + }, + ); + clearTimeout(timeoutId); if (vtRes.ok) { - const vtData = await vtRes.json() - scanDetails = { ...scanDetails, ...vtData.data.attributes.last_analysis_stats } - if (scanDetails.malicious > 0 || scanDetails.suspicious > 0) { - isSuspicious = true - scanDetails.reason = `Detectado: ${scanDetails.malicious} maliciosos, ${scanDetails.suspicious} suspeitos` + const vtData = await vtRes.json(); + scanDetails = { ...scanDetails, ...vtData.data.attributes.last_analysis_stats }; + const malicious = (scanDetails.malicious as number | undefined) ?? 0; + const suspicious = (scanDetails.suspicious as number | undefined) ?? 0; + if (malicious > 0 || suspicious > 0) { + isSuspicious = true; + scanDetails.reason = `Detectado: ${malicious} maliciosos, ${suspicious} suspeitos`; } else { - scanDetails.reason = 'Arquivo limpo (base VirusTotal)' + scanDetails.reason = "Arquivo limpo (base VirusTotal)"; } } else if (vtRes.status === 404) { - scanDetails.reason = 'Arquivo novo no VirusTotal (análise pendente). Permitido upload inicial.' + scanDetails.reason = + "Arquivo novo no VirusTotal (análise pendente). Permitido upload inicial."; } else { - throw new Error(`Falha na API de segurança (Status: ${vtRes.status})`) + throw new Error(`Falha na API de segurança (Status: ${vtRes.status})`); } - } catch (err: any) { - const reason = err.name === 'AbortError' ? 'Timeout na verificação (10s)' : err.message; - console.error('Security Check Failed:', reason); - - await supabaseAdmin.from('file_scan_logs').insert({ + } catch (err) { + const errorObj = err as { name?: string; message?: string }; + const reason = errorObj.name === "AbortError" + ? "Timeout na verificação (10s)" + : (errorObj.message ?? "Erro desconhecido"); + log.error("security_check_failed", { err, reason }); + + await supabaseAdmin.from("file_scan_logs").insert({ ...auditData, status_code: 403, - scan_result: { ...scanDetails, error: true, reason: `Bloqueio preventivo: ${reason}` } + scan_result: { ...scanDetails, error: true, reason: `Bloqueio preventivo: ${reason}` }, }); - return new Response(JSON.stringify({ error: `Segurança: ${reason}` }), { - status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }) + return log.respond( + new Response(JSON.stringify({ error: `Segurança: ${reason}`, request_id: requestId }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }), + ); } } if (isSuspicious) { - targetBucket = 'quarantine' - targetPrefix = 'suspect' + targetBucket = "quarantine"; + targetPrefix = "suspect"; } - const fileName = `${targetPrefix}/${folder}/${Date.now()}-${Math.random().toString(36).substring(7)}.${file.name.split('.').pop()}` + const fileName = `${targetPrefix}/${folder}/${Date.now()}-${ + Math.random().toString(36).substring(7) + }.${file.name.split(".").pop()}`; const { data: uploadData, error: uploadError } = await supabaseAdmin.storage .from(targetBucket) - .upload(fileName, fileBuffer, { contentType: file.type, upsert: false }) + .upload(fileName, fileBuffer, { contentType: file.type, upsert: false }); - if (uploadError) throw uploadError + if (uploadError) throw uploadError; auditData.path = uploadData.path; auditData.bucket = targetBucket; auditData.status_code = isSuspicious ? 403 : 200; auditData.scan_result = scanDetails; - await supabaseAdmin.from('file_scan_logs').insert(auditData as ScanLog) + await supabaseAdmin.from("file_scan_logs").insert(auditData as ScanLog); if (isSuspicious) { - return new Response(JSON.stringify({ error: 'Arquivo bloqueado: Malware detectado' }), { - status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }) + log.warn("upload_blocked_malware", { bucket: targetBucket, path: uploadData.path }); + return log.respond( + new Response( + JSON.stringify({ error: "Arquivo bloqueado: Malware detectado", request_id: requestId }), + { status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ), + ); } - const { data: { publicUrl } } = supabaseAdmin.storage.from(targetBucket).getPublicUrl(uploadData.path) - return new Response(JSON.stringify({ url: publicUrl, path: uploadData.path }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 - }) + const { data: { publicUrl } } = supabaseAdmin.storage + .from(targetBucket) + .getPublicUrl(uploadData.path); - } catch (error: any) { - console.error('Final Error:', error.message) + log.info("upload_ok", { + bucket: targetBucket, + path: uploadData.path, + user_id: user?.id ?? null, + }); + + return log.respond( + new Response( + JSON.stringify({ url: publicUrl, path: uploadData.path, request_id: requestId }), + { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 200 }, + ), + ); + } catch (error) { + const errorObj = error as { message?: string }; + log.error("upload_failed", { err: error }); if (auditData.hash) { - await supabaseAdmin.from('file_scan_logs').insert({ + await supabaseAdmin.from("file_scan_logs").insert({ ...auditData, status_code: 500, - scan_result: { error: true, message: error.message } + scan_result: { error: true, message: errorObj.message ?? "Erro desconhecido" }, }); } - return new Response(JSON.stringify({ error: error.message }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 - }) + return log.respond( + new Response( + JSON.stringify({ error: errorObj.message ?? "Erro desconhecido", request_id: requestId }), + { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 }, + ), + ); } -}) +});