From d164c77e32c304153713647e32b410f9e7233498 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:06:39 -0400 Subject: [PATCH 1/4] fix: Statement of Applicability only allows you to change answers from Yes to No and not the other way around CS-208: [Bug] - Statement of Applicability only allows you to change answers from Yes to No and not the other way around --- .../soa/components/ApplicableSwatch.tsx | 27 +++++++++ .../soa/components/EditableSOAFields.tsx | 45 +++++++-------- .../soa/components/SOAFrameworkTable.tsx | 8 ++- .../soa/components/SOAMobileRow.tsx | 39 ++++++++----- .../questionnaire/soa/components/SOATable.tsx | 9 ++- .../soa/components/SOATableRow.tsx | 55 +++++++++++-------- .../soa/components/soa-field-types.ts | 11 ++++ 7 files changed, 127 insertions(+), 67 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/ApplicableSwatch.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/soa-field-types.ts 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'} - + - ))} -
+ {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, + ); } From 32a97fab8d89dc90745bc9663054280fe3f7658b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:11:10 -0400 Subject: [PATCH 4/4] fix(trust-portal): replace networkcalc.com DNS checks with Node built-in resolver (#2463) The DNS verification for custom trust portal domains was failing because it relied on networkcalc.com, an unreliable external API. This caused domainVerified to stay false, which made the trust portal middleware redirect custom domains to trycomp.ai instead of serving the portal. Replaced with Node's built-in dns.promises resolver (matching the existing server action pattern). Also removed axios dependency from the service, replacing it with native fetch for Vercel API calls. Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/trust-portal/trust-portal.service.ts | 272 ++++++++---------- 1 file changed, 127 insertions(+), 145 deletions(-) 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(