diff --git a/apps/api/src/audit/audit-log.interceptor.ts b/apps/api/src/audit/audit-log.interceptor.ts index 09aeb3996e..25b7d14f6c 100644 --- a/apps/api/src/audit/audit-log.interceptor.ts +++ b/apps/api/src/audit/audit-log.interceptor.ts @@ -24,6 +24,7 @@ import { type ChangesRecord, buildChanges, buildDescription, + extractActionDescription, extractCommentContext, extractDownloadDescription, extractEntityId, @@ -129,6 +130,10 @@ export class AuditLogInterceptor implements NestInterceptor { responseBody, requestBody, ); + const actionDesc = extractActionDescription( + request.url, + method, + ); const downloadDesc = extractDownloadDescription( request.url, method, @@ -145,7 +150,7 @@ export class AuditLogInterceptor implements NestInterceptor { (request as { userRoles?: string[] }).userRoles, ); let descriptionOverride: string | null = - versionDesc ?? downloadDesc ?? policyActionDesc ?? findingDesc; + actionDesc ?? versionDesc ?? downloadDesc ?? policyActionDesc ?? findingDesc; const isAutomationUpdate = policyActionDesc && /automations/.test(request.url) && method === 'PATCH'; const isAttachmentAction = policyActionDesc && /attachments/.test(request.url); diff --git a/apps/api/src/audit/audit-log.utils.ts b/apps/api/src/audit/audit-log.utils.ts index 5c1856ac39..9aa24a4535 100644 --- a/apps/api/src/audit/audit-log.utils.ts +++ b/apps/api/src/audit/audit-log.utils.ts @@ -55,6 +55,25 @@ export function extractCommentContext( return null; } +/** + * Detects action sub-endpoints (e.g. trigger-assessment) on resources + * and returns a human-readable description instead of the generic + * "Created " that the POST method would produce. + */ +export function extractActionDescription( + path: string, + method: string, +): string | null { + if (method !== 'POST') return null; + + const pathWithoutQuery = path.split('?')[0]!; + + if (/\/vendors\/[^/]+\/trigger-assessment\/?$/.test(pathWithoutQuery)) + return 'Triggered vendor risk assessment'; + + return null; +} + /** * Detects download/export GET endpoints and returns a human-readable * description. Returns null for non-download endpoints. diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts index 05eb458b18..e47601c7fa 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts @@ -10,14 +10,19 @@ import { import { openai } from '@ai-sdk/openai'; import type { Prisma } from '@db'; import type { Task } from '@trigger.dev/sdk'; -import { logger, queue, schemaTask } from '@trigger.dev/sdk'; +import { logger, metadata, queue, schemaTask } from '@trigger.dev/sdk'; import { generateObject } from 'ai'; import { z } from 'zod'; import { resolveTaskCreatorAndAssignee } from './vendor-risk-assessment/assignee'; import { VENDOR_RISK_ASSESSMENT_TASK_ID } from './vendor-risk-assessment/constants'; -import { buildRiskAssessmentDescription } from './vendor-risk-assessment/description'; -import { firecrawlAgentVendorRiskAssessment } from './vendor-risk-assessment/firecrawl-agent'; +import { + buildRiskAssessmentDescription, + mergeNewsIntoRiskAssessment, +} from './vendor-risk-assessment/description'; +import { firecrawlResearchCore } from './vendor-risk-assessment/firecrawl-agent-core'; +import { firecrawlResearchNews } from './vendor-risk-assessment/firecrawl-agent-news'; +import type { ResearchMessage } from './vendor-risk-assessment/metadata-types'; import { buildFrameworkChecklist, getDefaultFrameworks, @@ -356,7 +361,9 @@ function mapCertificationToBadgeType( } /** - * Extract compliance badges from risk assessment data + * Extract compliance badges from risk assessment data. + * Passes through ALL verified certifications — known types get normalized + * to a canonical slug, unknown types are kept as-is. */ function extractComplianceBadges( data: Prisma.InputJsonValue, @@ -370,18 +377,19 @@ function extractComplianceBadges( return null; } - const badges: Array<{ type: ComplianceBadgeType; verified: boolean }> = []; - const seenTypes = new Set(); + const badges: Array<{ type: string; verified: boolean }> = []; + const seenTypes = new Set(); for (const cert of parsed.certifications) { - // Only include verified certifications if (cert.status !== 'verified') { continue; } - const badgeType = mapCertificationToBadgeType(cert.type); - if (badgeType && !seenTypes.has(badgeType)) { - seenTypes.add(badgeType); + // Normalize known types to canonical slugs, keep unknown as-is + const badgeType = + mapCertificationToBadgeType(cert.type) ?? cert.type.trim(); + if (badgeType && !seenTypes.has(badgeType.toLowerCase())) { + seenTypes.add(badgeType.toLowerCase()); badges.push({ type: badgeType, verified: true }); } } @@ -479,6 +487,7 @@ export const vendorRiskAssessmentTask: Task< id: true, website: true, status: true, + logoUrl: true, }, }); @@ -664,10 +673,24 @@ export const vendorRiskAssessmentTask: Task< } } - // Still mark the org-specific vendor as assessed + // Extract compliance badges and logo from cached GlobalVendors data + const cachedBadges = globalVendor?.riskAssessmentData + ? extractComplianceBadges( + globalVendor.riskAssessmentData as Prisma.InputJsonValue, + ) + : null; + const cachedLogoUrl = generateLogoUrl(vendor.website); + + // Still mark the org-specific vendor as assessed, and sync badges/logo await db.vendor.update({ where: { id: vendor.id }, - data: { status: VendorStatus.assessed }, + data: { + status: VendorStatus.assessed, + ...(cachedBadges ? { complianceBadges: cachedBadges } : {}), + ...(cachedLogoUrl && !vendor.logoUrl + ? { logoUrl: cachedLogoUrl } + : {}), + }, }); return { success: true, @@ -765,151 +788,365 @@ export const vendorRiskAssessmentTask: Task< const organizationFrameworks = getDefaultFrameworks(); const frameworkChecklist = buildFrameworkChecklist(organizationFrameworks); - // Do research if needed (vendor doesn't exist, no data, or explicitly requested) - const research = - needsResearch && payload.vendorWebsite - ? await firecrawlAgentVendorRiskAssessment({ - vendorName: payload.vendorName, - vendorWebsite: payload.vendorWebsite, - }) - : null; + try { + // Helper to append a progress message to run metadata + const messages: ResearchMessage[] = []; + const pushMessage = (text: string, type: ResearchMessage['type'], url?: string) => { + const msg: ResearchMessage = { text, type, timestamp: Date.now(), ...url ? { url } : {} }; + messages.push(msg); + metadata.set('messages', messages); + }; - const description = buildRiskAssessmentDescription({ - vendorName: payload.vendorName, - vendorWebsite: payload.vendorWebsite ?? null, - research, - frameworkChecklist, - organizationFrameworks, + // Initialize metadata + metadata.set('phase', 'starting'); + metadata.set('messages', []); + metadata.set('coreReady', false); + metadata.set('newsReady', false); + + metadata.set('phase', 'researching'); + pushMessage(`Analyzing ${payload.vendorWebsite}...`, 'searching'); + + logger.info('🚀 Starting parallel research', { + vendor: payload.vendorName, + website: payload.vendorWebsite, + organizationId: payload.organizationId, }); - const data = parseRiskAssessmentJson(description); + const coreStartedAt = Date.now(); + const newsStartedAt = Date.now(); - // Upsert GlobalVendors with risk assessment data (shared across all organizations) - // Version is auto-incremented (v1 -> v2 -> v3, etc.) - // Concurrency: serialize the final "read latest version + write + bump version" step. - const lockKey = domain ?? normalizedWebsite; - const { nextVersion, updatedWebsites } = await withAdvisoryLock({ - lockKey, - run: async () => { - const latestGlobalVendors = domain - ? await db.globalVendors.findMany({ - where: { website: { contains: domain } }, - select: { - website: true, - riskAssessmentVersion: true, - riskAssessmentUpdatedAt: true, - }, - orderBy: [ - { riskAssessmentUpdatedAt: 'desc' }, - { createdAt: 'desc' }, - ], - }) - : []; - - const currentMax = maxVersion(latestGlobalVendors); - const computedNext = incrementVersion(currentMax); - const now = new Date(); - - if (latestGlobalVendors.length > 0) { - for (const gv of latestGlobalVendors) { - await db.globalVendors.update({ - where: { website: gv.website }, - data: { - company_name: payload.vendorName, - riskAssessmentData: data, - riskAssessmentVersion: computedNext, - riskAssessmentUpdatedAt: now, - }, - }); + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + // Run core research and news research in parallel + const [coreResult, newsResult] = await Promise.allSettled([ + (async () => { + pushMessage('Crawling vendor website...', 'searching'); + logger.info('🔍 Core research started', { + vendor: payload.vendorName, + website: payload.vendorWebsite, + }); + const result = await firecrawlResearchCore({ + vendorName: payload.vendorName, + vendorWebsite: payload.vendorWebsite!, + }); + const durationMs = Date.now() - coreStartedAt; + if (result) { + const certCount = result.certifications?.length ?? 0; + const verifiedCount = + result.certifications?.filter((c) => c.status === 'verified') + .length ?? 0; + const linkCount = result.links?.length ?? 0; + logger.info('✅ Core research completed', { + vendor: payload.vendorName, + durationMs, + certifications: certCount, + verifiedCertifications: verifiedCount, + links: linkCount, + hasAssessment: Boolean(result.securityAssessment), + riskLevel: result.riskLevel ?? 'none', + }); + + // Report each finding individually with delays so the UI + // shows them appearing one by one in real time + if (result.certifications?.length) { + pushMessage('Extracting certifications...', 'analyzing'); + await sleep(300); + for (const cert of result.certifications) { + if (cert.status === 'verified') { + pushMessage(`Found ${cert.type}`, 'found', cert.url ?? undefined); + await sleep(250); + } + } } - return { - nextVersion: computedNext, - updatedWebsites: latestGlobalVendors.map((gv) => gv.website), - }; - } - await db.globalVendors.upsert({ - where: { website: normalizedWebsite }, - create: { - website: normalizedWebsite, - company_name: payload.vendorName, - riskAssessmentData: data, - riskAssessmentVersion: computedNext, - riskAssessmentUpdatedAt: now, - }, - update: { - company_name: payload.vendorName, - riskAssessmentData: data, - riskAssessmentVersion: computedNext, - riskAssessmentUpdatedAt: now, - }, + if (result.links?.length) { + pushMessage('Extracting security and legal links...', 'analyzing'); + await sleep(300); + for (const link of result.links) { + pushMessage(`Found ${link.label}`, 'found', link.url); + await sleep(200); + } + } + + if (result.securityAssessment) { + pushMessage('Generating security assessment...', 'analyzing'); + await sleep(400); + pushMessage('Security assessment complete', 'found'); + } + } else { + logger.warn('⚠️ Core research returned null', { + vendor: payload.vendorName, + durationMs, + }); + } + return result; + })(), + (async () => { + logger.info('📰 News research started', { + vendor: payload.vendorName, + website: payload.vendorWebsite, + }); + const result = await firecrawlResearchNews({ + vendorName: payload.vendorName, + vendorWebsite: payload.vendorWebsite!, }); + const durationMs = Date.now() - newsStartedAt; + if (result?.length) { + logger.info('✅ News research completed', { + vendor: payload.vendorName, + durationMs, + newsItems: result.length, + }); + // Stagger news reporting + pushMessage('Processing recent news...', 'analyzing'); + await sleep(200); + for (const item of result) { + pushMessage(`Found: ${item.title}`, 'found', item.url ?? undefined); + await sleep(150); + } + } else { + logger.info('📰 News research returned no items', { + vendor: payload.vendorName, + durationMs, + }); + } + return result; + })(), + ]); - return { - nextVersion: computedNext, - updatedWebsites: [normalizedWebsite], - }; - }, + logger.info('🏁 Both research calls settled', { + vendor: payload.vendorName, + coreStatus: coreResult.status, + newsStatus: newsResult.status, + coreError: + coreResult.status === 'rejected' ? String(coreResult.reason) : null, + newsError: + newsResult.status === 'rejected' ? String(newsResult.reason) : null, }); - if (updatedWebsites.length > 1) { - logger.info('Updated multiple duplicates', { + // --- Process core results --- + const coreData = + coreResult.status === 'fulfilled' ? coreResult.value : null; + + if (coreData) { + pushMessage('Writing core research to database...', 'analyzing'); + logger.info('💾 Writing core data to GlobalVendors', { vendor: payload.vendorName, - count: updatedWebsites.length, - websites: updatedWebsites, + domain, + normalizedWebsite, + }); + + const description = buildRiskAssessmentDescription({ + vendorName: payload.vendorName, + vendorWebsite: payload.vendorWebsite ?? null, + research: { ...coreData, news: null }, + frameworkChecklist, + organizationFrameworks, + }); + const data = parseRiskAssessmentJson(description); + + // Upsert GlobalVendors (same advisory lock pattern as before) + const lockKey = domain ?? normalizedWebsite; + const { nextVersion, updatedWebsites } = await withAdvisoryLock({ + lockKey, + run: async () => { + const latestGlobalVendors = domain + ? await db.globalVendors.findMany({ + where: { website: { contains: domain } }, + select: { + website: true, + riskAssessmentVersion: true, + riskAssessmentUpdatedAt: true, + }, + orderBy: [ + { riskAssessmentUpdatedAt: 'desc' }, + { createdAt: 'desc' }, + ], + }) + : []; + + const currentMax = maxVersion(latestGlobalVendors); + const computedNext = incrementVersion(currentMax); + const now = new Date(); + + if (latestGlobalVendors.length > 0) { + for (const gv of latestGlobalVendors) { + await db.globalVendors.update({ + where: { website: gv.website }, + data: { + company_name: payload.vendorName, + riskAssessmentData: data, + riskAssessmentVersion: computedNext, + riskAssessmentUpdatedAt: now, + }, + }); + } + return { + nextVersion: computedNext, + updatedWebsites: latestGlobalVendors.map((gv) => gv.website), + }; + } + + await db.globalVendors.upsert({ + where: { website: normalizedWebsite }, + create: { + website: normalizedWebsite, + company_name: payload.vendorName, + riskAssessmentData: data, + riskAssessmentVersion: computedNext, + riskAssessmentUpdatedAt: now, + }, + update: { + company_name: payload.vendorName, + riskAssessmentData: data, + riskAssessmentVersion: computedNext, + riskAssessmentUpdatedAt: now, + }, + }); + + return { + nextVersion: computedNext, + updatedWebsites: [normalizedWebsite], + }; + }, }); - } - const rawRiskLevel = extractRiskLevel(data); - const normalizedRiskLevel = await normalizeRiskLevel(rawRiskLevel); + logger.info('💾 GlobalVendors upsert complete', { + vendor: payload.vendorName, + version: nextVersion, + updatedWebsites, + }); - // Log if risk level is missing (AI fallback already logs for ambiguous values) - if (!rawRiskLevel) { - logger.info('No risk level in assessment data, defaulting to medium', { + // Extract risk level and badges + logger.info('🎯 Normalizing risk level', { vendor: payload.vendorName, }); - } else if (normalizedRiskLevel) { - logger.info('Risk level normalized', { + const rawRiskLevel = extractRiskLevel(data); + const normalizedRiskLvl = await normalizeRiskLevel(rawRiskLevel); + const inherentProbability = mapRiskLevelToLikelihood(normalizedRiskLvl); + const inherentImpact = mapRiskLevelToImpact(normalizedRiskLvl); + const residualProbability = mapRiskLevelToLikelihood(normalizedRiskLvl); + const residualImpact = mapRiskLevelToImpact(normalizedRiskLvl); + const complianceBadges = extractComplianceBadges(data); + const logoUrl = generateLogoUrl(vendor.website); + + logger.info('📊 Risk level and badges extracted', { vendor: payload.vendorName, rawRiskLevel, - normalizedRiskLevel, + normalizedRiskLevel: normalizedRiskLvl, + hasBadges: Boolean(complianceBadges), + badgeCount: Array.isArray(complianceBadges) ? complianceBadges.length : 0, + hasLogo: Boolean(logoUrl), }); - } - const inherentProbability = mapRiskLevelToLikelihood(normalizedRiskLevel); - const inherentImpact = mapRiskLevelToImpact(normalizedRiskLevel); - const residualProbability = mapRiskLevelToLikelihood(normalizedRiskLevel); - const residualImpact = mapRiskLevelToImpact(normalizedRiskLevel); + // Update vendor with core data (keep status in_progress — news may still be loading) + await db.vendor.update({ + where: { id: vendor.id }, + data: { + inherentProbability, + inherentImpact, + residualProbability, + residualImpact, + ...(complianceBadges ? { complianceBadges } : {}), + ...(logoUrl ? { logoUrl } : {}), + }, + }); - // Extract compliance badges from risk assessment certifications - const complianceBadges = extractComplianceBadges(data); - if (complianceBadges) { - logger.info('Extracted compliance badges from risk assessment', { + metadata.set('phase', 'core_complete'); + metadata.set('coreReady', true); + + logger.info('🎉 Core phase complete — vendor updated, metadata.coreReady=true', { vendor: payload.vendorName, - badges: complianceBadges, + vendorId: vendor.id, + version: nextVersion, }); - } - // Generate logo URL from website using Google Favicon API - const logoUrl = generateLogoUrl(vendor.website); + // --- Process news results (merge into existing data) --- + const newsData = + newsResult.status === 'fulfilled' ? newsResult.value : null; + + if (newsData && newsData.length > 0) { + pushMessage('Adding news to research data...', 'analyzing'); + + await withAdvisoryLock({ + lockKey, + run: async () => { + // Read current data, merge news, write back + const websites = + updatedWebsites.length > 0 + ? updatedWebsites + : [normalizedWebsite]; + for (const website of websites) { + const gv = await db.globalVendors.findUnique({ + where: { website }, + select: { riskAssessmentData: true }, + }); + if (!gv?.riskAssessmentData) continue; + + const existingParsed = gv.riskAssessmentData as Record< + string, + unknown + >; + const existingTyped = + existingParsed as unknown as import('./vendor-risk-assessment/agent-types').VendorRiskAssessmentDataV1; + const merged = mergeNewsIntoRiskAssessment( + existingTyped, + newsData, + ); + + await db.globalVendors.update({ + where: { website }, + data: { + riskAssessmentData: JSON.parse(JSON.stringify(merged)), + }, + }); + } + }, + }); + + metadata.set('newsReady', true); + logger.info('📰 News merged into GlobalVendors — metadata.newsReady=true', { + vendor: payload.vendorName, + vendorId: vendor.id, + newsCount: newsData.length, + websites: updatedWebsites.length > 0 ? updatedWebsites : [normalizedWebsite], + }); + } else if (newsResult.status === 'rejected') { + pushMessage('News research could not be completed', 'error'); + logger.warn('News research failed, continuing with core data only', { + vendor: payload.vendorName, + error: + newsResult.reason instanceof Error + ? newsResult.reason.message + : String(newsResult.reason), + }); + } + } else { + // Core research failed + if (coreResult.status === 'rejected') { + pushMessage('Research encountered an issue', 'error'); + metadata.set('phase', 'failed'); + throw coreResult.reason; + } + // Core returned null (API key missing, invalid URL, etc.) + pushMessage('Could not complete research for this vendor', 'error'); + metadata.set('phase', 'failed'); + throw new Error( + `Core research returned null for ${payload.vendorName} — vendor will not be marked as assessed`, + ); + } - // Mark org-specific vendor as assessed + // Mark vendor as assessed and flip verify task + logger.info('🏷️ Setting vendor status to assessed', { + vendor: payload.vendorName, + vendorId: vendor.id, + }); await db.vendor.update({ where: { id: vendor.id }, - data: { - status: VendorStatus.assessed, - inherentProbability, - inherentImpact, - residualProbability, - residualImpact, - // Only set complianceBadges if we found any, otherwise leave unchanged - ...(complianceBadges ? { complianceBadges } : {}), - // Only set logoUrl if we generated one, otherwise leave unchanged - ...(logoUrl ? { logoUrl } : {}), - }, + data: { status: VendorStatus.assessed }, }); - // Flip verify task to "todo" once the risk assessment is ready (only if it wasn't already completed/canceled). await db.taskItem.updateMany({ where: { id: verifyTaskItemId, @@ -919,25 +1156,58 @@ export const vendorRiskAssessmentTask: Task< status: TaskItemStatus.todo, description: 'Review the latest Risk Assessment and confirm it is accurate.', - // Keep stable assignee/creator assigneeId: assigneeMemberId, updatedById: creatorMemberId, }, }); - logger.info('✅ COMPLETED', { + metadata.set('phase', 'complete'); + + logger.info('✅ COMPLETED — all phases done', { vendor: payload.vendorName, - researched: Boolean(research), - version: nextVersion, + vendorId: vendor.id, + researched: Boolean(coreData), + hasNews: newsResult.status === 'fulfilled' && Boolean(newsResult.value), + coreStatus: coreResult.status, + newsStatus: newsResult.status, }); return { success: true, vendorId: vendor.id, deduped: false, - researched: Boolean(research), - riskAssessmentVersion: nextVersion, + researched: Boolean(coreData), + riskAssessmentVersion: coreData ? 'latest' : null, verifyTaskItemId, }; + } catch (error) { + // Reset vendor status so the UI no longer shows an infinite loading state. + // The user can retry later once the underlying issue is resolved. + logger.error('❌ Risk assessment failed, resetting vendor status', { + vendor: payload.vendorName, + vendorId: vendor.id, + error: error instanceof Error ? error.message : String(error), + }); + + await db.vendor.update({ + where: { id: vendor.id }, + data: { status: VendorStatus.assessed }, + }); + + // Also reset the verify task back to todo so it doesn't stay stuck + if (typeof verifyTaskItemId === 'string') { + await db.taskItem.updateMany({ + where: { + id: verifyTaskItemId, + status: { + notIn: [TaskItemStatus.done, TaskItemStatus.canceled], + }, + }, + data: { status: TaskItemStatus.todo }, + }); + } + + throw error; // Re-throw so trigger.dev still records the failure and retries + } }, }); diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts index 017e4d10f3..d19f6aae86 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts @@ -1,5 +1,8 @@ import type { OrgFramework } from './frameworks'; -import type { VendorRiskAssessmentDataV1 } from './agent-types'; +import type { + VendorRiskAssessmentDataV1, + VendorRiskAssessmentNewsItem, +} from './agent-types'; export function buildRiskAssessmentDescription(params: { vendorName: string; @@ -36,3 +39,17 @@ export function buildRiskAssessmentDescription(params: { (base.securityAssessment ?? '') + checklistSuffix || null, } satisfies VendorRiskAssessmentDataV1); } + +/** + * Merge news items into an existing risk assessment data object. + * Used when core research completes first and news arrives later. + */ +export function mergeNewsIntoRiskAssessment( + existing: VendorRiskAssessmentDataV1, + news: VendorRiskAssessmentNewsItem[], +): VendorRiskAssessmentDataV1 { + return { + ...existing, + news: news.length > 0 ? news : existing.news, + }; +} diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts new file mode 100644 index 0000000000..a2e366fdfc --- /dev/null +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts @@ -0,0 +1,194 @@ +// apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts +import { logger } from '@trigger.dev/sdk'; +import { vendorRiskAssessmentAgentSchema } from './agent-schema'; +import type { VendorRiskAssessmentDataV1 } from './agent-types'; +import { validateVendorUrl } from './url-validation'; +import { + type FirecrawlSetup, + handleFirecrawlError, + normalizeIso, + setupFirecrawlClient, +} from './firecrawl-agent-shared'; + +export async function firecrawlResearchCore(params: { + vendorName: string; + vendorWebsite: string; +}): Promise | null> { + const setup = setupFirecrawlClient(params); + if (!setup) return null; + + const { firecrawlClient, vendorDomain, seedUrls } = setup; + const { vendorName, vendorWebsite } = params; + + const prompt = `Complete cyber security research on the vendor "${vendorName}" with website ${vendorWebsite}. + +Extract the following information: + +1. **Certifications**: Find all security and compliance certifications. For each one found, determine: + - The type of certification (SOC 2 Type I, SOC 2 Type II, ISO 27001, ISO 27017, ISO 27018, ISO 27701, ISO 42001, FedRAMP, HIPAA, PCI DSS, GDPR, TISAX, CSA STAR, C5, SOC 1, SOC 3, etc.) + - Whether it's currently active/verified, expired, or not certified + - Any issue or expiry dates mentioned + - Direct link to the certification report or trust page + +2. **Security & Legal Links**: Find the direct URLs to these pages. IMPORTANT: Many vendors host their trust portal on a third-party platform (e.g., SafeBase at trust.page, Vanta, Drata, Whistic). Prefer the actual trust portal where customers can request security reports over documentation pages that just describe compliance processes. + - **Trust Center / Security Portal**: The page where customers can review security posture and request compliance reports. This is NOT the docs page about security — it's the dedicated trust portal. Look for links labeled "Trust Center", "Security", "Trust Portal" in the site navigation or footer. It may be hosted on a subdomain (trust.${vendorDomain}, security.${vendorDomain}) or a third-party domain (e.g., ${vendorName.toLowerCase()}.trust.page, ${vendorName.toLowerCase()}.safebase.io). TIP: Try searching "${vendorName} trust portal" or "${vendorName} security trust center" to find it if not immediately visible on the site. + - **Privacy Policy**: Usually at /privacy or /privacy-policy + - **Terms of Service**: Usually at /terms or /tos + - **Security Overview**: A page describing security practices (this CAN be a docs page) + - **SOC 2 Report**: Direct link to request or download the SOC 2 report + +3. **Summary**: Provide an overall assessment of the vendor's security posture based on your findings. + +Focus on the official website ${vendorWebsite} and its trust/security/compliance pages.`; + + let agentResponse; + try { + agentResponse = await firecrawlClient.agent({ + prompt, + urls: seedUrls, + strictConstrainToURLs: false, + maxCredits: 2500, + timeout: 360, + pollInterval: 5, + ...({ model: 'spark-1-pro' } as Record), // SDK types lag behind API — model is supported but not typed yet + schema: { + type: 'object', + properties: { + risk_level: { + type: 'string', + description: 'Overall vendor risk level: critical, high, medium, low, or very_low', + }, + security_assessment: { + type: 'string', + description: 'A detailed paragraph summarizing the vendor security posture, including strengths, weaknesses, and key findings', + }, + last_researched_at: { + type: 'string', + description: 'ISO 8601 date of when this research was conducted', + }, + certifications: { + type: 'array', + description: 'All security and compliance certifications found on the vendor website', + items: { + type: 'object', + properties: { + type: { + type: 'string', + description: 'Certification name, e.g. SOC 2 Type II, ISO 27001, FedRAMP, HIPAA, PCI DSS, GDPR, ISO 42001, ISO 27017, ISO 27018, TISAX, CSA STAR, C5, etc.', + }, + status: { + type: 'string', + enum: ['verified', 'expired', 'not_certified', 'unknown'], + description: 'Whether the certification is currently active/verified, expired, not certified, or unknown', + }, + issued_at: { + type: 'string', + description: 'ISO 8601 date when the certification was issued, if mentioned', + }, + expires_at: { + type: 'string', + description: 'ISO 8601 date when the certification expires, if mentioned', + }, + url: { + type: 'string', + description: 'Direct URL to the certification report or trust page on the vendor domain', + }, + }, + required: ['type'], + }, + }, + links: { + type: 'object', + description: 'Direct URLs to key legal and security pages on the vendor domain', + properties: { + privacy_policy_url: { + type: 'string', + description: 'Direct URL to the privacy policy page', + }, + terms_of_service_url: { + type: 'string', + description: 'Direct URL to the terms of service page', + }, + trust_center_url: { + type: 'string', + description: 'Direct URL to the trust portal where customers can review security posture and request reports. Prefer the dedicated trust portal (often on trust.page, safebase.io, vanta.com, or a trust. subdomain) over documentation pages.', + }, + security_page_url: { + type: 'string', + description: 'Direct URL to the security overview or security practices page', + }, + soc2_report_url: { + type: 'string', + description: 'Direct URL to request or download the SOC 2 report', + }, + }, + }, + }, + required: ['security_assessment'], + }, + }); + } catch (error) { + return handleFirecrawlError(error, { vendorName, vendorWebsite, callType: 'core' }); + } + + if (!agentResponse.success || agentResponse.status === 'failed') { + logger.warn('Firecrawl core research job did not complete successfully', { + vendorWebsite, + status: agentResponse.status, + error: agentResponse.error, + }); + return null; + } + + const parsed = vendorRiskAssessmentAgentSchema.safeParse(agentResponse.data); + if (!parsed.success) { + logger.warn('Firecrawl core research returned invalid data shape', { + vendorWebsite, + issues: parsed.error.issues, + }); + return null; + } + + const links = parsed.data.links ?? null; + const linkPairs: Array<{ label: string; url: string }> = []; + if (links?.trust_center_url) + linkPairs.push({ label: 'Trust & Security', url: links.trust_center_url }); + if (links?.security_page_url) + linkPairs.push({ label: 'Security Overview', url: links.security_page_url }); + if (links?.soc2_report_url) + linkPairs.push({ label: 'SOC 2 Report', url: links.soc2_report_url }); + if (links?.privacy_policy_url) + linkPairs.push({ label: 'Privacy Policy', url: links.privacy_policy_url }); + if (links?.terms_of_service_url) + linkPairs.push({ label: 'Terms of Service', url: links.terms_of_service_url }); + + const normalizedLinks = linkPairs + .map((l) => ({ ...l, url: validateVendorUrl(l.url, vendorDomain, l.label) })) + .filter((l): l is { label: string; url: string } => Boolean(l.url)); + + const certifications = + parsed.data.certifications?.map((c) => ({ + type: c.type, + status: c.status ?? 'unknown', + issuedAt: normalizeIso(c.issued_at ?? null), + expiresAt: normalizeIso(c.expires_at ?? null), + url: validateVendorUrl(c.url ?? null, vendorDomain, `cert:${c.type}`), + })) ?? []; + + logger.info('Firecrawl core research completed', { + vendorWebsite, + found: { links: normalizedLinks.length, certifications: certifications.length }, + }); + + return { + kind: 'vendorRiskAssessmentV1', + vendorName, + vendorWebsite, + lastResearchedAt: + normalizeIso(parsed.data.last_researched_at ?? null) ?? new Date().toISOString(), + riskLevel: parsed.data.risk_level ?? null, + securityAssessment: parsed.data.security_assessment ?? null, + certifications: certifications.length > 0 ? certifications : null, + links: normalizedLinks.length > 0 ? normalizedLinks : null, + }; +} diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-news.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-news.ts new file mode 100644 index 0000000000..11ca1bff65 --- /dev/null +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-news.ts @@ -0,0 +1,127 @@ +// apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-news.ts +import { logger } from '@trigger.dev/sdk'; +import type { VendorRiskAssessmentNewsItem } from './agent-types'; +import { + handleFirecrawlError, + normalizeIso, + normalizeUrl, + setupFirecrawlClient, +} from './firecrawl-agent-shared'; + +const newsResponseSchema = { + type: 'object' as const, + properties: { + news: { + type: 'array' as const, + description: 'Recent news articles about the company from the last 12 months, ordered by date descending', + items: { + type: 'object' as const, + properties: { + date: { + type: 'string' as const, + description: 'Publication date in ISO 8601 format (YYYY-MM-DD)', + }, + title: { + type: 'string' as const, + description: 'Article headline or title', + }, + summary: { + type: 'string' as const, + description: 'One to two sentence summary of the article content', + }, + source: { + type: 'string' as const, + description: 'Publication name, e.g. TechCrunch, Reuters, company blog', + }, + url: { + type: 'string' as const, + description: 'Direct URL to the article', + }, + sentiment: { + type: 'string' as const, + enum: ['positive', 'negative', 'neutral'], + description: 'Whether the news is positive (funding, partnerships), negative (breaches, lawsuits), or neutral', + }, + }, + required: ['date', 'title'], + }, + }, + }, + required: ['news'], +}; + +export async function firecrawlResearchNews(params: { + vendorName: string; + vendorWebsite: string; +}): Promise { + const setup = setupFirecrawlClient(params); + if (!setup) return null; + + const { firecrawlClient, origin } = setup; + const { vendorName, vendorWebsite } = params; + + const prompt = `Find recent news articles (last 12 months) about the company "${vendorName}" (${vendorWebsite}). + +Prioritize these categories (from most to least important): +1. **Security incidents**: Data breaches, vulnerabilities, security failures, incident reports +2. **Regulatory & legal**: Lawsuits, fines, regulatory actions, compliance issues, government investigations +3. **Funding & acquisitions**: Funding rounds, M&A activity, IPO news, valuation changes +4. **Product & partnerships**: Major product launches, strategic partnerships, platform changes +5. **Leadership**: C-suite changes, key hires, departures + +Search the company's blog, newsroom, press releases, and reputable tech news sources (TechCrunch, Reuters, Bloomberg, The Verge, etc). Return up to 10 most significant items, prioritizing security-relevant news.`; + + let agentResponse; + try { + agentResponse = await firecrawlClient.agent({ + prompt, + urls: [origin, `${origin}/blog`, `${origin}/newsroom`, `${origin}/press`], + strictConstrainToURLs: false, + maxCredits: 2500, + timeout: 360, + pollInterval: 5, + ...({ model: 'spark-1-pro' } as Record), + schema: newsResponseSchema, + }); + } catch (error) { + return handleFirecrawlError(error, { vendorName, vendorWebsite, callType: 'news' }); + } + + if (!agentResponse.success || agentResponse.status === 'failed') { + logger.warn('Firecrawl news research job did not complete successfully', { + vendorWebsite, + status: agentResponse.status, + error: agentResponse.error, + }); + return null; + } + + const data = agentResponse.data as { news?: Array> } | undefined; + const rawNews = data?.news; + if (!Array.isArray(rawNews) || rawNews.length === 0) { + logger.info('Firecrawl news research returned no news items', { vendorWebsite }); + return null; + } + + const news = rawNews + .flatMap((n) => { + const isoDate = normalizeIso(n.date as string | undefined); + if (!isoDate) return []; + return [{ + date: isoDate, + title: (n.title as string) ?? '', + summary: (n.summary as string) ?? null, + source: (n.source as string) ?? null, + url: normalizeUrl(n.url as string | undefined), + sentiment: (n.sentiment as 'positive' | 'negative' | 'neutral') ?? null, + }]; + }) + .filter(Boolean); + + logger.info('Firecrawl news research completed', { + vendorWebsite, + found: { news: news.length }, + }); + + return news.length > 0 ? news : null; +} diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-shared.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-shared.ts new file mode 100644 index 0000000000..13e53b41ee --- /dev/null +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-shared.ts @@ -0,0 +1,112 @@ +import Firecrawl from '@mendable/firecrawl-js'; +import { logger } from '@trigger.dev/sdk'; +import { extractVendorDomain } from './url-validation'; + +export function normalizeUrl(url: string | null | undefined): string | null { + if (!url) return null; + const trimmed = url.trim(); + if (!trimmed || trimmed === '') return null; + + const looksLikeDomain = + !/^https?:\/\//i.test(trimmed) && + /^[a-z0-9.-]+\.[a-z]{2,}([/].*)?$/i.test(trimmed); + const candidate = looksLikeDomain ? `https://${trimmed}` : trimmed; + + try { + const u = new URL(candidate); + if (!['http:', 'https:'].includes(u.protocol)) return null; + return u.toString(); + } catch { + return null; + } +} + +export function normalizeIso(date: string | null | undefined): string | null { + if (!date) return null; + const trimmed = date.trim(); + if (!trimmed) return null; + const d = new Date(trimmed); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString(); +} + +export type FirecrawlSetup = { + firecrawlClient: Firecrawl; + origin: string; + vendorDomain: string; + seedUrls: string[]; +}; + +export function setupFirecrawlClient(params: { + vendorName: string; + vendorWebsite: string; +}): FirecrawlSetup | null { + const apiKey = process.env.FIRECRAWL_API_KEY; + if (!apiKey) { + logger.warn('FIRECRAWL_API_KEY is not configured; skipping vendor research'); + return null; + } + + let origin: string; + try { + origin = new URL(params.vendorWebsite).origin; + } catch { + logger.warn('Invalid website URL provided to Firecrawl Agent', { + vendorWebsite: params.vendorWebsite, + }); + return null; + } + + const vendorDomain = extractVendorDomain(params.vendorWebsite); + if (!vendorDomain) { + logger.warn('Could not extract vendor domain for URL validation', { + vendorWebsite: params.vendorWebsite, + }); + return null; + } + + const firecrawlClient = new Firecrawl({ apiKey }); + + const seedUrls = [ + origin, + `${origin}/privacy`, + `${origin}/privacy-policy`, + `${origin}/terms`, + `${origin}/terms-of-service`, + `${origin}/security`, + `${origin}/trust`, + `${origin}/legal`, + `${origin}/compliance`, + ]; + + return { firecrawlClient, origin, vendorDomain, seedUrls }; +} + +export function handleFirecrawlError( + error: unknown, + context: { vendorName: string; vendorWebsite: string; callType: string }, +): null { + const message = error instanceof Error ? error.message : String(error); + const isBillingOrRateLimit = + message.includes('402') || + message.includes('429') || + message.includes('Payment Required') || + message.includes('Rate') || + message.includes('Too Many Requests'); + + if (isBillingOrRateLimit) { + logger.error(`Firecrawl API billing or rate limit error (${context.callType})`, { + vendorName: context.vendorName, + vendorWebsite: context.vendorWebsite, + error: message, + }); + throw error; + } + + logger.error(`Firecrawl Agent SDK call failed (${context.callType})`, { + vendorName: context.vendorName, + vendorWebsite: context.vendorWebsite, + error: message, + }); + return null; +} diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts index 5605f920a7..1ab6303a1b 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts @@ -1,3 +1,8 @@ +/** + * @deprecated Use firecrawlResearchCore and firecrawlResearchNews instead. + * This file is kept temporarily for reference. Safe to delete after verifying + * the parallel implementation works correctly in production. + */ import Firecrawl from '@mendable/firecrawl-js'; import { logger } from '@trigger.dev/sdk'; import { vendorRiskAssessmentAgentSchema } from './agent-schema'; @@ -109,65 +114,111 @@ Focus on their official website ${vendorWebsite} (especially trust/security/comp `${origin}/compliance`, ]; - const agentResponse = await firecrawlClient.agent({ - prompt, - urls: seedUrls, - strictConstrainToURLs: false, // allow following links from seed URLs, but seeds anchor it to the right domain - schema: { - type: 'object', - properties: { - risk_level: { type: 'string' }, - security_assessment: { type: 'string' }, - last_researched_at: { type: 'string' }, - certifications: { - type: 'array', - items: { - type: 'object', - properties: { - type: { type: 'string' }, - status: { - type: 'string', - enum: ['verified', 'expired', 'not_certified', 'unknown'], + let agentResponse; + try { + agentResponse = await firecrawlClient.agent({ + prompt, + urls: seedUrls, + strictConstrainToURLs: false, + maxCredits: 1000, + timeout: 480, // 8 minutes — enough for thorough research, with headroom before trigger.dev's 10min maxDuration + pollInterval: 5, + schema: { + type: 'object', + properties: { + risk_level: { type: 'string' }, + security_assessment: { type: 'string' }, + last_researched_at: { type: 'string' }, + certifications: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + status: { + type: 'string', + enum: ['verified', 'expired', 'not_certified', 'unknown'], + }, + issued_at: { type: 'string' }, + expires_at: { type: 'string' }, + url: { type: 'string' }, }, - issued_at: { type: 'string' }, - expires_at: { type: 'string' }, - url: { type: 'string' }, + required: ['type'], }, - required: ['type'], }, - }, - links: { - type: 'object', - properties: { - privacy_policy_url: { type: 'string' }, - terms_of_service_url: { type: 'string' }, - trust_center_url: { type: 'string' }, - security_page_url: { type: 'string' }, - soc2_report_url: { type: 'string' }, - }, - }, - news: { - type: 'array', - items: { + links: { type: 'object', properties: { - date: { type: 'string' }, - title: { type: 'string' }, - summary: { type: 'string' }, - source: { type: 'string' }, - url: { type: 'string' }, - sentiment: { - type: 'string', - enum: ['positive', 'negative', 'neutral'], + privacy_policy_url: { type: 'string' }, + terms_of_service_url: { type: 'string' }, + trust_center_url: { type: 'string' }, + security_page_url: { type: 'string' }, + soc2_report_url: { type: 'string' }, + }, + }, + news: { + type: 'array', + items: { + type: 'object', + properties: { + date: { type: 'string' }, + title: { type: 'string' }, + summary: { type: 'string' }, + source: { type: 'string' }, + url: { type: 'string' }, + sentiment: { + type: 'string', + enum: ['positive', 'negative', 'neutral'], + }, }, + required: ['date', 'title'], }, - required: ['date', 'title'], }, }, + required: ['security_assessment'], }, - required: ['security_assessment'], - }, - }); + }); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + const isBillingOrRateLimit = + message.includes('402') || + message.includes('429') || + message.includes('Payment Required') || + message.includes('Rate') || + message.includes('Too Many Requests'); + + if (isBillingOrRateLimit) { + // Billing/rate-limit errors — re-throw so the task fails and trigger.dev + // sends a Slack notification. The parent try-catch resets the vendor status + // so the customer never sees error details, just a normal "assessed" state. + logger.error('Firecrawl API billing or rate limit error', { + vendorName, + vendorWebsite, + error: message, + }); + throw error; + } + + // Transient errors (network, timeout, etc.) — log and return null so the task + // continues with a minimal assessment instead of failing outright. + logger.error('Firecrawl Agent SDK call failed', { + vendorName, + vendorWebsite, + error: message, + }); + return null; + } + + // Verify the agent job actually completed successfully + if (!agentResponse.success || agentResponse.status === 'failed') { + logger.warn('Firecrawl agent job did not complete successfully', { + vendorWebsite, + status: agentResponse.status, + error: agentResponse.error, + }); + return null; + } const parsed = vendorRiskAssessmentAgentSchema.safeParse(agentResponse.data); if (!parsed.success) { diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/metadata-types.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/metadata-types.ts new file mode 100644 index 0000000000..b6b50bcab1 --- /dev/null +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/metadata-types.ts @@ -0,0 +1,22 @@ +export type ResearchMessageType = 'searching' | 'found' | 'analyzing' | 'error'; + +export type ResearchMessage = { + text: string; + type: ResearchMessageType; + timestamp: number; + url?: string; +}; + +export type ResearchPhase = + | 'starting' + | 'researching' + | 'core_complete' + | 'complete' + | 'failed'; + +export type ResearchMetadata = { + phase: ResearchPhase; + messages: ResearchMessage[]; + coreReady: boolean; + newsReady: boolean; +}; diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.spec.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.spec.ts index ab46d9fe7f..a026c95cd6 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.spec.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.spec.ts @@ -104,10 +104,10 @@ describe('validateVendorUrl', () => { ); }); - it('returns null for URLs from wrong domain', () => { + it('accepts URLs from any domain (domain filtering removed — trusts AI agent)', () => { expect( validateVendorUrl('https://x.com/privacy', 'wix.com', 'privacy'), - ).toBe(null); + ).toBe('https://x.com/privacy'); }); it('returns null for empty/null input', () => { diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.ts index ddf755e06d..ee7467936d 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.ts @@ -1,20 +1,54 @@ import { logger } from '@trigger.dev/sdk'; import { getDomain } from 'tldts'; +// Well-known trust portal domains that vendors use to host their security pages +const TRUSTED_PORTAL_DOMAINS = [ + 'trust.page', // SafeBase + 'vanta.com', // Vanta trust centers + 'drata.com', // Drata trust centers + 'safebase.io', // SafeBase + 'securityscorecard.com', + 'whistic.com', + 'conveyor.com', + 'trustcloud.ai', + 'scrut.io', + 'tugboatlogic.com', + 'laika.com', +]; + /** - * Checks whether a URL belongs to the given vendor domain (including subdomains). - * For example, if vendorDomain is "wix.com", accepts "wix.com", "www.wix.com", - * "trust.wix.com", but rejects "x.com" or "notwix.com". + * Checks whether a URL belongs to or is related to the given vendor domain. + * Accepts: + * - Exact domain match: github.com + * - Subdomains: trust.github.com, security.github.com + * - Third-party trust portals with vendor name in subdomain: ghec.github.trust.page + * - Known trust portal domains with vendor name in the path or subdomain */ export function isUrlFromVendorDomain( url: string, vendorDomain: string, ): boolean { try { - const hostname = new URL(url).hostname.toLowerCase(); + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); const domain = vendorDomain.toLowerCase(); - // Exact match or subdomain match (e.g., trust.wix.com for wix.com) - return hostname === domain || hostname.endsWith(`.${domain}`); + const vendorName = domain.split('.')[0]!; // "github" from "github.com" + + // Direct match: github.com or *.github.com + if (hostname === domain || hostname.endsWith(`.${domain}`)) { + return true; + } + + // Third-party trust portal with vendor name in hostname + // e.g., ghec.github.trust.page, github.safebase.io + if (hostname.includes(vendorName)) { + const isKnownPortal = TRUSTED_PORTAL_DOMAINS.some( + (portal) => hostname === portal || hostname.endsWith(`.${portal}`), + ); + if (isKnownPortal) return true; + } + + return false; } catch { return false; } @@ -41,13 +75,14 @@ export function extractVendorDomain( } /** - * Validates and filters a URL, ensuring it belongs to the vendor domain. - * Returns null (with a warning log) if the URL is from a different domain. + * Validates a URL, ensuring it's a well-formed HTTP(S) URL. + * No longer filters by domain — the Firecrawl agent is trusted to return + * relevant URLs (vendors use custom trust portals on arbitrary domains). */ export function validateVendorUrl( url: string | null | undefined, - vendorDomain: string, - label: string, + _vendorDomain: string, + _label: string, ): string | null { if (!url) return null; const trimmed = url.trim(); @@ -62,18 +97,7 @@ export function validateVendorUrl( try { const u = new URL(candidate); if (!['http:', 'https:'].includes(u.protocol)) return null; - const normalized = u.toString(); - - if (!isUrlFromVendorDomain(normalized, vendorDomain)) { - logger.warn('Filtered out URL from wrong domain', { - vendorDomain, - label, - url: normalized, - }); - return null; - } - - return normalized; + return u.toString(); } catch { return null; } diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index d784c1b53d..b29de78d15 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -2496,16 +2496,20 @@ export class TrustAccessService { globalVendors.map((gv) => [gv.website, gv.riskAssessmentData]), ); - // Add icon URLs to compliance badges and trust portal URL + // Enrich vendors with trust portal URL and compliance badges from GlobalVendors return vendors.map((vendor) => { // Default to original website URL let trustPortalUrl: string | null = vendor.website; + let badges = vendor.complianceBadges; - // Try to get trust portal URL from GlobalVendors riskAssessmentData + // Enrich from GlobalVendors riskAssessmentData if (vendor.website) { const riskData = globalVendorMap.get(vendor.website); if (riskData && typeof riskData === 'object' && riskData !== null) { - const links = (riskData as Record).links; + const parsed = riskData as Record; + + // Extract trust portal URL + const links = parsed.links; if (Array.isArray(links) && links.length > 0) { const firstLink = links[0]; if ( @@ -2517,18 +2521,74 @@ export class TrustAccessService { trustPortalUrl = firstLink.url; } } + + // Extract compliance badges from riskAssessmentData when vendor record has none + if (!badges || !Array.isArray(badges) || badges.length === 0) { + badges = this.extractBadgesFromRiskData(parsed); + } } } return { ...vendor, - complianceBadges: this.formatComplianceBadgeLabels( - vendor.complianceBadges, - ), + complianceBadges: this.formatComplianceBadgeLabels(badges), trustPortalUrl, }; }); } + /** + * Extract compliance badges from GlobalVendors riskAssessmentData certifications. + * Used as fallback when the vendor record has no complianceBadges synced yet. + */ + private extractBadgesFromRiskData( + data: Record, + ): Array<{ type: string; verified: boolean }> | null { + const certs = data.certifications; + if (!Array.isArray(certs)) return null; + + const CERT_MAP: Record = { + soc2: 'soc2', + 'soc 2': 'soc2', + iso27001: 'iso27001', + 'iso 27001': 'iso27001', + iso42001: 'iso42001', + 'iso 42001': 'iso42001', + gdpr: 'gdpr', + hipaa: 'hipaa', + pcidss: 'pci_dss', + 'pci dss': 'pci_dss', + pci_dss: 'pci_dss', + nen7510: 'nen7510', + 'nen 7510': 'nen7510', + iso9001: 'iso9001', + 'iso 9001': 'iso9001', + }; + + const badges: Array<{ type: string; verified: boolean }> = []; + const seen = new Set(); + + for (const cert of certs) { + if ( + !cert || + typeof cert !== 'object' || + cert.status !== 'verified' || + typeof cert.type !== 'string' + ) + continue; + + const normalized = cert.type.toLowerCase().replace(/[^a-z0-9 _]/g, ''); + // Use canonical slug for known certs, keep original type for unknown ones + const badgeType = CERT_MAP[normalized] ?? cert.type.trim(); + const key = badgeType.toLowerCase(); + if (badgeType && !seen.has(key)) { + seen.add(key); + badges.push({ type: badgeType, verified: true }); + } + } + + return badges.length > 0 ? badges : null; + } + /** * Format compliance badges as simple type + label pairs for external rendering. * Does NOT include branded icons to avoid implying vendors were certified through us. diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index f8f6cdca98..35f2dc47e0 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -6,7 +6,6 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import axios, { AxiosInstance } from 'axios'; import { DeleteObjectCommand, GetObjectCommand, @@ -25,6 +24,7 @@ import { ComplianceResourceUrlResponseDto, UploadComplianceResourceDto, } from './dto/compliance-resource.dto'; +import * as dns from 'node:dns'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '../app/s3'; import { DeleteTrustDocumentDto, @@ -61,25 +61,54 @@ interface VercelDomainConfigResponse { @Injectable() export class TrustPortalService { private readonly logger = new Logger(TrustPortalService.name); - private readonly vercelApi: AxiosInstance; + private readonly vercelBaseUrl = 'https://api.vercel.com'; + private readonly vercelToken: string; private readonly MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; private readonly SIGNED_URL_EXPIRY_SECONDS = 900; constructor() { - const bearerToken = process.env.VERCEL_ACCESS_TOKEN; - - if (!bearerToken) { + this.vercelToken = process.env.VERCEL_ACCESS_TOKEN || ''; + if (!this.vercelToken) { this.logger.warn('VERCEL_ACCESS_TOKEN is not set'); } + } - // Initialize axios instance for Vercel API - this.vercelApi = axios.create({ - baseURL: 'https://api.vercel.com', + private async vercelFetch({ + method, + path, + params, + body, + }: { + method: 'GET' | 'POST' | 'DELETE'; + path: string; + params?: Record; + body?: unknown; + }): Promise<{ data: T; status: number }> { + const url = new URL(path, this.vercelBaseUrl); + if (params) { + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + } + const resp = await fetch(url.toString(), { + method, headers: { - Authorization: `Bearer ${bearerToken || ''}`, + Authorization: `Bearer ${this.vercelToken}`, 'Content-Type': 'application/json', }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); + if (!resp.ok) { + const errorBody = await resp.json().catch(() => ({})); + const err = new Error( + errorBody?.error?.message || `Vercel API ${method} ${path} failed (${resp.status})`, + ) as Error & { status: number; responseData: unknown }; + err.status = resp.status; + err.responseData = errorBody; + throw err; + } + const data = (await resp.json()) as T; + return { data, status: resp.status }; } private static readonly FRAMEWORK_CONFIG: Record< @@ -181,29 +210,24 @@ export class TrustPortalService { // Get domain information including verification status // Vercel API endpoint: GET /v9/projects/{projectId}/domains/{domain} + const teamId = process.env.VERCEL_TEAM_ID!; const [domainResponse, configResponse] = await Promise.all([ - this.vercelApi.get( - `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}`, - { - params: { - teamId: process.env.VERCEL_TEAM_ID, - }, - }, - ), + this.vercelFetch({ + method: 'GET', + path: `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}`, + params: { teamId }, + }), // Get domain config to retrieve the actual CNAME target - // Vercel API endpoint: GET /v6/domains/{domain}/config - this.vercelApi - .get(`/v6/domains/${TrustPortalService.safeDomainPath(domain)}/config`, { - params: { - teamId: process.env.VERCEL_TEAM_ID, - }, - }) - .catch((err) => { - this.logger.warn( - `Failed to get domain config for ${domain}: ${err.message}`, - ); - return null; - }), + this.vercelFetch({ + method: 'GET', + path: `/v6/domains/${TrustPortalService.safeDomainPath(domain)}/config`, + params: { teamId }, + }).catch((err) => { + this.logger.warn( + `Failed to get domain config for ${domain}: ${err instanceof Error ? err.message : err}`, + ); + return null; + }), ]); const domainInfo = domainResponse.data; @@ -236,11 +260,9 @@ export class TrustPortalService { error instanceof Error ? error.stack : error, ); - // Handle axios errors with more detail - if (axios.isAxiosError(error)) { - const statusCode = error.response?.status; - const message = error.response?.data?.error?.message || error.message; - this.logger.error(`Vercel API error (${statusCode}): ${message}`); + if (error instanceof Error && 'status' in error) { + const statusCode = (error as Error & { status: number }).status; + this.logger.error(`Vercel API error (${statusCode}): ${error.message}`); } throw new InternalServerErrorException( @@ -755,10 +777,11 @@ export class TrustPortalService { // Remove old domain from Vercel if switching to a different one if (currentTrust?.domain && currentTrust.domain !== domain) { try { - await this.vercelApi.delete( - `/v9/projects/${projectId}/domains/${TrustPortalService.safeDomainPath(currentTrust.domain)}`, - { params: { teamId } }, - ); + await this.vercelFetch({ + method: 'DELETE', + path: `/v9/projects/${projectId}/domains/${TrustPortalService.safeDomainPath(currentTrust.domain)}`, + params: { teamId }, + }); } catch (error) { this.logger.warn( `Failed to remove old domain ${currentTrust.domain} from Vercel: ${error}`, @@ -767,13 +790,15 @@ export class TrustPortalService { } // Check if domain already exists on the Vercel project - const existingDomainsResp = await this.vercelApi.get( - `/v9/projects/${projectId}/domains`, - { params: { teamId } }, - ); + const existingDomainsResp = await this.vercelFetch<{ + domains: Array<{ name: string }>; + }>({ + method: 'GET', + path: `/v9/projects/${projectId}/domains`, + params: { teamId }, + }); - const existingDomains: Array<{ name: string }> = - existingDomainsResp.data?.domains ?? []; + const existingDomains = existingDomainsResp.data?.domains ?? []; const alreadyOnProject = existingDomains.some((d) => d.name === domain); @@ -792,10 +817,11 @@ export class TrustPortalService { // Domain already on Vercel for this org — fetch current status // instead of deleting and re-adding (which regenerates verification tokens) - const statusResp = await this.vercelApi.get( - `/v9/projects/${projectId}/domains/${TrustPortalService.safeDomainPath(domain)}`, - { params: { teamId } }, - ); + const statusResp = await this.vercelFetch({ + method: 'GET', + path: `/v9/projects/${projectId}/domains/${TrustPortalService.safeDomainPath(domain)}`, + params: { teamId }, + }); const statusData = statusResp.data; const isVercelDomain = statusData.verified === false; @@ -827,11 +853,12 @@ export class TrustPortalService { this.logger.log(`Adding domain to Vercel project: ${domain}`); - const addResp = await this.vercelApi.post( - `/v9/projects/${projectId}/domains`, - { name: domain }, - { params: { teamId } }, - ); + const addResp = await this.vercelFetch({ + method: 'POST', + path: `/v9/projects/${projectId}/domains`, + params: { teamId }, + body: { name: domain }, + }); const addData = addResp.data; const isVercelDomain = addData.verified === false; @@ -861,8 +888,9 @@ export class TrustPortalService { }; } catch (error) { // Handle Vercel 409 conflict — domain already exists on the project - if (axios.isAxiosError(error) && error.response?.status === 409) { - const errorData = error.response.data?.error; + const vercelError = error as Error & { status?: number; responseData?: { error?: { code?: string; projectId?: string; message?: string; domain?: VercelDomainResponse } } }; + if (vercelError.status === 409) { + const errorData = vercelError.responseData?.error; if ( errorData?.code === 'domain_already_in_use' && @@ -913,15 +941,8 @@ export class TrustPortalService { } // Extract meaningful error message - let errorMessage = 'Failed to update custom domain'; - if (axios.isAxiosError(error)) { - errorMessage = - error.response?.data?.error?.message || - error.message || - errorMessage; - } else if (error instanceof Error) { - errorMessage = error.message || errorMessage; - } + const errorMessage = + error instanceof Error ? error.message : 'Failed to update custom domain'; this.logger.error(`Custom domain error for ${domain}:`, error); throw new BadRequestException(errorMessage); @@ -965,38 +986,17 @@ export class TrustPortalService { const rootDomain = domain.split('.').slice(-2).join('.'); - const [cnameResp, txtResp, vercelTxtResp] = await Promise.all([ - axios - .get(`https://networkcalc.com/api/dns/lookup/${TrustPortalService.safeDomainPath(domain)}`) - .catch(() => null), - axios - .get( - `https://networkcalc.com/api/dns/lookup/${TrustPortalService.safeDomainPath(rootDomain)}?type=TXT`, - ) - .catch(() => null), - axios - .get( - `https://networkcalc.com/api/dns/lookup/_vercel.${TrustPortalService.safeDomainPath(rootDomain)}?type=TXT`, - ) - .catch(() => null), - ]); + const dnsPromises = dns.promises; + const resolveCname = (host: string): Promise => + dnsPromises.resolve(host, 'CNAME').catch(() => []); + const resolveTxt = (host: string): Promise => + dnsPromises.resolve(host, 'TXT').catch(() => []); - if ( - !cnameResp || - cnameResp.status !== 200 || - cnameResp.data?.status !== 'OK' || - !txtResp || - txtResp.status !== 200 || - txtResp.data?.status !== 'OK' - ) { - throw new BadRequestException( - 'DNS record verification failed, check the records are valid or try again later.', - ); - } - - const cnameRecords = cnameResp.data?.records?.CNAME; - const txtRecords = txtResp.data?.records?.TXT; - const vercelTxtRecords = vercelTxtResp?.data?.records?.TXT; + const [cnameRecords, txtRecords, vercelTxtRecords] = await Promise.all([ + resolveCname(domain), + resolveTxt(rootDomain), + resolveTxt(`_vercel.${rootDomain}`), + ]); // Fetch fresh verification state from Vercel instead of relying on // potentially stale DB values (tokens change if domain was re-added). @@ -1005,10 +1005,11 @@ export class TrustPortalService { if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) { try { - const vercelStatusResp = await this.vercelApi.get( - `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}`, - { params: { teamId: process.env.VERCEL_TEAM_ID } }, - ); + const vercelStatusResp = await this.vercelFetch({ + method: 'GET', + path: `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}`, + params: { teamId: process.env.VERCEL_TEAM_ID }, + }); const vercelData = vercelStatusResp.data; liveIsVercelDomain = vercelData.verified === false; liveVercelVerification = @@ -1038,53 +1039,33 @@ export class TrustPortalService { const expectedTxtValue = `compai-domain-verification=${organizationId}`; const expectedVercelTxtValue = liveVercelVerification; - // Check CNAME - let isCnameVerified = false; - if (cnameRecords) { - isCnameVerified = cnameRecords.some( - (r: { address: string }) => - TrustPortalService.VERCEL_DNS_CNAME_PATTERN.test(r.address), + // Node's resolve(host, 'TXT') returns string[][] — each inner array is one TXT record + const txtRecordMatches = (records: string[][], expected: string | null) => + expected != null && + records.some((segments) => segments.some((s) => s === expected)); + + // Check CNAME — Node DNS resolve returns string[] of CNAME targets + let isCnameVerified = cnameRecords.some((address) => + TrustPortalService.VERCEL_DNS_CNAME_PATTERN.test(address), + ); + if (!isCnameVerified) { + const fallback = cnameRecords.find((address) => + TrustPortalService.VERCEL_DNS_FALLBACK_PATTERN.test(address), ); - if (!isCnameVerified) { - const fallback = cnameRecords.find( - (r: { address: string }) => - TrustPortalService.VERCEL_DNS_FALLBACK_PATTERN.test(r.address), - ); - if (fallback) { - this.logger.warn( - `CNAME matched fallback pattern: ${fallback.address}`, - ); - isCnameVerified = true; - } + if (fallback) { + this.logger.warn(`CNAME matched fallback pattern: ${fallback}`); + isCnameVerified = true; } } // Check TXT - let isTxtVerified = false; - if (txtRecords) { - isTxtVerified = txtRecords.some((record: any) => { - if (typeof record === 'string') return record === expectedTxtValue; - if (record?.value) return record.value === expectedTxtValue; - if (Array.isArray(record?.txt)) - return record.txt.some((t: string) => t === expectedTxtValue); - return false; - }); - } + const isTxtVerified = txtRecordMatches(txtRecords, expectedTxtValue); // Check Vercel TXT - let isVercelTxtVerified = false; - if (vercelTxtRecords) { - isVercelTxtVerified = vercelTxtRecords.some((record: any) => { - if (typeof record === 'string') - return record === expectedVercelTxtValue; - if (record?.value) return record.value === expectedVercelTxtValue; - if (Array.isArray(record?.txt)) - return record.txt.some( - (t: string) => t === expectedVercelTxtValue, - ); - return false; - }); - } + const isVercelTxtVerified = txtRecordMatches( + vercelTxtRecords, + expectedVercelTxtValue, + ); const requiresVercelTxt = liveIsVercelDomain; const isVerified = @@ -1107,11 +1088,12 @@ export class TrustPortalService { let vercelVerified = false; if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) { try { - const verifyResp = await this.vercelApi.post( - `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}/verify`, - {}, - { params: { teamId: process.env.VERCEL_TEAM_ID } }, - ); + const verifyResp = await this.vercelFetch<{ verified: boolean }>({ + method: 'POST', + path: `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}/verify`, + params: { teamId: process.env.VERCEL_TEAM_ID }, + body: {}, + }); vercelVerified = verifyResp.data?.verified === true; } catch (error) { this.logger.warn( diff --git a/apps/api/trigger.config.ts b/apps/api/trigger.config.ts index cf79b1895b..c700d132b1 100644 --- a/apps/api/trigger.config.ts +++ b/apps/api/trigger.config.ts @@ -1,4 +1,3 @@ -import { PrismaInstrumentation } from '@prisma/instrumentation'; import { syncVercelEnvVars } from '@trigger.dev/build/extensions/core'; import { defineConfig } from '@trigger.dev/sdk'; import { prismaExtension } from './customPrismaExtension'; @@ -9,7 +8,6 @@ export default defineConfig({ runtime: 'node-22', project: 'proj_zhioyrusqertqgafqgpj', // API project logLevel: 'log', - instrumentations: [new PrismaInstrumentation()], maxDuration: 300, // 5 minutes build: { extensions: [ diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx index 8e0b9008b3..fcc23d0c7f 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx @@ -165,10 +165,10 @@ export function FrameworkRequirements({ style={{ cursor: 'pointer' }} > - {item.name} + {item.name} -
+
{item.description} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksTable.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksTable.tsx index 2a09d42508..81ab8b193c 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksTable.tsx @@ -228,7 +228,7 @@ export function FrameworksTable({ -
+
{fw.framework.description?.trim() || '—'} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/ApplicableSwatch.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/ApplicableSwatch.tsx new file mode 100644 index 0000000000..4d1ca0d4ab --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/ApplicableSwatch.tsx @@ -0,0 +1,27 @@ +import { cn } from '@trycompai/ui/cn'; + +/** Swatch + label; shared by read-only display and select items (policy table pattern). */ +export function ApplicableSwatchRow({ isApplicable }: { isApplicable: boolean | null }) { + const swatchClass = + isApplicable === true + ? 'bg-primary' + : isApplicable === false + ? 'bg-red-600 dark:bg-red-400' + : 'bg-gray-400 dark:bg-gray-500'; + const label = isApplicable === true ? 'Yes' : isApplicable === false ? 'No' : '\u2014'; + + return ( + + + {label} + + ); +} + +export function ApplicableReadOnlyDisplay({ isApplicable }: { isApplicable: boolean | null }) { + return ( +
+ +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx index cb870e713d..7a081d6291 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx @@ -21,6 +21,10 @@ import { import { X, Loader2, Edit2 } from 'lucide-react'; import { toast } from 'sonner'; import { useSOADocument } from '../../hooks/useSOADocument'; +import { ApplicableReadOnlyDisplay, ApplicableSwatchRow } from './ApplicableSwatch'; +import type { SOAFieldSavePayload } from './soa-field-types'; + +export type { SOAFieldSavePayload, SOATableAnswerData } from './soa-field-types'; interface EditableSOAFieldsProps { documentId: string; @@ -31,7 +35,8 @@ interface EditableSOAFieldsProps { isControl7?: boolean; isFullyRemote?: boolean; organizationId: string; - onUpdate?: (savedAnswer: string | null) => void; + /** Called after a successful save so the table can override autofill/cache without a full reload. */ + onUpdate?: (payload: SOAFieldSavePayload) => void; } export function EditableSOAFields({ @@ -54,14 +59,6 @@ export function EditableSOAFields({ const justificationTextareaRef = useRef(null); const [isJustificationDialogOpen, setJustificationDialogOpen] = useState(false); const dialogSavedRef = useRef(false); - const badgeBaseClasses = - 'inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-medium tracking-wide w-[3rem]'; - const badgeClasses = - isApplicable === true - ? `${badgeBaseClasses} bg-primary text-primary-foreground border-primary/70 shadow-sm shadow-primary/40` - : isApplicable === false - ? `${badgeBaseClasses} bg-destructive text-destructive-foreground border-destructive/70 shadow-sm shadow-destructive/40` - : `${badgeBaseClasses} bg-muted text-muted-foreground border-transparent`; useEffect(() => { setIsApplicable(initialIsApplicable); @@ -101,9 +98,10 @@ export function EditableSOAFields({ setIsEditing(false); setError(null); toast.success('Answer saved successfully'); - // Call onUpdate with the saved answer value to update parent state optimistically - const savedAnswer = nextIsApplicable === false ? nextJustification : null; - onUpdate?.(savedAnswer); + onUpdate?.({ + isApplicable: nextIsApplicable, + justification: nextIsApplicable === false ? nextJustification : null, + }); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to save answer'; if (!isJustificationDialogOpen) { @@ -128,11 +126,6 @@ export function EditableSOAFields({ const handleEditClick = () => { setIsEditing(true); - if (isApplicable === false) { - setJustificationDialogOpen(true); - } else { - setJustificationDialogOpen(false); - } }; const handleSelectChange = (value: 'yes' | 'no' | 'null') => { @@ -186,9 +179,7 @@ export function EditableSOAFields({ // Display mode return (
- - {isApplicable === true ? 'YES' : isApplicable === false ? 'NO' : '\u2014'} - +
); } @@ -196,9 +187,7 @@ export function EditableSOAFields({ if (!isEditing) { return (
- - {isApplicable === true ? 'YES' : isApplicable === false ? 'NO' : '\u2014'} - +
); } - return {row.original.name}; + return ( +
+ {row.original.name} + {isResearching && ( + + + + + + Researching + + )} +
+ ); } function VendorStatusCell({ row }: { row: Row }) { @@ -52,6 +66,14 @@ function VendorStatusCell({ row }: { row: Row }) {
); } + if (row.original.status === 'in_progress') { + return ( +
+ + Researching... +
+ ); + } return ; } diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx index 1a87ae1896..e0bf11e674 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx @@ -93,6 +93,7 @@ function VendorNameCell({ vendor }: { vendor: VendorRow }) { const status = onboardingStatus[vendor.id]; const isPending = vendor.isPending || status === 'pending' || status === 'processing'; const isAssessing = vendor.isAssessing || status === 'assessing'; + const isResearching = vendor.status === 'in_progress'; const isResolved = vendor.status === 'assessed'; if ((isPending || isAssessing) && !isResolved) { @@ -104,7 +105,20 @@ function VendorNameCell({ vendor }: { vendor: VendorRow }) { ); } - return {vendor.name}; + return ( +
+ {vendor.name} + {isResearching && ( + + + + + + Researching + + )} +
+ ); } function VendorStatusCell({ vendor }: { vendor: VendorRow }) { diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx index 4713ba5d35..a247af7428 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorDetailTabs.tsx @@ -3,6 +3,8 @@ import { isFailureRunStatus } from '@/app/(app)/[orgId]/cloud-tests/status'; import { VendorRiskAssessmentSkeleton } from '@/components/vendor-risk-assessment/VendorRiskAssessmentSkeleton'; import { VendorRiskAssessmentView } from '@/components/vendor-risk-assessment/VendorRiskAssessmentView'; +import { VendorNewsLoadingPlaceholder } from '@/components/vendor-risk-assessment/VendorNewsLoadingPlaceholder'; +import { parseVendorRiskAssessmentDescription } from '@/components/vendor-risk-assessment/parse-vendor-risk-assessment-description'; import { Comments } from '@/components/comments/Comments'; import { RecentAuditLogs } from '@/components/RecentAuditLogs'; import { useAuditLogs } from '@/hooks/use-audit-logs'; @@ -12,12 +14,14 @@ import { useVendor, useVendorActions, type VendorResponse } from '@/hooks/use-ve import { usePermissions } from '@/hooks/use-permissions'; import { SecondaryFields } from './secondary-fields/secondary-fields'; import { VendorResearchBadges, VendorResearchLinks } from './VendorResearchSection'; +import { VendorResearchFeed } from './VendorResearchFeed'; import { VendorInherentRiskChart } from './VendorInherentRiskChart'; import { VendorResidualRiskChart } from './VendorResidualRiskChart'; import type { Member, User, Vendor } from '@db'; import { CommentEntityType } from '@db'; import type { Prisma } from '@db'; import { useRealtimeRun } from '@trigger.dev/react-hooks'; +import { AnimatePresence, motion } from 'motion/react'; import { Breadcrumb, Button, @@ -89,6 +93,8 @@ export function VendorDetailTabs({ const [descriptionValue, setDescriptionValue] = useState(''); const [isMitigationLoading, setIsMitigationLoading] = useState(false); const [isAssessmentLoading, setIsAssessmentLoading] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); + const [activeTab, setActiveTab] = useState(defaultTab); const { data: taskItemsData, mutate: refreshTaskItems } = useTaskItems( vendorId, 'vendor', 1, 50, 'createdAt', 'desc', {}, @@ -130,6 +136,38 @@ export function VendorDetailTabs({ return ['EXECUTING', 'QUEUED', 'PENDING', 'WAITING'].includes(assessmentRun.status); }, [assessmentRun]); + // Extract research progress from trigger.dev run metadata + const researchMetadata = useMemo(() => { + if (!assessmentRun?.metadata) return null; + const meta = assessmentRun.metadata as Record; + type MessageType = 'searching' | 'found' | 'analyzing' | 'error'; + const validTypes = new Set(['searching', 'found', 'analyzing', 'error']); + const rawMessages = (meta.messages as Array<{ text: string; type: string; timestamp: number }>) ?? []; + return { + phase: (meta.phase as string) ?? 'starting', + messages: rawMessages.map((m) => ({ + ...m, + type: (validTypes.has(m.type) ? m.type : 'analyzing') as MessageType, + })), + coreReady: (meta.coreReady as boolean) ?? false, + newsReady: (meta.newsReady as boolean) ?? false, + }; + }, [assessmentRun?.metadata]); + + // Trigger SWR refetch when core data or news data becomes ready + useEffect(() => { + if (researchMetadata?.coreReady) { + setIsRegenerating(false); + void refreshVendor(); + } + }, [researchMetadata?.coreReady, refreshVendor]); + + useEffect(() => { + if (researchMetadata?.newsReady) { + void refreshVendor(); + } + }, [researchMetadata?.newsReady, refreshVendor]); + useEffect(() => { if (!assessmentRun?.status) return; if (assessmentRun.status === 'COMPLETED') { @@ -137,12 +175,14 @@ export function VendorDetailTabs({ void refreshTaskItems(); setAssessmentRunId(null); setAssessmentToken(null); + setIsRegenerating(false); } else if (isFailureRunStatus(assessmentRun.status)) { toast.error('Risk assessment failed. Please try again.'); void refreshVendor(); void refreshTaskItems(); setAssessmentRunId(null); setAssessmentToken(null); + setIsRegenerating(false); } }, [assessmentRun?.status, refreshVendor, refreshTaskItems]); @@ -226,10 +266,12 @@ export function VendorDetailTabs({ try { const result = await triggerAssessment(vendorId); toast.success('Assessment regeneration triggered.'); - refreshVendor(); if (result.runId && result.publicAccessToken) { + setIsRegenerating(true); + setActiveTab('risk-assessment'); handleAssessmentTriggered(result.runId, result.publicAccessToken); } + refreshVendor(); } catch { toast.error('Failed to trigger risk assessment regeneration'); } finally { @@ -241,6 +283,29 @@ export function VendorDetailTabs({ const riskAssessmentUpdatedAt = resolvedVendor.riskAssessmentUpdatedAt ?? null; const showSkeleton = resolvedVendor.status === 'in_progress' || isRiskAssessmentGenerating || isRealtimeRunActive; + // Check if risk assessment data has news + const hasNews = useMemo(() => { + if (!riskAssessmentData) return false; + const parsed = parseVendorRiskAssessmentDescription( + typeof riskAssessmentData === 'string' ? riskAssessmentData : JSON.stringify(riskAssessmentData), + ); + return (parsed?.news?.length ?? 0) > 0; + }, [riskAssessmentData]); + + // Is the vendor currently being researched? (survives page refresh via DB status) + const isVendorInProgress = resolvedVendor.status === 'in_progress'; + + // Determine which phase to show in the risk assessment tab + // Show feed when: + // 1. User just clicked regenerate (immediate, no waiting for realtime), OR + // 2. We have a live realtime run and are waiting for core data, OR + // 3. The vendor is in_progress in DB (page was refreshed during research) + const showResearchFeed = + (isRegenerating && !researchMetadata?.coreReady) || + (isRealtimeRunActive && researchMetadata && !riskAssessmentData) || + (isVendorInProgress && !isRealtimeRunActive); + const showNewsPlaceholder = isRealtimeRunActive && riskAssessmentData && !isRegenerating && !hasNews && researchMetadata && !researchMetadata.newsReady; + return ( <> )} - {!isViewingTask && ( + {!isViewingTask && !isRegenerating && !isVendorInProgress && ( )} + {!isViewingTask && (isRegenerating || isVendorInProgress) && ( +
+ + + + + Researching +
+ )}
{!isViewingTask && ( isEditingDescription ? ( @@ -320,7 +394,7 @@ export function VendorDetailTabs({ ) )} - {!isViewingTask && ( + {!isViewingTask && !isRegenerating && !isVendorInProgress && ( )} @@ -328,7 +402,7 @@ export function VendorDetailTabs({ {isViewingTask ? ( ) : ( - + Overview @@ -353,26 +427,49 @@ export function VendorDetailTabs({ - {riskAssessmentData ? ( - - ) : ( -
- {showSkeleton ? ( - - ) : ( - No risk assessment found yet. - )} -
- )} + + {showResearchFeed ? ( + + + + ) : riskAssessmentData ? ( + + + {showNewsPlaceholder && } + + ) : ( +
+ {showSkeleton ? ( + + ) : ( + No risk assessment found yet. + )} +
+ )} +
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResearchFeed.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResearchFeed.tsx new file mode 100644 index 0000000000..f9f6ed7301 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResearchFeed.tsx @@ -0,0 +1,464 @@ +'use client'; + +import { Text } from '@trycompai/design-system'; +import { Checkmark } from '@trycompai/design-system/icons'; +import { motion } from 'motion/react'; +import { useMemo, useState, useRef, useCallback, useEffect } from 'react'; + +export type MessageType = 'searching' | 'found' | 'analyzing' | 'error'; + +export type ResearchMessage = { + text: string; + type: MessageType; + timestamp: number; + url?: string; +}; + +interface VendorResearchFeedProps { + messages: ResearchMessage[]; + isActive: boolean; + vendorName?: string; +} + +type Finding = { + label: string; + kind: 'cert' | 'link' | 'assessment' | 'news'; + id: string; + url?: string; +}; + +function parseFindings(messages: ResearchMessage[]): Finding[] { + const findings: Finding[] = []; + const seen = new Set(); + + for (const msg of messages) { + if (msg.type !== 'found') continue; + const text = msg.text; + const url = msg.url; + + if (text === 'Security assessment complete') { + if (!seen.has('__assessment__')) { + seen.add('__assessment__'); + findings.push({ + label: 'Security Assessment', + kind: 'assessment', + id: '__assessment__', + }); + } + continue; + } + + if (text.startsWith('Found: ')) { + const title = text.slice(7); + const id = `news-${title}`; + if (!seen.has(id)) { + seen.add(id); + findings.push({ label: title, kind: 'news', id, url }); + } + continue; + } + + const match = text.match(/^Found (.+?)(?:\s+certification)?$/); + if (match) { + const name = match[1]!; + const id = name.toLowerCase(); + if (seen.has(id)) continue; + seen.add(id); + + const linkKeywords = [ + 'trust', + 'privacy', + 'terms', + 'security overview', + 'soc 2 report', + ]; + const isLink = linkKeywords.some((kw) => + name.toLowerCase().includes(kw), + ); + findings.push({ label: name, kind: isLink ? 'link' : 'cert', id, url }); + } + } + + return findings; +} + +/** Build scan path using measured pixel positions relative to grid. */ +function buildScanPath( + pendingIndices: number[], + centers: Array<{ x: number; y: number }>, +) { + const tops: string[] = []; + const lefts: string[] = []; + const times: number[] = []; + + // Grid indices: 0=TL, 1=TR, 2=BL, 3=BR + // Clockwise visual order: TL(0) → TR(1) → BR(3) → BL(2) + const clockwiseOrder = [0, 1, 3, 2]; + const pending = clockwiseOrder.filter( + (i) => pendingIndices.includes(i) && centers[i], + ); + if (pending.length === 0) return { tops, lefts, times }; + + const n = pending.length; + const circleRadiusPx = 25; + const circleFraction = n === 1 ? 0.85 : 0.7 / n; + const travelFraction = n === 1 ? 0.15 : 0.3 / n; + const steps = 32; + let t = 0; + + for (const idx of pending) { + const c = centers[idx]!; + for (let s = 0; s <= steps; s++) { + const progress = s / steps; + const angle = progress * Math.PI * 2; + const ramp = Math.sin(progress * Math.PI); + const dx = Math.round(circleRadiusPx * ramp * Math.sin(angle)); + const dy = Math.round(-circleRadiusPx * ramp * Math.cos(angle)); + tops.push(`${c.y + dy}px`); + lefts.push(`${c.x + dx}px`); + times.push(t + circleFraction * progress); + } + t += circleFraction + travelFraction; + } + + const first = centers[pending[0]!]!; + tops.push(`${first.y}px`); + lefts.push(`${first.x}px`); + times.push(1); + + return { tops, lefts, times }; +} + +function ScanningGlass({ + onCardChange, + pendingIndices, + gridRef, + cardRefs, +}: { + onCardChange: (index: number) => void; + pendingIndices: number[]; + gridRef: React.RefObject; + cardRefs: React.RefObject>; +}) { + const [centers, setCenters] = useState>([]); + const lastCardRef = useRef(-1); + + // Measure card centers relative to grid + useEffect(() => { + const measure = () => { + const grid = gridRef.current; + const cards = cardRefs.current; + if (!grid || !cards) return; + const gridRect = grid.getBoundingClientRect(); + setCenters( + cards.map((card) => { + if (!card) return { x: 0, y: 0 }; + const r = card.getBoundingClientRect(); + return { + x: r.left - gridRect.left + r.width / 2, + y: r.top - gridRect.top + r.height / 2, + }; + }), + ); + }; + measure(); + const obs = new ResizeObserver(measure); + if (gridRef.current) obs.observe(gridRef.current); + cardRefs.current?.forEach((c) => c && obs.observe(c)); + return () => obs.disconnect(); + }, [gridRef, cardRefs, pendingIndices]); + + const { tops, lefts, times } = useMemo( + () => buildScanPath(pendingIndices, centers), + [pendingIndices, centers], + ); + + if (tops.length === 0 || centers.length === 0) return null; + + const duration = pendingIndices.length === 1 ? 4 : pendingIndices.length * 3; + + return ( + `${Math.round(c.x)},${Math.round(c.y)}`).join('|')}`} + className="pointer-events-none absolute z-10 -translate-x-[18px] -translate-y-[18px]" + animate={{ top: tops, left: lefts }} + transition={{ + duration, + repeat: Number.POSITIVE_INFINITY, + ease: 'easeInOut', + times, + }} + onUpdate={(latest) => { + const val = (v: unknown) => Number.parseFloat(String(v)); + const top = val(latest.top); + const left = val(latest.left); + if (Number.isNaN(top) || Number.isNaN(left) || centers.length === 0) + return; + let closest = -1; + let minDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < centers.length; i++) { + const c = centers[i]!; + const dist = (top - c.y) ** 2 + (left - c.x) ** 2; + if (dist < minDist) { + minDist = dist; + closest = i; + } + } + if (closest !== lastCardRef.current) { + lastCardRef.current = closest; + onCardChange(closest); + } + }} + > + + + + + + + + ); +} + +function CategoryCard({ + label, + items, + isActive, + color, + highlighted, + cardRef, +}: { + label: string; + items: Finding[]; + isActive: boolean; + color: 'success' | 'primary'; + highlighted: boolean; + cardRef: (el: HTMLDivElement | null) => void; +}) { + const done = items.length > 0; + + return ( + +
+
+ {done ? ( + + ) : isActive ? ( + + ) : ( + + )} + + {label} + +
+ {done && ( + + {items.length} found + + )} +
+ + {done && color === 'success' ? ( +
+ {items.map((item, i) => ( + window.open(item.url, '_blank', 'noopener,noreferrer') : undefined} + > + + {item.label} + + ))} +
+ ) : done ? ( +
+ {items.map((item, i) => ( + + {item.url ? ( + + {item.label} + + ) : ( + + {item.label} + + )} + + ))} +
+ ) : isActive ? ( +
+
+
+
+
+ ) : null} + + ); +} + +export function VendorResearchFeed({ + messages, + isActive, + vendorName, +}: VendorResearchFeedProps) { + const findings = useMemo(() => parseFindings(messages), [messages]); + const [activeCard, setActiveCard] = useState(-1); + const handleCardChange = useCallback((index: number) => { + setActiveCard(index); + }, []); + + const certs = findings.filter((f) => f.kind === 'cert'); + const links = findings.filter((f) => f.kind === 'link'); + const assessments = findings.filter((f) => f.kind === 'assessment'); + const news = findings.filter((f) => f.kind === 'news'); + const totalFindings = findings.length; + + const pendingIndices = useMemo(() => { + const indices: number[] = []; + if (certs.length === 0) indices.push(0); + if (links.length === 0) indices.push(1); + if (assessments.length === 0) indices.push(2); + if (news.length === 0) indices.push(3); + return indices; + }, [certs.length, links.length, assessments.length, news.length]); + + // Refs for measuring card positions dynamically + const gridRef = useRef(null); + const cardRefs = useRef>([null, null, null, null]); + const setCardRef = useCallback( + (index: number) => (el: HTMLDivElement | null) => { + cardRefs.current[index] = el; + }, + [], + ); + + return ( +
+ {/* Header */} +
+
+ + {isActive + ? `Researching ${vendorName ?? 'vendor'} security posture` + : 'Research complete'} + + {isActive && ( + + This may take 1-10 minutes depending on the vendor. You can leave this page, we'll notify you when it's done. + + )} +
+
+ {totalFindings > 0 && ( + + {totalFindings} {totalFindings === 1 ? 'finding' : 'findings'} + + )} +
+
+ + {/* Category cards grid — with scanning glass overlay */} +
+ {isActive && pendingIndices.length > 0 && ( + + )} + + + + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResearchSection.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResearchSection.tsx index c03938582c..843d095c54 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResearchSection.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResearchSection.tsx @@ -63,11 +63,13 @@ export function VendorResearchBadges({ riskAssessmentData }: VendorResearchProps if (certifications.length === 0) return null; + const withIcons = certifications.filter((cert) => getCertificationIcon(cert)); + const withoutIcons = certifications.length - withIcons.length; + return (
- {certifications.map((cert, index) => { - const IconComponent = getCertificationIcon(cert); - if (!IconComponent) return null; + {withIcons.map((cert, index) => { + const IconComponent = getCertificationIcon(cert)!; const iconContent = (
{iconContent}
; })} + {withoutIcons > 0 && ( + + +{withoutIcons} more + + )}
); } diff --git a/apps/app/src/components/vendor-risk-assessment/VendorNewsLoadingPlaceholder.tsx b/apps/app/src/components/vendor-risk-assessment/VendorNewsLoadingPlaceholder.tsx new file mode 100644 index 0000000000..8b743ceb0f --- /dev/null +++ b/apps/app/src/components/vendor-risk-assessment/VendorNewsLoadingPlaceholder.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle, Text } from '@trycompai/design-system'; +import { Skeleton } from '@trycompai/ui/skeleton'; + +export function VendorNewsLoadingPlaceholder() { + return ( +
+ + + +
+ + + + + Gathering recent news... +
+
+
+ +
+ + + +
+
+
+
+ ); +} diff --git a/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx b/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx index d959c8f495..77415fa2ad 100644 --- a/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx +++ b/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx @@ -11,7 +11,7 @@ function CertificationRow({ cert }: { cert: VendorRiskAssessmentCertification }) cert.status === 'verified' ? (
) : cert.status === 'expired' ? ( -
+
) : cert.status === 'not_certified' ? (
) : ( diff --git a/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentView.tsx b/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentView.tsx index 56dfdf2602..610edfebce 100644 --- a/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentView.tsx +++ b/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentView.tsx @@ -3,6 +3,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from '@trycompai/design-system'; import { Launch, Security } from '@trycompai/design-system/icons'; import { useMemo } from 'react'; +import { motion } from 'motion/react'; import { parseVendorRiskAssessmentDescription } from './parse-vendor-risk-assessment-description'; import { filterCertifications } from './filter-certifications'; import { VendorRiskAssessmentCertificationsCard } from './VendorRiskAssessmentCertificationsCard'; @@ -34,65 +35,89 @@ export function VendorRiskAssessmentView({ source }: { source: VendorRiskAssessm return (
- - - -
- - Security Assessment -
-
-
- - {data?.securityAssessment ? ( - - ) : ( -

- No automated security assessment found. -

- )} -
-
- - {certifications.length > 0 && ( - - )} - - {links.length > 0 && ( + -
Links
+
+ + Security Assessment +
-
- {links.map((link, index) => ( - - ))} -
+ {data?.securityAssessment ? ( + + ) : ( +

+ No automated security assessment found. +

+ )}
+
+ + {certifications.length > 0 && ( + + + + )} + + {links.length > 0 && ( + + + + +
Security Links
+
+
+ +
+ {links.map((link, index) => ( + + ))} +
+
+
+
)} - + + +
); } diff --git a/apps/app/src/components/vendor-risk-assessment/filter-certifications.ts b/apps/app/src/components/vendor-risk-assessment/filter-certifications.ts index 2f6afd0ca1..d512e67634 100644 --- a/apps/app/src/components/vendor-risk-assessment/filter-certifications.ts +++ b/apps/app/src/components/vendor-risk-assessment/filter-certifications.ts @@ -1,12 +1,9 @@ import type { VendorRiskAssessmentCertification } from './vendor-risk-assessment-types'; /** - * Filter certifications to only show specific ones: - * - ISO 27001 (with partial matching: includes "iso" and "27001") - * - ISO 42001 (with partial matching: includes "iso" and "42001") - * - SOC 2 Type 1 (exact match) - * - SOC 2 Type 2 (exact match) - * - HIPAA (exact match) + * Return all certifications that have a non-empty type string. + * Previously this was a hardcoded whitelist (SOC 2, ISO 27001, HIPAA only), + * which silently dropped valid certs like FedRAMP, TISAX, C5, ISO 27017, etc. */ export function filterCertifications( certifications: VendorRiskAssessmentCertification[] | null | undefined, @@ -15,43 +12,8 @@ export function filterCertifications( return []; } - return certifications.filter((cert) => { - const typeLower = cert.type.toLowerCase().trim(); - - // ISO 27001 - partial matching - if (typeLower.includes('iso') && typeLower.includes('27001')) { - return true; - } - - // ISO 42001 - partial matching - if (typeLower.includes('iso') && typeLower.includes('42001')) { - return true; - } - - // SOC 2 Type 1 - check for "soc" and "type 1" or "type i" - if ( - typeLower.includes('soc') && - (typeLower.includes('type 1') || typeLower.includes('type i')) && - !typeLower.includes('type 2') && - !typeLower.includes('type ii') - ) { - return true; - } - - // SOC 2 Type 2 - check for "soc" and "type 2" or "type ii" - if ( - typeLower.includes('soc') && - (typeLower.includes('type 2') || typeLower.includes('type ii')) - ) { - return true; - } - - // HIPAA - exact match (case insensitive) - if (typeLower === 'hipaa' || typeLower === 'hipa') { - return true; - } - - return false; - }); + return certifications.filter( + (cert) => cert.type && cert.type.trim().length > 0, + ); }