diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index 83f6d1bd68..ea6ed4d449 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -18,6 +18,10 @@ import { ac, allRoles } from '@trycompai/auth'; import { createAuthMiddleware } from 'better-auth/api'; import { Redis } from '@upstash/redis'; import type { AccessControl } from 'better-auth/plugins/access'; +import { + resolveMicrosoftEmail, + type MicrosoftEmailClaims, +} from './microsoft-email'; const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 60; // 1 hour @@ -184,6 +188,13 @@ if ( clientSecret: process.env.AUTH_MICROSOFT_CLIENT_SECRET, tenantId: process.env.AUTH_MICROSOFT_TENANT_ID || 'common', prompt: 'select_account', + // Microsoft Entra often omits the `email` claim for work/school accounts, + // which makes better-auth abort sign-in with `email_not_found`. Fall back to + // the username/UPN claims so these users can sign in. Accounts that DO return + // an `email` claim are unaffected. See ./microsoft-email.ts. + mapProfileToUser: (profile: MicrosoftEmailClaims) => ({ + email: resolveMicrosoftEmail(profile), + }), }; } diff --git a/apps/api/src/auth/microsoft-email.spec.ts b/apps/api/src/auth/microsoft-email.spec.ts new file mode 100644 index 0000000000..dfc525a392 --- /dev/null +++ b/apps/api/src/auth/microsoft-email.spec.ts @@ -0,0 +1,84 @@ +import { resolveMicrosoftEmail } from './microsoft-email'; + +describe('resolveMicrosoftEmail', () => { + it('returns the email claim unchanged when present (no regression for working accounts)', () => { + expect( + resolveMicrosoftEmail({ + email: 'real@corp.com', + preferred_username: 'login@corp.onmicrosoft.com', + upn: 'upn@corp.com', + }), + ).toBe('real@corp.com'); + }); + + it('falls back to preferred_username when the email claim is missing', () => { + expect( + resolveMicrosoftEmail({ + preferred_username: 'login@corp.com', + upn: 'upn@corp.com', + }), + ).toBe('login@corp.com'); + }); + + it('falls back to upn when email and preferred_username are missing', () => { + expect(resolveMicrosoftEmail({ upn: 'upn@corp.com' })).toBe('upn@corp.com'); + }); + + it('returns undefined when no identifier is present (still fails loudly)', () => { + expect(resolveMicrosoftEmail({})).toBeUndefined(); + }); + + it('treats empty / whitespace-only claims as missing and falls back', () => { + expect( + resolveMicrosoftEmail({ email: ' ', preferred_username: 'login@corp.com' }), + ).toBe('login@corp.com'); + }); + + it('handles null claims (Entra may send null) and falls back', () => { + expect( + resolveMicrosoftEmail({ + email: null, + preferred_username: null, + upn: 'upn@corp.com', + }), + ).toBe('upn@corp.com'); + }); + + it('trims surrounding whitespace from the chosen value', () => { + expect(resolveMicrosoftEmail({ email: ' real@corp.com ' })).toBe( + 'real@corp.com', + ); + }); + + it('returns undefined when every claim is empty/whitespace', () => { + expect( + resolveMicrosoftEmail({ email: '', preferred_username: ' ', upn: '' }), + ).toBeUndefined(); + }); + + it('prefers preferred_username over upn when both are present (and email absent)', () => { + expect( + resolveMicrosoftEmail({ preferred_username: 'pref@corp.com', upn: 'upn@corp.com' }), + ).toBe('pref@corp.com'); + }); + + it('does not crash on a non-string claim (untrusted JWT) and falls back', () => { + // The decoded ID token is attacker-influenced; a non-string claim must not + // throw (e.g. .trim() on a number). Cast through unknown to simulate it. + const malformed = { + email: 12345, + preferred_username: { spoofed: true }, + upn: 'upn@corp.com', + } as unknown as Parameters[0]; + expect(resolveMicrosoftEmail(malformed)).toBe('upn@corp.com'); + }); + + it('returns undefined when all claims are present but none are usable strings', () => { + const malformed = { + email: 0, + preferred_username: null, + upn: false, + } as unknown as Parameters[0]; + expect(resolveMicrosoftEmail(malformed)).toBeUndefined(); + }); +}); diff --git a/apps/api/src/auth/microsoft-email.ts b/apps/api/src/auth/microsoft-email.ts new file mode 100644 index 0000000000..4c0e36a04f --- /dev/null +++ b/apps/api/src/auth/microsoft-email.ts @@ -0,0 +1,51 @@ +/** + * Resolve a usable email address for a Microsoft (Entra ID) sign-in. + * + * better-auth's built-in `microsoft` provider derives the user's email solely + * from the ID token's `email` claim (see `@better-auth/core` + * `social-providers/microsoft-entra-id`: `email: user.email`). Microsoft Entra + * only emits that claim when the account has a `mail` attribute set, or when + * `email` is configured as an optional claim in the app registration. Many + * work/school accounts therefore arrive with NO `email` claim — and better-auth + * then aborts the sign-in with `error=email_not_found`, bouncing the user to the + * API root (Swagger) instead of the app. + * + * We fall back to the username claims (`preferred_username`, then `upn`), which + * are the email-form login for Entra accounts. This mirrors better-auth's own + * `microsoftEntraId` generic-oauth helper (`profile.email ?? profile.preferred_username`). + * + * Safety: + * - When Microsoft DOES return an `email` claim, this returns it unchanged — + * accounts that already work are unaffected. + * - The input is a decoded, attacker-influenced JWT, so each claim is validated + * to actually be a non-empty string at runtime (a malformed token with a + * non-string claim must not crash sign-in). + * - Returns `undefined` only when no usable identifier is present, so a + * genuinely identifier-less account still fails loudly rather than signing in + * with an empty email. + */ +export interface MicrosoftEmailClaims { + email?: string | null; + preferred_username?: string | null; + upn?: string | null; +} + +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + } + return undefined; +} + +export function resolveMicrosoftEmail( + profile: MicrosoftEmailClaims, +): string | undefined { + return firstNonEmptyString( + profile.email, + profile.preferred_username, + profile.upn, + ); +} diff --git a/apps/api/src/cloud-security/cloud-security.service.scan-gcp.spec.ts b/apps/api/src/cloud-security/cloud-security.service.scan-gcp.spec.ts new file mode 100644 index 0000000000..f4edb98592 --- /dev/null +++ b/apps/api/src/cloud-security/cloud-security.service.scan-gcp.spec.ts @@ -0,0 +1,258 @@ +jest.mock('@db', () => ({ db: {}, Prisma: {} })); + +const mockGetManifest = jest.fn(); +const mockRunAllChecks = jest.fn(); +jest.mock('@trycompai/integration-platform', () => ({ + getManifest: (...args: unknown[]) => mockGetManifest(...args), + runAllChecks: (...args: unknown[]) => mockRunAllChecks(...args), +})); + +import { + CloudSecurityService, + type SecurityFinding, +} from './cloud-security.service'; +import { GCP_SCAN_MODE_DIRECT, GCP_SCAN_MODE_SCC } from './gcp-scan-fallback'; + +type Ctor = ConstructorParameters; + +interface ScanGcpParams { + credentials: Record; + variables: Record; + enabledServices: string[] | undefined; + disabledServices: ReadonlySet; + connectionId: string; + organizationId: string; + providerSlug: string; +} + +function callScanGcp( + service: CloudSecurityService, + params: ScanGcpParams, +): Promise<{ findings: SecurityFinding[]; scanMode: string }> { + return ( + service as unknown as { + scanGcp: (p: ScanGcpParams) => Promise<{ + findings: SecurityFinding[]; + scanMode: string; + }>; + } + ).scanGcp(params); +} + +const PARAMS: ScanGcpParams = { + credentials: { access_token: 'tok' }, + variables: { project_ids: ['p1', 'p2'] }, + enabledServices: undefined, + disabledServices: new Set(), + connectionId: 'icn_1', + organizationId: 'org_1', + providerSlug: 'gcp', +}; + +const directCheckResult = ( + status: 'success' | 'failed' | 'error', + error?: string, +) => ({ + durationMs: 1, + totalFindings: status === 'failed' ? 1 : 0, + totalPassing: status === 'success' ? 1 : 0, + results: [ + { + checkId: 'storage-public-access', + checkName: 'Storage public access', + status, + error, + durationMs: 1, + result: { + logs: [], + summary: { + totalChecked: 1, + passed: status === 'success' ? 1 : 0, + failed: status === 'failed' ? 1 : 0, + }, + findings: + status === 'failed' + ? [ + { + status: 'open' as const, + title: 'Bucket publicly accessible: b1', + description: 'public', + resourceType: 'gcp-storage-bucket', + resourceId: 'p1/b1', + severity: 'high' as const, + remediation: 'fix it', + evidence: { bucket: 'b1' }, + }, + ] + : [], + passingResults: + status === 'success' + ? [ + { + collectedAt: new Date(), + title: 'Bucket private: b2', + description: 'ok', + resourceType: 'gcp-storage-bucket', + resourceId: 'p1/b2', + evidence: { bucket: 'b2' }, + }, + ] + : [], + }, + }, + ], +}); + +describe('CloudSecurityService.scanGcp', () => { + let gcpService: { scanSecurityFindings: jest.Mock }; + let service: CloudSecurityService; + + beforeEach(() => { + jest.clearAllMocks(); + gcpService = { scanSecurityFindings: jest.fn() }; + mockGetManifest.mockReturnValue({ + checks: [{ id: 'storage-public-access' }], + }); + service = new CloudSecurityService( + {} as unknown as Ctor[0], + {} as unknown as Ctor[1], + gcpService as unknown as Ctor[2], + {} as unknown as Ctor[3], + {} as unknown as Ctor[4], + {} as unknown as Ctor[5], + ); + }); + + const sccFinding: SecurityFinding = { + id: 'scc-1', + title: 'Public Bucket Acl', + description: 'x', + severity: 'high', + resourceType: 'gcp-resource', + resourceId: 'r1', + evidence: { findingKey: 'gcp-cloud-storage-public-bucket-acl' }, + createdAt: new Date().toISOString(), + }; + + it('combines our direct findings with SCC into one list, tagged gcp_scc, when SCC works', async () => { + mockRunAllChecks.mockResolvedValue(directCheckResult('failed')); + gcpService.scanSecurityFindings.mockResolvedValue([sccFinding]); + + const result = await callScanGcp(service, PARAMS); + + expect(result.scanMode).toBe(GCP_SCAN_MODE_SCC); + // One combined list: 1 direct finding + 1 SCC finding (no source split). + expect(result.findings).toHaveLength(2); + expect(result.findings.map((f) => f.title)).toEqual( + expect.arrayContaining([ + 'Bucket publicly accessible: b1', + 'Public Bucket Acl', + ]), + ); + expect(mockRunAllChecks).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'Security Command Center Legacy has been disabled by Google. Please activate the Standard or Premium tier.', + 'SCC_NOT_ACTIVATED: Security Command Center is not activated for project p1.', + 'Permission denied. Grant "Security Center Findings Viewer" role at the organization level.', + ])( + 'shows direct-API checks only (tagged gcp_direct) when SCC is structurally unavailable: %s', + async (message) => { + mockRunAllChecks.mockResolvedValue(directCheckResult('failed')); + gcpService.scanSecurityFindings.mockRejectedValue(new Error(message)); + + const result = await callScanGcp(service, PARAMS); + + expect(result.scanMode).toBe(GCP_SCAN_MODE_DIRECT); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + title: 'Bucket publicly accessible: b1', + passed: false, + }); + }, + ); + + it('shows direct-API checks only when SCC errors transiently (does not abort)', async () => { + mockRunAllChecks.mockResolvedValue(directCheckResult('failed')); + gcpService.scanSecurityFindings.mockRejectedValue( + new Error('GCP API error (500): internal error'), + ); + + const result = await callScanGcp(service, PARAMS); + + expect(result.scanMode).toBe(GCP_SCAN_MODE_DIRECT); + expect(result.findings).toHaveLength(1); + }); + + it('throws (preserves prior run) when our OWN checks all error, even if SCC works', async () => { + mockRunAllChecks.mockResolvedValue( + directCheckResult('error', 'invalid_grant: token expired'), + ); + gcpService.scanSecurityFindings.mockResolvedValue([sccFinding]); + + await expect(callScanGcp(service, PARAMS)).rejects.toThrow( + /GCP direct-API checks all failed/, + ); + }); + + it('returns direct passing findings (clean account) when SCC is unavailable', async () => { + mockRunAllChecks.mockResolvedValue(directCheckResult('success')); + gcpService.scanSecurityFindings.mockRejectedValue( + new Error('Security Command Center Legacy has been disabled by Google.'), + ); + + const result = await callScanGcp(service, PARAMS); + + expect(result.scanMode).toBe(GCP_SCAN_MODE_DIRECT); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + passed: true, + severity: 'info', + }); + }); + + it('excludes checks for a disabled service from the direct-API run', async () => { + mockGetManifest.mockReturnValue({ + checks: [ + { id: 'gcp-storage-no-public-access' }, // → cloud-storage + { id: 'gcp-iam-no-primitive-roles' }, // → iam + ], + }); + mockRunAllChecks.mockResolvedValue(directCheckResult('failed')); + gcpService.scanSecurityFindings.mockRejectedValue( + new Error('Security Command Center Legacy has been disabled by Google.'), + ); + + await callScanGcp(service, { + ...PARAMS, + disabledServices: new Set(['cloud-storage']), + }); + + // Only the iam check is handed to the runner — the disabled storage check is dropped. + const firstCallArg = mockRunAllChecks.mock.calls[0][0] as { + manifest: { checks: Array<{ id: string }> }; + }; + expect(firstCallArg.manifest.checks.map((c) => c.id)).toEqual([ + 'gcp-iam-no-primitive-roles', + ]); + }); + + it('runs no direct checks (returns []) when every covered service is disabled', async () => { + mockGetManifest.mockReturnValue({ + checks: [{ id: 'gcp-storage-no-public-access' }], // only cloud-storage + }); + gcpService.scanSecurityFindings.mockRejectedValue( + new Error('Security Command Center Legacy has been disabled by Google.'), + ); + + const result = await callScanGcp(service, { + ...PARAMS, + disabledServices: new Set(['cloud-storage']), + }); + + expect(result.findings).toEqual([]); + expect(result.scanMode).toBe(GCP_SCAN_MODE_DIRECT); + expect(mockRunAllChecks).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/cloud-security/cloud-security.service.ts b/apps/api/src/cloud-security/cloud-security.service.ts index 3a433cc026..95daa49a96 100644 --- a/apps/api/src/cloud-security/cloud-security.service.ts +++ b/apps/api/src/cloud-security/cloud-security.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { db, Prisma } from '@db'; -import { getManifest } from '@trycompai/integration-platform'; +import { getManifest, runAllChecks } from '@trycompai/integration-platform'; import { runs, tasks } from '@trigger.dev/sdk'; import { CredentialVaultService } from '../integration-platform/services/credential-vault.service'; import { OAuthCredentialsService } from '../integration-platform/services/oauth-credentials.service'; @@ -9,7 +9,17 @@ import { AWSSecurityService } from './providers/aws-security.service'; import { AzureSecurityService } from './providers/azure-security.service'; import { AWS_SERVICE_TASK_MAPPINGS } from './aws-task-mappings'; import { CloudReconciliationService } from './reconciliation.service'; -import { type AwsScanMode, resolveAwsScanMode } from './aws-scan-mode'; +import { resolveAwsScanMode } from './aws-scan-mode'; +import { + GCP_SCAN_MODE_DIRECT, + GCP_SCAN_MODE_SCC, + formatCheckLog, + gcpCheckResultsToFindings, + isGcpCheckServiceDisabled, + isSccStructurallyUnavailable, + toCheckCredentials, + toCheckVariables, +} from './gcp-scan-fallback'; export interface SecurityFinding { id: string; @@ -273,26 +283,37 @@ export class CloudSecurityService { } } - // AWS-only — which engine produced this scan. Persisted on the run - // so reconciliation only diffs like-for-like (cross-mode findingKeys - // live in different namespaces). Null for GCP / Azure runs. - let awsScanMode: AwsScanMode | null = null; + // Which detection engine produced this scan. Persisted on the run so + // reconciliation only diffs like-for-like — different engines emit + // findingKeys in different namespaces, so a cross-engine diff would mark + // every prior finding falsely "resolved". Null for Azure. + // - AWS: 'comp_scanners' | 'security_hub' + // - GCP: 'gcp_scc' (Security Command Center) | 'gcp_direct' (API checks) + let runScanMode: string | null = null; switch (providerSlug) { - case 'gcp': - findings = await this.gcpService.scanSecurityFindings( + case 'gcp': { + const gcp = await this.scanGcp({ credentials, variables, enabledServices, - ); + disabledServices, + connectionId, + organizationId, + providerSlug, + }); + findings = gcp.findings; + runScanMode = gcp.scanMode; break; + } case 'aws': { // AWS scan-mode lives on connection.metadata (non-secret, frontend- // readable); credentials are encrypted blobs intended for the AWS // SDK. Read from metadata so a single source of truth. const metadata = (connection.metadata as Record | null) ?? {}; - awsScanMode = resolveAwsScanMode(metadata.awsScanMode); + const awsScanMode = resolveAwsScanMode(metadata.awsScanMode); + runScanMode = awsScanMode; findings = await this.awsService.scanSecurityFindings( credentials, variables, @@ -323,7 +344,7 @@ export class CloudSecurityService { connectionId, providerSlug, findings, - awsScanMode, + runScanMode, ); // Reconcile against the prior scan to record resolutions and regressions. @@ -416,6 +437,171 @@ export class CloudSecurityService { } } + /** + * GCP Cloud Tests scan = our direct-API checks (always) + Security Command + * Center (when available), combined into one finding list. + * + * Our direct-API manifest checks (storage / IAM / firewall / Cloud SQL / …) + * read the GCP APIs directly with the OAuth token, so they always work and + * are the baseline. SCC is a supplement that adds its own findings (60+ + * detector categories) on top. + * + * Google retired the free "Legacy" SCC tier, so orgs that haven't activated + * SCC Standard/Premium error on every SCC query. That must NOT fail the scan + * — it previously aborted everything and froze the dashboard (CS issue, + * bevri.ai 2026-06). So an SCC error just drops the SCC layer for that run; + * the customer still gets the direct-API checks. + * + * Both sources run in parallel. The only hard failure is when our OWN checks + * can't run at all (e.g. a dead token) — `scanGcpDirectChecks` throws then so + * the outer catch preserves the prior good run rather than storing nothing. + * + * The returned scanMode tags the run by whether SCC contributed, so + * reconciliation never diffs an SCC-bearing run (findings carry findingKeys) + * against a direct-only run (no findingKeys) — a cross-source diff would mark + * every prior finding falsely "resolved". + */ + private async scanGcp(params: { + credentials: Record; + variables: Record; + enabledServices: string[] | undefined; + disabledServices: ReadonlySet; + connectionId: string; + organizationId: string; + providerSlug: string; + }): Promise<{ findings: SecurityFinding[]; scanMode: string }> { + const { + credentials, + variables, + enabledServices, + disabledServices, + connectionId, + organizationId, + providerSlug, + } = params; + + const [directResult, sccResult] = await Promise.allSettled([ + this.scanGcpDirectChecks({ + credentials, + variables, + disabledServices, + connectionId, + organizationId, + providerSlug, + }), + this.gcpService.scanSecurityFindings( + credentials, + variables, + enabledServices, + ), + ]); + + // Our checks are the baseline. If they couldn't run at all, fail the scan so + // the prior good run is preserved (don't overwrite it with a thin result). + if (directResult.status === 'rejected') { + throw directResult.reason; + } + const directFindings = directResult.value; + + // SCC is best-effort. When it works, append its findings (one combined + // list); when it doesn't, log why and show the direct-API checks only. + if (sccResult.status === 'fulfilled') { + return { + findings: [...directFindings, ...sccResult.value], + scanMode: GCP_SCAN_MODE_SCC, + }; + } + + const err = sccResult.reason; + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + isSccStructurallyUnavailable(err) + ? `GCP SCC not available for connection ${connectionId} (${message}); showing direct-API checks only` + : `GCP SCC scan errored for connection ${connectionId} (${message}); showing direct-API checks only this run`, + ); + return { findings: directFindings, scanMode: GCP_SCAN_MODE_DIRECT }; + } + + /** + * Run the GCP integration-platform manifest checks (direct GCP API reads) + * in-process and convert their results to SecurityFindings. This is the same + * check set the `run-connection-checks` task runs; we run it here so the + * manual "Scan" button refreshes findings even when SCC is dead. + * + * Honors the per-service disable toggle: checks mapped to a service the user + * disabled are skipped (matching how the SCC path drops disabled services), so + * a combined run never shows findings for a service the customer turned off. + * + * Guardrail: if EVERY check that ran errored (e.g. an invalid token or missing + * read access), throw instead of returning [] — an empty result would store a + * fresh "success" run with zero findings, hiding the prior good run and + * false-resolving every finding. Throwing lets the outer catch preserve the + * prior run. (Zero checks because the user disabled everything is NOT a + * failure — that returns [] legitimately.) + */ + private async scanGcpDirectChecks(params: { + credentials: Record; + variables: Record; + disabledServices: ReadonlySet; + connectionId: string; + organizationId: string; + providerSlug: string; + }): Promise { + const { + credentials, + variables, + disabledServices, + connectionId, + organizationId, + providerSlug, + } = params; + + const manifest = getManifest(providerSlug); + if (!manifest?.checks || manifest.checks.length === 0) { + throw new Error( + `GCP direct-API checks unavailable: no manifest checks for ${providerSlug}`, + ); + } + + const enabledChecks = manifest.checks.filter( + (check) => !isGcpCheckServiceDisabled(check.id, disabledServices), + ); + if (enabledChecks.length === 0) { + // The user disabled every service these checks cover — scan nothing here + // (intentional empty, not a failure). SCC may still contribute findings. + return []; + } + + const accessToken = + typeof credentials.access_token === 'string' + ? credentials.access_token + : undefined; + + const result = await runAllChecks({ + manifest: { ...manifest, checks: enabledChecks }, + accessToken, + credentials: toCheckCredentials(credentials), + variables: toCheckVariables(variables), + connectionId, + organizationId, + logger: { + info: (msg, data) => this.logger.log(formatCheckLog(msg, data)), + warn: (msg, data) => this.logger.warn(formatCheckLog(msg, data)), + error: (msg, data) => this.logger.error(formatCheckLog(msg, data)), + }, + }); + + const anyCheckSucceeded = result.results.some((r) => r.status !== 'error'); + if (!anyCheckSucceeded) { + const firstError = result.results.find((r) => r.error)?.error; + throw new Error( + `GCP direct-API checks all failed${firstError ? `: ${firstError}` : ''}`, + ); + } + + return gcpCheckResultsToFindings(result); + } + /** * Resolve short-lived, customer-scoped AWS credentials for a connection's * IAM role — performed here in ECS, which holds the roleAssumer task role and @@ -668,9 +854,11 @@ export class CloudSecurityService { connectionId: string, provider: string, findings: SecurityFinding[], - // AWS only — which engine produced these findings. Stored on the run so - // reconciliation can avoid cross-mode diffs. Null for GCP / Azure runs. - awsScanMode: AwsScanMode | null, + // Detection engine that produced these findings — AWS scan mode + // ('comp_scanners'/'security_hub') or GCP source ('gcp_scc'/'gcp_direct'). + // Stored on the run so reconciliation only diffs same-engine runs. Null for + // Azure / untagged runs. + scanMode: string | null, ): Promise { const passedCount = findings.filter((f) => f.passed).length; const failedCount = findings.filter((f) => !f.passed).length; @@ -705,7 +893,7 @@ export class CloudSecurityService { passedCount, failedCount, scannedServices, - scanMode: awsScanMode, + scanMode, }, }); diff --git a/apps/api/src/cloud-security/gcp-ai-remediation.prompt.ts b/apps/api/src/cloud-security/gcp-ai-remediation.prompt.ts index 77667c2ac9..6ea5f18226 100644 --- a/apps/api/src/cloud-security/gcp-ai-remediation.prompt.ts +++ b/apps/api/src/cloud-security/gcp-ai-remediation.prompt.ts @@ -70,7 +70,7 @@ export type GcpFixPlan = z.infer; // ─── System Prompt ────────────────────────────────────────────────────────── -export const GCP_SYSTEM_PROMPT = `You are a GCP security remediation expert. You analyze Security Command Center findings and produce structured fix plans using GCP REST API calls. +export const GCP_SYSTEM_PROMPT = `You are a GCP security remediation expert. You analyze GCP security findings — from Security Command Center (SCC) OR from Comp AI's direct GCP API checks — and produce structured fix plans using GCP REST API calls. The two sources describe the same kinds of issues; treat them identically. A human will ALWAYS review your plan before execution. Be precise and correct. @@ -187,9 +187,9 @@ For each step, provide: ### Pub/Sub - PUBSUB_CMEK_DISABLED: canAutoFix=false — CMEK cannot be added to existing topics, requires recreation -## PARSING SCC FINDING EVIDENCE +## PARSING FINDING EVIDENCE -The finding evidence contains rich data from Security Command Center: +SCC findings carry rich structured evidence: - resourceName: Full GCP resource path (e.g., "//storage.googleapis.com/buckets/my-bucket") - category: SCC finding category (e.g., "PUBLIC_BUCKET_ACL", "OPEN_FIREWALL") - projectDisplayName: GCP project name @@ -197,6 +197,8 @@ The finding evidence contains rich data from Security Command Center: - externalUri: Link to the resource in GCP Console - compliances: Compliance mappings (CIS, PCI-DSS, etc.) +Comp AI direct-check findings have NO SCC \`category\`/\`resourceName\`/\`externalUri\`. For those, infer the fix from the Title, Description, Existing Remediation Guidance, Resource Type, Resource ID, and whatever keys the evidence DOES carry (e.g. projectId, bucket, role, members). The Resource ID is your concrete target — e.g. resourceType "gcp-storage-bucket" with resourceId "my-proj/my-bucket" → bucket "my-bucket" in project "my-proj". Match the issue semantically to the same fix you would apply for the equivalent SCC category (e.g. a "Bucket publicly accessible" finding == PUBLIC_BUCKET_ACL; "Primitive role in use" == PRIMITIVE_ROLES_USED; "open firewall" == OPEN_FIREWALL) and apply the same canAutoFix rules. + To convert resourceName to API URL: - "//storage.googleapis.com/buckets/my-bucket" → https://storage.googleapis.com/storage/v1/b/my-bucket - "//compute.googleapis.com/projects/my-proj/global/firewalls/my-fw" → https://compute.googleapis.com/compute/v1/projects/my-proj/global/firewalls/my-fw @@ -264,7 +266,7 @@ When you set canAutoFix=false, you MUST provide clear guidedSteps: 3. For PATCH requests, ALWAYS specify updateMask in queryParams 4. URLs must start with https:// and contain googleapis.com 5. currentState and proposedState must use the SAME keys for comparison -6. The fix must address the EXACT issue the SCC finding reports +6. The fix must address the EXACT issue the finding reports (SCC or direct check) 7. For getIamPolicy: ALWAYS include body { "options": { "requestedPolicyVersion": 3 } } — without this, auditConfigs and conditions are NOT returned 8. For setIamPolicy: body MUST be { "policy": }. Include etag, version, ALL bindings, and auditConfigs. A partial policy DELETES everything not included`; @@ -280,9 +282,9 @@ export function buildGcpFixPlanPrompt(finding: { findingKey: string; evidence: Record; }): string { - return `Analyze this GCP Security Command Center finding and generate a fix plan using GCP REST API calls. + return `Analyze this GCP security finding and generate a fix plan using GCP REST API calls. -IMPORTANT: Your fix must change the EXACT GCP resource/setting that caused this finding. The SCC will re-check the same thing. +IMPORTANT: Your fix must change the EXACT GCP resource/setting that caused this finding. The next scan will re-check the same thing. FINDING: - Title: ${finding.title} diff --git a/apps/api/src/cloud-security/gcp-scan-fallback.spec.ts b/apps/api/src/cloud-security/gcp-scan-fallback.spec.ts new file mode 100644 index 0000000000..b70ddf344f --- /dev/null +++ b/apps/api/src/cloud-security/gcp-scan-fallback.spec.ts @@ -0,0 +1,150 @@ +import type { RunAllChecksResult } from '@trycompai/integration-platform'; +import { + GCP_SCAN_MODE_DIRECT, + GCP_SCAN_MODE_SCC, + gcpCheckResultsToFindings, + isSccStructurallyUnavailable, + toCheckCredentials, + toCheckVariables, +} from './gcp-scan-fallback'; + +describe('isSccStructurallyUnavailable', () => { + it.each([ + 'SCC_NOT_ACTIVATED: Security Command Center is not activated for project x.', + 'Security Command Center Legacy has been disabled by Google. Please activate the Standard or Premium tier.', + 'Permission denied. Grant "Security Center Findings Viewer" role at the organization level.', + ])('treats structural SCC errors as unavailable: %s', (message) => { + expect(isSccStructurallyUnavailable(new Error(message))).toBe(true); + }); + + it.each([ + 'OAuth scopes insufficient. Reconnect the GCP integration.', + 'GCP API error (500): internal error', + 'GCP API error (429): rate limited', + 'some unexpected network blip', + ])('treats transient/unknown errors as NOT structural: %s', (message) => { + expect(isSccStructurallyUnavailable(new Error(message))).toBe(false); + }); + + it('handles non-Error throwables', () => { + expect(isSccStructurallyUnavailable('SCC_NOT_ACTIVATED: nope')).toBe(true); + expect(isSccStructurallyUnavailable(undefined)).toBe(false); + }); + + it('exposes distinct run-source tags', () => { + expect(GCP_SCAN_MODE_SCC).not.toBe(GCP_SCAN_MODE_DIRECT); + }); +}); + +describe('toCheckCredentials', () => { + it('keeps strings and all-string arrays, drops everything else', () => { + expect( + toCheckCredentials({ + access_token: 'tok', + regions: ['us', 'eu'], + count: 3, + nested: { a: 1 }, + mixed: ['ok', 2], + }), + ).toEqual({ access_token: 'tok', regions: ['us', 'eu'] }); + }); +}); + +describe('toCheckVariables', () => { + it('keeps primitives + string[] + undefined, drops mixed arrays and objects', () => { + expect( + toCheckVariables({ + project_ids: ['a', 'b'], + name: 'x', + max: 10, + enabled: true, + optional: undefined, + mixed: ['a', 1], + obj: { k: 'v' }, + }), + ).toEqual({ + project_ids: ['a', 'b'], + name: 'x', + max: 10, + enabled: true, + optional: undefined, + }); + }); +}); + +describe('gcpCheckResultsToFindings', () => { + const result: RunAllChecksResult = { + durationMs: 1, + totalFindings: 1, + totalPassing: 1, + results: [ + { + checkId: 'storage-public-access', + checkName: 'Storage public access', + status: 'failed', + durationMs: 1, + result: { + logs: [], + summary: { totalChecked: 2, passed: 1, failed: 1 }, + findings: [ + { + status: 'open', + title: 'Bucket publicly accessible: b1', + description: 'public', + resourceType: 'gcp-storage-bucket', + resourceId: 'proj/b1', + severity: 'high', + remediation: 'remove allUsers', + evidence: { bucket: 'b1', findingKey: 'k1' }, + }, + ], + passingResults: [ + { + collectedAt: new Date(), + title: 'Bucket private: b2', + description: 'ok', + resourceType: 'gcp-storage-bucket', + resourceId: 'proj/b2', + evidence: { bucket: 'b2' }, + }, + ], + }, + }, + ], + }; + + it('maps failures to passed:false and passing to passed:true info findings', () => { + const findings = gcpCheckResultsToFindings(result); + expect(findings).toHaveLength(2); + + const failure = findings.find((f) => !f.passed); + expect(failure).toMatchObject({ + title: 'Bucket publicly accessible: b1', + severity: 'high', + resourceType: 'gcp-storage-bucket', + resourceId: 'proj/b1', + remediation: 'remove allUsers', + passed: false, + }); + // Evidence preserved verbatim (findingKey carries through for reconciliation) + expect(failure?.evidence).toEqual({ bucket: 'b1', findingKey: 'k1' }); + + const passing = findings.find((f) => f.passed); + expect(passing).toMatchObject({ + title: 'Bucket private: b2', + severity: 'info', + passed: true, + }); + }); + + it('returns [] for an empty result set', () => { + expect( + gcpCheckResultsToFindings({ + durationMs: 0, + totalFindings: 0, + totalPassing: 0, + results: [], + }), + ).toEqual([]); + }); +}); diff --git a/apps/api/src/cloud-security/gcp-scan-fallback.ts b/apps/api/src/cloud-security/gcp-scan-fallback.ts new file mode 100644 index 0000000000..633cf7c0d7 --- /dev/null +++ b/apps/api/src/cloud-security/gcp-scan-fallback.ts @@ -0,0 +1,165 @@ +import type { RunAllChecksResult } from '@trycompai/integration-platform'; +import type { SecurityFinding } from './cloud-security.service'; + +/** + * GCP run-source tags persisted on IntegrationCheckRun.scanMode so + * reconciliation only diffs same-source runs. Security Command Center findings + * carry findingKeys; direct-API findings do not — cross-diffing the two would + * mark every prior finding falsely "resolved". + */ +export const GCP_SCAN_MODE_SCC = 'gcp_scc'; +export const GCP_SCAN_MODE_DIRECT = 'gcp_direct'; + +/** + * Cloud Tests serviceId each GCP manifest check belongs to, so the direct-API + * checks honor the user's per-service disable toggle the same way the SCC path + * does. A check with NO entry here is never gated (always runs) — a missing + * mapping must never silently hide findings. + * + * We gate on `disabledServices` (the explicit user opt-out) rather than the + * full `enabledServices` set: `enabledServices` also requires a service to have + * been auto-detected, but the direct checks are the always-on baseline (the + * manual Scan button doesn't run detection first), so gating on detection could + * hide real findings for a service the user never disabled. + */ +const GCP_CHECK_SERVICE: Record = { + 'gcp-storage-no-public-access': 'cloud-storage', + 'gcp-storage-encryption': 'cloud-storage', + 'gcp-iam-no-primitive-roles': 'iam', + 'gcp-vpc-no-open-firewalls': 'vpc-network', + 'gcp-cloud-sql-ssl-enforced': 'cloud-sql', + 'gcp-cloud-sql-backups-enabled': 'cloud-sql', + 'gcp-cloud-sql-encryption': 'cloud-sql', + 'gcp-cloud-monitoring-alerting': 'cloud-monitoring', + // gcp-environment-separation spans multiple services → intentionally ungated. +}; + +/** True when a check maps to a service the user explicitly disabled. */ +export function isGcpCheckServiceDisabled( + checkId: string, + disabledServices: ReadonlySet, +): boolean { + const service = GCP_CHECK_SERVICE[checkId]; + return service !== undefined && disabledServices.has(service); +} + +/** + * True when an SCC error means SCC is structurally unavailable for the whole + * connection (so the direct-API fallback is the right move), as opposed to a + * transient/unknown error (where we'd rather preserve the prior run and let the + * customer retry). Matches the structured errors thrown by + * GCPSecurityService.fetchFindings: + * - 'SCC_NOT_ACTIVATED: …' (SERVICE_DISABLED / not-used / 404 not-found) + * - '…Security Command Center Legacy has been disabled…' + * - 'Permission denied. Grant "Security Center Findings Viewer" …' + * + * Deliberately NOT matched (re-thrown so the prior run is preserved): + * - 'OAuth scopes insufficient…' — the same token drives the direct checks, + * so falling back would just fail again. + * - 'GCP API error (5xx): …' — likely transient; retry beats degrading. + */ +export function isSccStructurallyUnavailable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.startsWith('SCC_NOT_ACTIVATED:') || + message.includes('Security Command Center Legacy') || + message.includes('Security Center Findings Viewer') + ); +} + +/** + * Convert decrypted credentials to the `string | string[]` shape the + * integration-platform check runner expects. Drops values that are neither a + * string nor an all-string array (e.g. nested objects) rather than coercing. + */ +export function toCheckCredentials( + credentials: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(credentials)) { + if (typeof value === 'string') { + out[key] = value; + } else if (Array.isArray(value)) { + const strings = value.filter((v): v is string => typeof v === 'string'); + if (strings.length === value.length) out[key] = strings; + } + } + return out; +} + +/** + * Narrow connection.variables to the value types the check runner accepts + * (string | number | boolean | string[] | undefined). Mixed-type arrays and + * objects are dropped rather than coerced. + */ +export function toCheckVariables( + variables: Record, +): Record { + const out: Record = + {}; + for (const [key, value] of Object.entries(variables)) { + if ( + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + out[key] = value; + } else if (Array.isArray(value)) { + const strings = value.filter((v): v is string => typeof v === 'string'); + if (strings.length === value.length) out[key] = strings; + } + } + return out; +} + +/** Compact a check-runner log line + structured data into one string. */ +export function formatCheckLog( + message: string, + data?: Record, +): string { + return data ? `${message} ${JSON.stringify(data)}` : message; +} + +/** + * Flatten runAllChecks output into SecurityFindings. Failures become + * passed:false findings; passingResults become passed:true 'info' findings. + * Evidence is preserved verbatim so any serviceId/findingKey carries through to + * grouping and reconciliation. + */ +export function gcpCheckResultsToFindings( + result: RunAllChecksResult, +): SecurityFinding[] { + const now = new Date().toISOString(); + const findings: SecurityFinding[] = []; + for (const checkResult of result.results) { + for (const finding of checkResult.result.findings) { + findings.push({ + id: `${checkResult.checkId}:${finding.resourceId}`, + title: finding.title, + description: finding.description, + severity: finding.severity, + resourceType: finding.resourceType, + resourceId: finding.resourceId, + remediation: finding.remediation, + evidence: finding.evidence ?? {}, + createdAt: now, + passed: false, + }); + } + for (const passing of checkResult.result.passingResults) { + findings.push({ + id: `${checkResult.checkId}:${passing.resourceId}`, + title: passing.title, + description: passing.description, + severity: 'info', + resourceType: passing.resourceType, + resourceId: passing.resourceId, + evidence: passing.evidence ?? {}, + createdAt: now, + passed: true, + }); + } + } + return findings; +} diff --git a/apps/api/src/trigger/policies/process-policy-template.ts b/apps/api/src/trigger/policies/process-policy-template.ts new file mode 100644 index 0000000000..4476ed85fc --- /dev/null +++ b/apps/api/src/trigger/policies/process-policy-template.ts @@ -0,0 +1,238 @@ +type JsonNode = Record; + +const PLACEHOLDER_QUESTIONS: Array<[string, RegExp]> = [ + ['COMPANYINFO', /describe your company/i], + ['INDUSTRY', /what industry/i], + ['EMPLOYEES', /how many employees/i], + ['DEVICES', /what devices/i], + ['SOFTWARE', /what software/i], + ['LOCATION', /how does your team work/i], + ['CRITICAL', /where do you host/i], + ['DATA', /what type(s)? of data/i], + ['GEO', /where is your data/i], +]; + +const FRAMEWORK_MATCHERS: Array<[string, (n: string) => boolean]> = [ + ['soc2', (n) => /soc\s*2/i.test(n) || n.includes('soc')], + ['hipaa', (n) => n.includes('hipaa')], + ['pipeda', (n) => n.includes('pipeda')], + ['gdpr', (n) => n.includes('gdpr')], + ['iso27001', (n) => /iso\s*27001/i.test(n)], + ['pci', (n) => /pci/i.test(n)], + ['nist', (n) => /nist/i.test(n)], + ['ccpa', (n) => n.includes('ccpa')], +]; + +export function buildVariables({ + companyName, + contextHub, +}: { + companyName: string; + contextHub: string; +}): Record { + const vars: Record = { COMPANY: companyName }; + const lines = contextHub.split('\n'); + + for (let i = 0; i < lines.length - 1; i++) { + for (const [key, pattern] of PLACEHOLDER_QUESTIONS) { + if (!vars[key] && pattern.test(lines[i])) { + vars[key] = lines[i + 1]?.trim() || 'N/A'; + } + } + } + + return vars; +} + +export function buildFlags( + frameworks: Array<{ name: string }>, +): Record { + const flags: Record = {}; + for (const [flag, test] of FRAMEWORK_MATCHERS) { + flags[flag] = frameworks.some((f) => test(f.name.toLowerCase())); + } + return flags; +} + +function extractText(node: JsonNode): string { + if (typeof node.text === 'string') return node.text; + if (Array.isArray(node.content)) { + return (node.content as JsonNode[]).map(extractText).join(''); + } + return ''; +} + +function replacePlaceholdersInText( + text: string, + vars: Record, +): string { + return text.replace( + /\{\{(\w+)\}\}/g, + (match, key: string) => vars[key] ?? 'N/A', + ); +} + +function processInlineConditionals( + text: string, + flags: Record, +): string { + return text.replace( + /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, + (_match, flag: string, inner: string) => (flags[flag] ? inner : ''), + ); +} + +function stripMarkerText(node: JsonNode, marker: RegExp): JsonNode { + if (typeof node.text === 'string') { + return { ...node, text: node.text.replace(marker, '') }; + } + if (Array.isArray(node.content)) { + return { + ...node, + content: (node.content as JsonNode[]).map((child) => + stripMarkerText(child, marker), + ), + }; + } + return node; +} + +function processTextNode( + node: JsonNode, + vars: Record, + flags: Record, +): JsonNode | null { + if (typeof node.text !== 'string') return node; + + let text = node.text; + text = processInlineConditionals(text, flags); + text = replacePlaceholdersInText(text, vars); + + if (!text) return null; + return { ...node, text }; +} + +function processNode( + node: JsonNode, + vars: Record, + flags: Record, +): JsonNode | null { + if (node.type === 'text') { + return processTextNode(node, vars, flags); + } + + if (!Array.isArray(node.content)) return node; + + const processed = processContentArray( + node.content as JsonNode[], + vars, + flags, + ); + if (processed.length === 0 && node.type !== 'document') return null; + + return { ...node, content: processed }; +} + +/** + * Walks a TipTap content array, evaluates {{#if}}…{{/if}} blocks that + * span multiple sibling nodes, replaces {{PLACEHOLDER}} values, and + * returns the cleaned array. Fully deterministic — no LLM calls. + */ +export function processContentArray( + nodes: JsonNode[], + vars: Record, + flags: Record, +): JsonNode[] { + const result: JsonNode[] = []; + let skipDepth = 0; + + for (const node of nodes) { + const text = extractText(node); + + const openMatch = text.match(/\{\{#if\s+(\w+)\}\}/); + const closeMatch = text.includes('{{/if}}'); + const hasOnlyMarker = + /^\s*\{\{#if\s+\w+\}\}\s*$/.test(text) || + /^\s*\{\{\/if\}\}\s*$/.test(text); + + if (openMatch && closeMatch) { + if (skipDepth === 0) { + const processed = processNode(node, vars, flags); + if (processed) result.push(processed); + } + continue; + } + + if (openMatch) { + if (skipDepth > 0) { + skipDepth++; + continue; + } + const flag = openMatch[1]; + const isTrue = flags[flag] ?? false; + if (!isTrue) { + skipDepth++; + } else if (!hasOnlyMarker) { + const stripped = stripMarkerText(node, /\{\{#if\s+\w+\}\}/); + const processed = processNode(stripped, vars, flags); + if (processed) result.push(processed); + } + continue; + } + + if (closeMatch) { + if (skipDepth > 0) { + skipDepth--; + } else if (!hasOnlyMarker) { + const stripped = stripMarkerText(node, /\{\{\/if\}\}/); + const processed = processNode(stripped, vars, flags); + if (processed) result.push(processed); + } + continue; + } + + if (skipDepth > 0) continue; + + const processed = processNode(node, vars, flags); + if (processed) result.push(processed); + } + + return result; +} + +/** + * Processes a full TipTap document: replaces handlebars placeholders with + * real values and evaluates conditional blocks based on framework flags. + * Returns the processed content array (inner nodes of the document). + */ +export function processTemplate({ + content, + companyName, + contextHub, + frameworks, +}: { + content: unknown; + companyName: string; + contextHub: string; + frameworks: Array<{ name: string }>; +}): JsonNode[] { + const vars = buildVariables({ companyName, contextHub }); + const flags = buildFlags(frameworks); + + let nodes: JsonNode[]; + if ( + content && + typeof content === 'object' && + 'type' in (content as JsonNode) && + (content as JsonNode).type === 'doc' && + Array.isArray((content as JsonNode).content) + ) { + nodes = (content as JsonNode).content as JsonNode[]; + } else if (Array.isArray(content)) { + nodes = content as JsonNode[]; + } else { + return []; + } + + return processContentArray(nodes, vars, flags); +} diff --git a/apps/api/src/trigger/policies/refine-cue-lines.ts b/apps/api/src/trigger/policies/refine-cue-lines.ts new file mode 100644 index 0000000000..ba04f6ba37 --- /dev/null +++ b/apps/api/src/trigger/policies/refine-cue-lines.ts @@ -0,0 +1,84 @@ +import { anthropic } from '@ai-sdk/anthropic'; +import { logger } from '@trigger.dev/sdk'; +import { generateObject } from 'ai'; +import { z } from 'zod'; + +const CUE_LINE_PATTERN = + /^(State that|Clarify that|Add a |Include a |Specify |List |Note that|Require that|Describe |Define )/; + +type JsonNode = Record; + +/** + * Finds text nodes containing instruction cue lines (e.g. "State that...", + * "Define..."). Returns an array of { path, text } entries where path is + * the index chain to reach the text node in the content tree. + */ +function findCueLines( + nodes: JsonNode[], + path: number[] = [], +): Array<{ path: number[]; text: string }> { + const results: Array<{ path: number[]; text: string }> = []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ( + node.type === 'text' && + typeof node.text === 'string' && + CUE_LINE_PATTERN.test(node.text) + ) { + results.push({ path: [...path, i], text: node.text }); + } + if (Array.isArray(node.content)) { + results.push(...findCueLines(node.content as JsonNode[], [...path, i])); + } + } + return results; +} + +function setTextAtPath( + nodes: JsonNode[], + path: number[], + newText: string, +): void { + let current: JsonNode[] = nodes; + for (let i = 0; i < path.length - 1; i++) { + const node = current[path[i]]; + current = node.content as JsonNode[]; + } + const target = current[path[path.length - 1]]; + target.text = newText; +} + +/** + * Rewrites instruction cue lines into direct policy language using a + * targeted LLM call. Only fires when cue lines are detected — most + * policies skip this entirely. + */ +export async function refineCueLines( + content: JsonNode[], + policyName: string, +): Promise { + const cueLines = findCueLines(content); + if (cueLines.length === 0) return content; + + try { + const { object } = await generateObject({ + model: anthropic('claude-sonnet-4-6'), + system: `You rewrite policy template instructions into direct, professional policy language. Each input is an instruction (e.g. "State that...", "Define..."). Return the equivalent text as it should appear in a published security policy — authoritative, concise, no instructional phrasing.`, + prompt: `Policy: "${policyName}"\n\nRewrite each instruction:\n${cueLines.map((c, i) => `${i + 1}. ${c.text}`).join('\n')}`, + schema: z.object({ + rewrites: z.array(z.string()).length(cueLines.length), + }), + }); + + for (let i = 0; i < cueLines.length; i++) { + setTextAtPath(content, cueLines[i].path, object.rewrites[i]); + } + } catch (err) { + logger.warn('Cue line refinement failed; keeping original text', { + policyName, + error: err instanceof Error ? err.message : String(err), + }); + } + + return content; +} diff --git a/apps/api/src/trigger/policies/update-policy-helpers.spec.ts b/apps/api/src/trigger/policies/update-policy-helpers.spec.ts new file mode 100644 index 0000000000..7f1c7cdf52 --- /dev/null +++ b/apps/api/src/trigger/policies/update-policy-helpers.spec.ts @@ -0,0 +1,129 @@ +import { db } from '@db'; +import { generateObject } from 'ai'; +import { processPolicyUpdate } from './update-policy-helpers'; + +jest.mock('@db', () => ({ + db: { + organization: { findUnique: jest.fn() }, + policy: { findUnique: jest.fn(), update: jest.fn() }, + frameworkEditorPolicyTemplate: { findUnique: jest.fn() }, + policyVersion: { create: jest.fn(), deleteMany: jest.fn() }, + $transaction: jest.fn(), + }, +})); + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +jest.mock('ai', () => ({ + generateObject: jest.fn(), + NoObjectGeneratedError: { isInstance: jest.fn(() => false) }, +})); + +jest.mock('@ai-sdk/openai', () => ({ openai: jest.fn(() => 'openai-model') })); +jest.mock('@ai-sdk/anthropic', () => ({ + anthropic: jest.fn(() => 'anthropic-model'), +})); + +// A real TipTap template carrying an org-specific handlebars placeholder. The +// deterministic template processor must substitute {{COMPANY}} with the org +// name; the legacy gpt-5-mini generator never would (it ignores the template). +const TEMPLATE_CONTENT = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Information Security Policy' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'This policy applies to all employees of {{COMPANY}}.', + }, + ], + }, + ], +}; + +describe('processPolicyUpdate (individual policy regeneration)', () => { + let storedContent: unknown[] | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + storedContent = undefined; + + (db.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + name: 'Acme Inc', + website: 'https://acme.example', + }); + (db.policy.findUnique as jest.Mock).mockResolvedValue({ + id: 'pol_1', + organizationId: 'org_1', + policyTemplateId: 'tmpl_1', + versions: [], + }); + ( + db.frameworkEditorPolicyTemplate.findUnique as jest.Mock + ).mockResolvedValue({ + id: 'tmpl_1', + name: 'Information Security Policy', + content: TEMPLATE_CONTENT, + }); + (db.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: unknown) => Promise) => + cb({ + policy: { update: jest.fn() }, + policyVersion: { + create: jest.fn(({ data }: { data: { content: unknown[] } }) => { + storedContent = data.content; + return { id: 'pv_1' }; + }), + deleteMany: jest.fn(), + }, + }), + ); + + // If the legacy gpt-5-mini path runs, it returns generic, template-ignoring + // content (no org-specific values) — exactly the reported bug. + (generateObject as jest.Mock).mockResolvedValue({ + object: { + type: 'document', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Generic boilerplate policy content.' }, + ], + }, + ], + }, + }); + }); + + it('fills org-specific placeholders deterministically without the legacy full-document LLM generator', async () => { + const result = await processPolicyUpdate({ + organizationId: 'org_1', + policyId: 'pol_1', + contextHub: '', + frameworks: [], + }); + + const serialized = JSON.stringify(storedContent); + + // Deterministic processTemplate substitutes {{COMPANY}} with the org name. + expect(serialized).toContain('Acme Inc'); + expect(serialized).not.toContain('{{COMPANY}}'); + + // The slow, generic gpt-5-mini full-document generator must not run for a + // template that has no instruction cue lines to refine. + expect(generateObject).not.toHaveBeenCalled(); + expect(serialized).not.toContain('Generic boilerplate policy content.'); + + expect(result.policyName).toBe('Information Security Policy'); + }); +}); diff --git a/apps/api/src/trigger/policies/update-policy-helpers.ts b/apps/api/src/trigger/policies/update-policy-helpers.ts index b5bc8bea51..7913092f1d 100644 --- a/apps/api/src/trigger/policies/update-policy-helpers.ts +++ b/apps/api/src/trigger/policies/update-policy-helpers.ts @@ -1,4 +1,3 @@ -import { openai } from '@ai-sdk/openai'; import { db } from '@db'; import type { FrameworkEditorFramework, @@ -7,102 +6,8 @@ import type { Prisma, } from '@db'; import { logger } from '@trigger.dev/sdk'; -import { generateObject, NoObjectGeneratedError } from 'ai'; -import { z } from 'zod'; -import { generatePrompt } from './update-policy-prompts'; - -// Sanitization utilities -const PLACEHOLDER_REGEX = /<<\s*TO\s*REVIEW\s*>>/gi; - -function extractText(node: Record): string { - const text = node && typeof node['text'] === 'string' ? node['text'] : ''; - const content = Array.isArray((node as any)?.content) - ? ((node as any).content as Record[]) - : null; - if (content && content.length > 0) { - return content.map(extractText).join(''); - } - return text || ''; -} - -function sanitizeNodePlaceholders( - node: Record, -): Record { - const cloned: Record = { ...node }; - if (typeof cloned['text'] === 'string') { - const replaced = cloned['text'] - .replace(PLACEHOLDER_REGEX, '') - .replace(/\s{2,}/g, ' ') - .trim(); - cloned['text'] = replaced; - } - const content = Array.isArray((cloned as any).content) - ? ((cloned as any).content as Record[]) - : null; - if (content) { - (cloned as any).content = content.map(sanitizeNodePlaceholders); - } - return cloned; -} - -function shouldRemoveAuditorArtifactsHeading(headingText: string): boolean { - const lower = headingText.trim().toLowerCase(); - return ( - lower.includes('auditor') && - (lower.includes('artefact') || lower.includes('artifact')) - ); -} - -function removeAuditorArtifactsSection( - content: Record[], -): Record[] { - const result: Record[] = []; - let i = 0; - while (i < content.length) { - const node = content[i]; - const nodeType = typeof node['type'] === 'string' ? node['type'] : ''; - if (nodeType === 'heading') { - const headingText = extractText(node); - if (shouldRemoveAuditorArtifactsHeading(headingText)) { - i += 1; - while (i < content.length) { - const nextNode = content[i]; - const nextType = - typeof nextNode['type'] === 'string' ? nextNode['type'] : ''; - if (nextType === 'heading') break; - i += 1; - } - continue; - } - } - result.push(sanitizeNodePlaceholders(node)); - i += 1; - } - return result; -} - -function extractHeadingText(node: Record): string { - const type = typeof node['type'] === 'string' ? node['type'] : ''; - if (type !== 'heading') return ''; - return extractText(node).trim(); -} - -function getAllowedTopLevelHeadings( - originalContent: Record[], -): string[] { - const allowed: string[] = []; - for (const node of originalContent) { - const type = typeof node['type'] === 'string' ? node['type'] : ''; - if (type === 'heading') { - const level = (node as any)?.attrs?.level; - if (typeof level === 'number' && level >= 1 && level <= 2) { - const text = extractHeadingText(node); - if (text) allowed.push(text.toLowerCase()); - } - } - } - return allowed; -} +import { processTemplate } from './process-policy-template'; +import { refineCueLines } from './refine-cue-lines'; export type OrganizationData = { id: string; @@ -162,78 +67,6 @@ export async function fetchOrganizationAndPolicy( return { organization, policy, policyTemplate }; } -export async function generatePolicyPrompt( - policyTemplate: FrameworkEditorPolicyTemplate, - contextHub: string, - organization: OrganizationData, - frameworks: FrameworkEditorFramework[], -): Promise { - return generatePrompt({ - contextHub, - policyTemplate, - companyName: organization.name ?? 'Company', - companyWebsite: organization.website ?? 'https://company.com', - frameworks, - }); -} - -export async function generatePolicyContent(prompt: string): Promise<{ - type: 'document'; - content: Record[]; -}> { - try { - const { object } = await generateObject({ - model: openai('gpt-5-mini'), - output: 'no-schema', - system: `You are an expert at writing security policies. Generate content directly as TipTap JSON format. - -TipTap JSON structure: -- Root: {"type": "document", "content": [array of nodes]} -- Paragraphs: {"type": "paragraph", "content": [text nodes]} -- Headings: {"type": "heading", "attrs": {"level": 1-6}, "content": [text nodes]} -- Lists: {"type": "orderedList"/"bulletList", "content": [listItem nodes]} -- List items: {"type": "listItem", "content": [paragraph nodes]} -- Text: {"type": "text", "text": "content", "marks": [formatting]} -- Bold: {"type": "bold"} in marks array -- Italic: {"type": "italic"} in marks array - -IMPORTANT: Follow ALL formatting instructions in the prompt, implementing them as proper TipTap JSON structures. -Return a JSON object with exactly this shape: {"type": "document", "content": [array of TipTap nodes]}`, - prompt: `Generate a SOC 2 compliant security policy as a complete TipTap JSON document. - -INSTRUCTIONS TO IMPLEMENT IN TIPTAP JSON: -${prompt.replace(/\\n/g, '\n')} - -Return the complete TipTap document following ALL the above requirements using proper TipTap JSON structure.`, - }); - - const parsed = object as { type?: string; content?: unknown }; - if (parsed?.type !== 'document' || !Array.isArray(parsed?.content)) { - throw new Error( - 'AI response did not match expected TipTap document structure', - ); - } - - return { - type: 'document' as const, - content: parsed.content as Record[], - }; - } catch (aiError) { - logger.error(`Error generating AI content: ${aiError}`); - if (NoObjectGeneratedError.isInstance(aiError)) { - logger.error( - `NoObjectGeneratedError: ${JSON.stringify({ - cause: aiError.cause, - text: aiError.text, - response: aiError.response, - usage: aiError.usage, - })}`, - ); - } - throw aiError; - } -} - export async function updatePolicyInDatabase( policyId: string, content: Record[], @@ -330,13 +163,31 @@ export async function processPolicyUpdate( organizationId, policyId, ); - const prompt = await generatePolicyPrompt( - policyTemplate, + + // Deterministic template processing: fill {{PLACEHOLDER}} values and evaluate + // {{#if framework}} blocks from the org's real context. Mirrors the + // bulk/onboarding path and replaces the slow, generic gpt-5-mini + // full-document generator that ignored the template and produced + // template-ish output. + const processedContent = processTemplate({ + content: policyTemplate.content, + companyName: organization.name ?? 'Company', contextHub, - organization, frameworks, + }); + + // Only fires when the template carries instruction cue lines ("State + // that...", "Define..."); most policies skip it. Falls back to the + // deterministic content if the targeted LLM rewrite fails. + const refinedContent = await refineCueLines( + processedContent, + policyTemplate.name, ); - const updatedContent = await generatePolicyContent(prompt); + const updatedContent = { + type: 'document' as const, + content: refinedContent, + }; + await updatePolicyInDatabase(policyId, updatedContent.content, memberId); return { diff --git a/apps/api/src/trust-portal/policy-pdf-renderer.service.spec.ts b/apps/api/src/trust-portal/policy-pdf-renderer.service.spec.ts index f1d4c4030f..926ed3f87d 100644 --- a/apps/api/src/trust-portal/policy-pdf-renderer.service.spec.ts +++ b/apps/api/src/trust-portal/policy-pdf-renderer.service.spec.ts @@ -75,6 +75,54 @@ describe('PolicyPdfRendererService', () => { expect(result).toBeInstanceOf(Buffer); }); + it('does not crash when content.content is not an array (Drata migration import)', () => { + // Regression for the Drata-migration download-all bug: imported, + // non-TipTap policy content can have `content.content` as a string/object + // rather than a JSONContent[] array. convertToInternalFormat used to call + // .map on it -> "content.map is not a function" -> the whole bundle + // rejected with a 500. A single malformed policy must not poison the + // bundle; it should degrade to an empty body. + const result = service.renderPoliciesPdfBuffer( + [ + { + name: 'Imported Policy', + content: { + type: 'doc', + content: 'This was a plain string, not a TipTap node array.', + }, + }, + ], + 'Test Org', + ); + + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + expect(result.subarray(0, 5).toString()).toBe('%PDF-'); + }); + + it('does not crash when a nested node content is not an array', () => { + // The malformed shape can also appear nested: a top-level array whose + // item has a non-array `content`. The recursive convertToInternalFormat + // call must guard against .map on a non-array too. + const result = service.renderPoliciesPdfBuffer( + [ + { + name: 'Nested Malformed Policy', + content: { + type: 'doc', + content: [ + { type: 'paragraph', content: 'plain string instead of nodes' }, + ], + }, + }, + ], + 'Test Org', + ); + + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + }); + it('handles policies without organization name', () => { const result = service.renderPoliciesPdfBuffer([ { diff --git a/apps/api/src/trust-portal/policy-pdf-renderer.service.ts b/apps/api/src/trust-portal/policy-pdf-renderer.service.ts index da92a19bc1..11c3c61c7c 100644 --- a/apps/api/src/trust-portal/policy-pdf-renderer.service.ts +++ b/apps/api/src/trust-portal/policy-pdf-renderer.service.ts @@ -190,6 +190,14 @@ export class PolicyPdfRendererService { } private convertToInternalFormat(content: any[]): JSONContent[] { + // Imported / non-TipTap policy content (e.g. Drata migrations) can have a + // `content` field that is a string or object instead of a JSONContent[] + // array. Guard so .map never runs on a non-array and throws + // "content.map is not a function" — a single malformed policy used to + // reject the entire download-all bundle with a 500. + if (!Array.isArray(content)) { + return []; + } return content.map((item) => ({ type: item.type || 'paragraph', attrs: item.attrs,