From 34566555f85845a4d7e6bf7fe6ad25fe82851a20 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Wed, 13 May 2026 18:32:44 -0400
Subject: [PATCH 01/15] feat(cloud-tests): auditor visibility improvements
(phases 1-5)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the depth and audit trail Chris asked for so auditors can trust the
automated checks. Five phases shipped together:
Phase 1 — Quick wins
- Evidence JSON viewer on each finding (sensitive keys redacted via
evidence-sanitizer.ts — suffix-match strategy preserves booleans/numbers)
- Last-scan metadata strip (47 checks, 41 passed, 6 failed, 3.2s)
- Resource labels on every finding row (IAM User: john, S3 Bucket: …)
- Rename "Security Findings" header + tab to "Scan Results"
Phase 2 — AI check descriptions
- Tier 3 panel (what / pass / fail / why) generated lazily on first expand
- Claude Haiku 4.5 via @ai-sdk/anthropic, cached per (orgId, checkId) with
source-hash invalidation (regenerates when adapter strings change)
- Server-side strip of compliance control numbers + URLs as a backstop
- GCP/Azure: passthrough from provider catalog (no AI call)
Phase 3 — Per-check sub-grouping
- Findings within a service grouped by normalized checkKey
- "X of Y failing" headers; "Show all results" reveals passing rows
- 100-row display cap per check with "Show more" affordance
Phase 4 — Comments on auditor findings
- CommentsPermissionGuard resolves entityType-specific permission so
auditors (finding:update, no task:update) can comment on findings
- Comments thread on /overview/findings detail sheet
Phase 5 — Resolution + exceptions history
- FindingException / FindingResolution / FindingRegression tables
- Reconciliation classifies each resolution: platform_fix (RemediationAction
matched), external_fix, resource_deleted, or exception_marked
- scannedServices column on IntegrationCheckRun prevents false "resolved"
events on partial scans
- "Mark as exception" modal: required reason (20+ chars), optional reviewer,
optional auto-review date
- New History tab: summary + resolutions + active exceptions + regressions
Includes 96 new tests (59 api + 37 frontend) and 3 Prisma migrations.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../ai-description.prompt.spec.ts | 144 +++++++
.../cloud-security/ai-description.prompt.ts | 128 ++++++
.../cloud-security/ai-description.service.ts | 59 +++
.../check-definition-provider-passthrough.ts | 89 ++++
.../check-definition.service.spec.ts | 57 +++
.../check-definition.service.ts | 239 +++++++++++
.../cloud-security/check-definition.utils.ts | 67 +++
.../cloud-security/cloud-security-audit.ts | 4 +-
.../cloud-security-query.legacy.ts | 89 ++++
.../cloud-security-query.service.ts | 256 ++++++------
.../cloud-security-query.types.ts | 73 ++++
.../cloud-security.controller.ts | 104 +++++
.../cloud-security/cloud-security.module.ts | 10 +
.../cloud-security/cloud-security.service.ts | 43 +-
.../cloud-security/dto/mark-exception.dto.ts | 28 ++
.../cloud-security/evidence-sanitizer.spec.ts | 219 ++++++++++
.../src/cloud-security/evidence-sanitizer.ts | 72 ++++
.../cloud-security/exception.service.spec.ts | 232 +++++++++++
.../src/cloud-security/exception.service.ts | 211 ++++++++++
.../api/src/cloud-security/history.service.ts | 66 +++
.../reconciliation.service.spec.ts | 280 +++++++++++++
.../cloud-security/reconciliation.service.ts | 306 ++++++++++++++
.../comments-permission.guard.spec.ts | 200 +++++++++
.../src/comments/comments-permission.guard.ts | 134 ++++++
apps/api/src/comments/comments.controller.ts | 7 +-
apps/api/src/comments/comments.module.ts | 8 +-
.../components/CheckDefinitionPanel.test.tsx | 110 +++++
.../components/CheckDefinitionPanel.tsx | 84 ++++
.../components/CheckGroupBlock.test.tsx | 190 +++++++++
.../components/CheckGroupBlock.tsx | 138 +++++++
.../components/CloudTestsSection.tsx | 384 ++++++++++++++----
.../components/EvidenceJsonViewer.test.tsx | 55 +++
.../components/EvidenceJsonViewer.tsx | 98 +++++
.../cloud-tests/components/FindingsTable.tsx | 2 +-
.../components/HistoryTab.test.tsx | 214 ++++++++++
.../cloud-tests/components/HistoryTab.tsx | 308 ++++++++++++++
.../components/MarkExceptionModal.test.tsx | 151 +++++++
.../components/MarkExceptionModal.tsx | 198 +++++++++
.../cloud-tests/components/ProviderTabs.tsx | 11 +-
.../cloud-tests/components/ResultsView.tsx | 4 +-
.../components/check-groups.test.ts | 183 +++++++++
.../cloud-tests/components/check-groups.ts | 125 ++++++
.../app/(app)/[orgId]/cloud-tests/types.ts | 33 ++
.../components/FindingDetailSheet.tsx | 10 +
.../migration.sql | 28 ++
.../migration.sql | 2 +
.../migration.sql | 116 ++++++
.../db/prisma/schema/check-definition.prisma | 48 +++
packages/db/prisma/schema/comment.prisma | 1 +
.../db/prisma/schema/finding-history.prisma | 121 ++++++
.../prisma/schema/integration-platform.prisma | 13 +
packages/db/prisma/schema/organization.prisma | 4 +
52 files changed, 5539 insertions(+), 217 deletions(-)
create mode 100644 apps/api/src/cloud-security/ai-description.prompt.spec.ts
create mode 100644 apps/api/src/cloud-security/ai-description.prompt.ts
create mode 100644 apps/api/src/cloud-security/ai-description.service.ts
create mode 100644 apps/api/src/cloud-security/check-definition-provider-passthrough.ts
create mode 100644 apps/api/src/cloud-security/check-definition.service.spec.ts
create mode 100644 apps/api/src/cloud-security/check-definition.service.ts
create mode 100644 apps/api/src/cloud-security/check-definition.utils.ts
create mode 100644 apps/api/src/cloud-security/cloud-security-query.legacy.ts
create mode 100644 apps/api/src/cloud-security/cloud-security-query.types.ts
create mode 100644 apps/api/src/cloud-security/dto/mark-exception.dto.ts
create mode 100644 apps/api/src/cloud-security/evidence-sanitizer.spec.ts
create mode 100644 apps/api/src/cloud-security/evidence-sanitizer.ts
create mode 100644 apps/api/src/cloud-security/exception.service.spec.ts
create mode 100644 apps/api/src/cloud-security/exception.service.ts
create mode 100644 apps/api/src/cloud-security/history.service.ts
create mode 100644 apps/api/src/cloud-security/reconciliation.service.spec.ts
create mode 100644 apps/api/src/cloud-security/reconciliation.service.ts
create mode 100644 apps/api/src/comments/comments-permission.guard.spec.ts
create mode 100644 apps/api/src/comments/comments-permission.guard.ts
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/CheckDefinitionPanel.test.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/CheckDefinitionPanel.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/CheckGroupBlock.test.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/CheckGroupBlock.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.test.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.test.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.test.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/check-groups.test.ts
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/check-groups.ts
create mode 100644 packages/db/prisma/migrations/20260513213400_cloud_tests_check_definition/migration.sql
create mode 100644 packages/db/prisma/migrations/20260513220233_comment_entity_type_finding/migration.sql
create mode 100644 packages/db/prisma/migrations/20260513221148_cloud_tests_phase5_history/migration.sql
create mode 100644 packages/db/prisma/schema/check-definition.prisma
create mode 100644 packages/db/prisma/schema/finding-history.prisma
diff --git a/apps/api/src/cloud-security/ai-description.prompt.spec.ts b/apps/api/src/cloud-security/ai-description.prompt.spec.ts
new file mode 100644
index 0000000000..28bfd3fe72
--- /dev/null
+++ b/apps/api/src/cloud-security/ai-description.prompt.spec.ts
@@ -0,0 +1,144 @@
+import {
+ buildCheckDescriptionPrompt,
+ checkDescriptionSchema,
+ findForbiddenContent,
+} from './ai-description.prompt';
+
+describe('ai-description.prompt', () => {
+ describe('checkDescriptionSchema', () => {
+ it('accepts a well-formed Tier 3 description', () => {
+ const parsed = checkDescriptionSchema.safeParse({
+ title: 'IAM password policy enforces 14+ character minimum',
+ description:
+ 'Verifies that the AWS account password policy requires user passwords to be at least 14 characters long.',
+ passCriteria:
+ 'Password policy exists AND MinimumPasswordLength >= 14',
+ failCriteria:
+ 'No password policy is configured OR MinimumPasswordLength < 14',
+ whyItMatters:
+ 'Short passwords are vulnerable to brute force attacks and credential stuffing.',
+ });
+ expect(parsed.success).toBe(true);
+ });
+
+ it('rejects fields below minimum length', () => {
+ const parsed = checkDescriptionSchema.safeParse({
+ title: '',
+ description: 'short',
+ passCriteria: 'x',
+ failCriteria: 'y',
+ whyItMatters: 'z',
+ });
+ expect(parsed.success).toBe(false);
+ });
+ });
+
+ describe('findForbiddenContent', () => {
+ const baseline = {
+ title: 'IAM password policy enforces 14+ character minimum',
+ description:
+ 'Verifies that the AWS account password policy requires user passwords to be at least 14 characters long.',
+ passCriteria:
+ 'Password policy exists AND MinimumPasswordLength >= 14',
+ failCriteria:
+ 'No password policy is configured OR MinimumPasswordLength < 14',
+ whyItMatters:
+ 'Short passwords are vulnerable to brute force attacks and credential stuffing.',
+ };
+
+ it('returns null for clean output', () => {
+ expect(findForbiddenContent(baseline)).toBeNull();
+ });
+
+ it('flags SOC 2 control numbers', () => {
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ whyItMatters:
+ 'This check aligns with SOC 2 CC6.1 logical access controls.',
+ }),
+ ).toMatchObject({ field: 'whyItMatters' });
+ });
+
+ it('flags ISO 27001 references', () => {
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ whyItMatters: 'Required by ISO 27001 A.9.4.3.',
+ }),
+ ).toMatchObject({ field: 'whyItMatters' });
+ });
+
+ it('flags HIPAA / NIST framework citations', () => {
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ description: 'HIPAA-aligned password requirement.',
+ }),
+ ).toMatchObject({ field: 'description' });
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ description: 'Maps to NIST AC-2.',
+ }),
+ ).toMatchObject({ field: 'description' });
+ });
+
+ it('flags any URL', () => {
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ whyItMatters: 'See https://docs.aws.amazon.com for more info.',
+ }),
+ ).toMatchObject({ field: 'whyItMatters' });
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ description: 'Reference: www.cisecurity.org/benchmark.',
+ }),
+ ).toMatchObject({ field: 'description' });
+ });
+
+ it('flags CC. control patterns even without "SOC 2"', () => {
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ passCriteria: 'Control reference: CC7.1',
+ }),
+ ).toMatchObject({ field: 'passCriteria' });
+ });
+ });
+
+ describe('buildCheckDescriptionPrompt', () => {
+ it('includes provider, severity, title and description', () => {
+ const prompt = buildCheckDescriptionPrompt({
+ provider: 'aws',
+ serviceName: 'IAM',
+ title: 'IAM user "john" does not have MFA enabled',
+ description: 'User john has no MFA device configured.',
+ severity: 'high',
+ remediation: 'Enable MFA via IAM console.',
+ });
+ expect(prompt).toContain('AWS');
+ expect(prompt).toContain('IAM');
+ expect(prompt).toContain('high');
+ expect(prompt).toContain('john');
+ expect(prompt).toContain('Enable MFA');
+ });
+
+ it('omits null/empty fields cleanly', () => {
+ const prompt = buildCheckDescriptionPrompt({
+ provider: 'aws',
+ serviceName: null,
+ title: 'Untitled finding',
+ description: null,
+ severity: null,
+ remediation: null,
+ });
+ expect(prompt).toContain('Untitled finding');
+ expect(prompt).not.toContain('Service:');
+ expect(prompt).not.toContain('Finding description:');
+ expect(prompt).not.toContain('Suggested remediation:');
+ });
+ });
+});
diff --git a/apps/api/src/cloud-security/ai-description.prompt.ts b/apps/api/src/cloud-security/ai-description.prompt.ts
new file mode 100644
index 0000000000..f487ab37fe
--- /dev/null
+++ b/apps/api/src/cloud-security/ai-description.prompt.ts
@@ -0,0 +1,128 @@
+import { z } from 'zod';
+
+/**
+ * Output shape of the "About this check" panel auditors see when they
+ * expand a finding. Tier 3 = title, description, pass/fail criteria,
+ * rationale. NOTHING ELSE — no compliance control numbers, no external
+ * URLs, no framework claims. Server-side validation strips forbidden
+ * content before persisting to the cache.
+ */
+export const checkDescriptionSchema = z.object({
+ title: z
+ .string()
+ .min(1)
+ .max(160)
+ .describe('Plain-English summary of what this check verifies (~1 sentence).'),
+ description: z
+ .string()
+ .min(20)
+ .max(600)
+ .describe(
+ 'What the check actually verifies in plain English (1-3 sentences). No control numbers, no URLs, no framework claims.',
+ ),
+ passCriteria: z
+ .string()
+ .min(10)
+ .max(300)
+ .describe(
+ 'The configuration condition that makes this check pass. 1 sentence.',
+ ),
+ failCriteria: z
+ .string()
+ .min(10)
+ .max(300)
+ .describe(
+ 'The configuration condition that makes this check fail. 1 sentence.',
+ ),
+ whyItMatters: z
+ .string()
+ .min(20)
+ .max(600)
+ .describe(
+ 'Security/risk rationale in plain English. 1-2 sentences. NO compliance citations.',
+ ),
+});
+
+export type CheckDescription = z.infer;
+
+export const CHECK_DESCRIPTION_SYSTEM_PROMPT = `You write audit-friendly explanations of cloud-security checks.
+
+Audience: an external auditor (SOC 2 / ISO 27001) reviewing the customer's environment. They need to TRUST the automation by understanding what each check verifies.
+
+OUTPUT
+- Return only the fields requested by the schema. Nothing else.
+- Tone: neutral, professional, present tense, third person.
+- Plain English. Avoid product jargon when a simpler word works.
+
+HARD RULES (output that violates these is stripped server-side):
+- DO NOT mention any specific compliance control number (no "SOC 2 CC6.1", "ISO 27001 A.9.4.3", "NIST AC-2", "HIPAA \xA7164.312", "CIS 1.8", "PCI 8.2.3", etc.).
+- DO NOT name a compliance framework as if it requires this check (no "required by SOC 2", "ISO mandates", "HIPAA-aligned").
+- DO NOT include any URL or external link.
+- DO NOT invent product features the input doesn't describe.
+
+Style: short, clear, factual.`;
+
+export interface CheckDescriptionInput {
+ provider: 'aws' | 'gcp' | 'azure' | string;
+ serviceName: string | null;
+ title: string;
+ description: string | null;
+ severity: string | null;
+ remediation: string | null;
+}
+
+export function buildCheckDescriptionPrompt(input: CheckDescriptionInput): string {
+ return [
+ `Provider: ${input.provider.toUpperCase()}`,
+ input.serviceName ? `Service: ${input.serviceName}` : null,
+ `Severity: ${input.severity ?? 'unknown'}`,
+ `Finding title: "${input.title}"`,
+ input.description ? `Finding description: "${input.description}"` : null,
+ input.remediation ? `Suggested remediation: "${input.remediation}"` : null,
+ '',
+ 'Generate a CheckDefinition for this check. Focus on what the CHECK as a class verifies — not on the specific resource that failed.',
+ ]
+ .filter(Boolean)
+ .join('\n');
+}
+
+/**
+ * Detect content that slipped past the prompt — typical AI hallucinations
+ * we want to keep out of the rendered panel. Returns a list of regex
+ * patterns that should NEVER match in production output.
+ */
+const FORBIDDEN_PATTERNS: readonly RegExp[] = [
+ // Compliance control numbers and framework citations
+ /\bSOC ?2\b/i,
+ /\bISO ?27001\b/i,
+ /\bISO ?27002\b/i,
+ /\bHIPAA\b/i,
+ /\bNIST\b/i,
+ /\bPCI ?DSS\b/i,
+ /\bCIS ?Benchmark\b/i,
+ /\bCC\d+\.\d+\b/i, // SOC 2 control numbers like CC6.1
+ /\bA\.\d+\.\d+(\.\d+)?\b/, // ISO control numbers like A.9.4.3
+ // URLs
+ /https?:\/\//i,
+ /www\./i,
+];
+
+/**
+ * Return the first forbidden pattern that matches any field's value, or
+ * null when output is clean. Used as a server-side backstop to the prompt.
+ */
+export function findForbiddenContent(
+ description: CheckDescription,
+): { field: keyof CheckDescription; pattern: string } | null {
+ for (const [field, value] of Object.entries(description) as [
+ keyof CheckDescription,
+ string,
+ ][]) {
+ for (const pattern of FORBIDDEN_PATTERNS) {
+ if (pattern.test(value)) {
+ return { field, pattern: pattern.source };
+ }
+ }
+ }
+ return null;
+}
diff --git a/apps/api/src/cloud-security/ai-description.service.ts b/apps/api/src/cloud-security/ai-description.service.ts
new file mode 100644
index 0000000000..388c6ce448
--- /dev/null
+++ b/apps/api/src/cloud-security/ai-description.service.ts
@@ -0,0 +1,59 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { generateObject } from 'ai';
+import { anthropic } from '@ai-sdk/anthropic';
+import {
+ CHECK_DESCRIPTION_SYSTEM_PROMPT,
+ buildCheckDescriptionPrompt,
+ checkDescriptionSchema,
+ findForbiddenContent,
+ type CheckDescription,
+ type CheckDescriptionInput,
+} from './ai-description.prompt';
+
+/**
+ * Haiku 4.5 — cheap, fast, plenty good for descriptive text. Locked here
+ * so cache invalidation can detect model upgrades via `modelVersion`.
+ */
+export const DESCRIPTION_MODEL_VERSION = 'claude-haiku-4-5';
+const MODEL = anthropic(DESCRIPTION_MODEL_VERSION);
+
+@Injectable()
+export class AiDescriptionService {
+ private readonly logger = new Logger(AiDescriptionService.name);
+
+ /**
+ * Generate a Tier 3 "About this check" panel from a finding's metadata.
+ * Returns null on any AI failure — callers should surface a graceful
+ * fallback (showing only the existing per-finding description) rather
+ * than throwing.
+ */
+ async generate(input: CheckDescriptionInput): Promise {
+ try {
+ const { object } = await generateObject({
+ model: MODEL,
+ schema: checkDescriptionSchema,
+ system: CHECK_DESCRIPTION_SYSTEM_PROMPT,
+ prompt: buildCheckDescriptionPrompt(input),
+ temperature: 0,
+ });
+
+ // Server-side backstop: if Haiku slipped past the prompt and emitted
+ // a compliance control number or URL, refuse to cache it. Callers
+ // get null and the UI falls back to existing content.
+ const violation = findForbiddenContent(object);
+ if (violation) {
+ this.logger.warn(
+ `AI description for "${input.title}" rejected: ${violation.field} matched forbidden pattern ${violation.pattern}`,
+ );
+ return null;
+ }
+
+ return object;
+ } catch (err) {
+ this.logger.error(
+ `AI description generation failed for "${input.title}": ${err instanceof Error ? err.message : String(err)}`,
+ );
+ return null;
+ }
+ }
+}
diff --git a/apps/api/src/cloud-security/check-definition-provider-passthrough.ts b/apps/api/src/cloud-security/check-definition-provider-passthrough.ts
new file mode 100644
index 0000000000..1aadc588ed
--- /dev/null
+++ b/apps/api/src/cloud-security/check-definition-provider-passthrough.ts
@@ -0,0 +1,89 @@
+/**
+ * GCP/Azure check descriptions are NOT generated by AI — they're surfaced
+ * directly from the provider's own evidence payload, which already
+ * contains auditor-grade descriptions written by Google / Microsoft.
+ *
+ * This module isolates the per-provider mapping so the main
+ * CheckDefinitionService stays focused on cache + AI orchestration.
+ */
+
+import type { ResolvedCheckDescription } from './check-definition.service';
+
+export interface PassthroughInput {
+ provider: 'gcp' | 'azure' | string;
+ title: string;
+ description: string | null;
+ evidence: Record | null;
+}
+
+function humanizeCategory(category: string): string {
+ return category
+ .toLowerCase()
+ .replace(/_/g, ' ')
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+function fromGcpEvidence(
+ input: PassthroughInput,
+): ResolvedCheckDescription | null {
+ if (!input.evidence) return null;
+ const category =
+ typeof input.evidence.category === 'string'
+ ? input.evidence.category
+ : null;
+ const findingClass =
+ typeof input.evidence.findingClass === 'string'
+ ? input.evidence.findingClass
+ : null;
+ if (!category) return null;
+
+ return {
+ title: input.title,
+ description: input.description ?? humanizeCategory(category),
+ passCriteria: `No ${category
+ .toLowerCase()
+ .replace(/_/g, ' ')} detected by Google Security Command Center.`,
+ failCriteria: input.description
+ ? input.description
+ : `Google Security Command Center flagged this resource under category ${category}.`,
+ whyItMatters: findingClass
+ ? `Google classifies this finding as a "${findingClass}" — addressing it reduces the underlying security risk Google identified.`
+ : 'Findings from Google Security Command Center indicate a security concern Google detected in the customer environment.',
+ source: 'provider',
+ };
+}
+
+function fromAzureEvidence(
+ input: PassthroughInput,
+): ResolvedCheckDescription | null {
+ const alertType =
+ input.evidence && typeof input.evidence.alertType === 'string'
+ ? input.evidence.alertType
+ : input.evidence && typeof input.evidence.serviceName === 'string'
+ ? (input.evidence.serviceName as string)
+ : null;
+
+ return {
+ title: input.title,
+ description:
+ input.description ??
+ 'Surfaced from Microsoft Defender for Cloud. Defender flagged this resource as not meeting its recommended secure configuration.',
+ passCriteria:
+ 'Microsoft Defender for Cloud reports the resource as healthy.',
+ failCriteria: input.description
+ ? input.description
+ : 'Microsoft Defender for Cloud reports the resource as unhealthy.',
+ whyItMatters: alertType
+ ? `Defender raised this finding under "${alertType}" — addressing it brings the resource in line with Microsoft\'s recommended configuration.`
+ : 'Defender for Cloud findings indicate a deviation from Microsoft-recommended secure configuration in the customer environment.',
+ source: 'provider',
+ };
+}
+
+export function buildProviderPassthroughDescription(
+ input: PassthroughInput,
+): ResolvedCheckDescription | null {
+ if (input.provider === 'gcp') return fromGcpEvidence(input);
+ if (input.provider === 'azure') return fromAzureEvidence(input);
+ return null;
+}
diff --git a/apps/api/src/cloud-security/check-definition.service.spec.ts b/apps/api/src/cloud-security/check-definition.service.spec.ts
new file mode 100644
index 0000000000..c9f45aa777
--- /dev/null
+++ b/apps/api/src/cloud-security/check-definition.service.spec.ts
@@ -0,0 +1,57 @@
+import { normalizeCheckId } from './check-definition.utils';
+
+// We test the pure-function exports here. The full CheckDefinitionService
+// orchestration (db + ai) is covered by integration patterns; this file
+// focuses on the normalization logic that determines cache identity —
+// getting that wrong silently breaks the per-org cache.
+
+describe('normalizeCheckId', () => {
+ it('returns the input unchanged when resourceId is null', () => {
+ expect(normalizeCheckId('iam-no-password-policy', null)).toBe(
+ 'iam-no-password-policy',
+ );
+ });
+
+ it('strips a resource-specific suffix matching the resourceId', () => {
+ expect(normalizeCheckId('iam-no-mfa-john', 'john')).toBe('iam-no-mfa');
+ expect(normalizeCheckId('cloudtrail-not-logging-prod-trail', 'prod-trail')).toBe(
+ 'cloudtrail-not-logging',
+ );
+ });
+
+ it('returns input unchanged when resourceId is "account-level"', () => {
+ // Fixed-id checks use 'account-level' as a placeholder resourceId.
+ // No suffix to strip.
+ expect(normalizeCheckId('iam-no-password-policy', 'account-level')).toBe(
+ 'iam-no-password-policy',
+ );
+ });
+
+ it('handles compound resourceIds by trying each path segment', () => {
+ // e.g. AWS API Gateway uses "${apiId}/${routeKey}" as resourceId.
+ expect(
+ normalizeCheckId('apigw-no-auth-abc123-route-1', 'abc123/route-1'),
+ ).toBe('apigw-no-auth-abc123');
+ });
+
+ it('returns the input unchanged when no suffix can be matched', () => {
+ expect(normalizeCheckId('iam-no-mfa', 'unrelated-resource')).toBe(
+ 'iam-no-mfa',
+ );
+ });
+
+ it('preserves uniqueness across DIFFERENT check types for the same resource', () => {
+ // Sanity: two findings on the same resource but different checks should
+ // normalize to different keys so the cache stays correct.
+ expect(normalizeCheckId('iam-no-mfa-john', 'john')).not.toBe(
+ normalizeCheckId('iam-no-access-keys-john', 'john'),
+ );
+ });
+
+ it('produces the same normalized key for two resources of the same check', () => {
+ // The cache benefit: different users, same check → one cache entry.
+ expect(normalizeCheckId('iam-no-mfa-john', 'john')).toBe(
+ normalizeCheckId('iam-no-mfa-alice', 'alice'),
+ );
+ });
+});
diff --git a/apps/api/src/cloud-security/check-definition.service.ts b/apps/api/src/cloud-security/check-definition.service.ts
new file mode 100644
index 0000000000..2ffa636d16
--- /dev/null
+++ b/apps/api/src/cloud-security/check-definition.service.ts
@@ -0,0 +1,239 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { db } from '@db';
+import { AiDescriptionService, DESCRIPTION_MODEL_VERSION } from './ai-description.service';
+import type { CheckDescription } from './ai-description.prompt';
+import { buildProviderPassthroughDescription } from './check-definition-provider-passthrough';
+import { computeSourceHash, normalizeCheckId } from './check-definition.utils';
+
+export { normalizeCheckId };
+
+/**
+ * The CheckDescription returned to clients. `source` lets the UI render a
+ * subtle hint about whether the content was AI-generated (AWS) or
+ * surfaced directly from the cloud provider's own catalog (GCP / Azure).
+ */
+export interface ResolvedCheckDescription extends CheckDescription {
+ source: 'ai' | 'provider';
+}
+
+export interface CheckDescriptionRequest {
+ organizationId: string;
+ /** Normalized, resource-agnostic check identifier — see normalizeCheckId. */
+ checkId: string;
+ provider: 'aws' | 'gcp' | 'azure' | string;
+ serviceName: string | null;
+ /** Per-finding title / description / remediation — the SOURCE we hash. */
+ title: string;
+ description: string | null;
+ severity: string | null;
+ remediation: string | null;
+ /** Raw evidence — used for GCP/Azure passthrough only. */
+ evidence: Record | null;
+}
+
+
+@Injectable()
+export class CheckDefinitionService {
+ private readonly logger = new Logger(CheckDefinitionService.name);
+
+ constructor(private readonly aiDescription: AiDescriptionService) {}
+
+ /**
+ * Resolve a check description for a finding by its ID. Scoped to the
+ * caller's organization to prevent cross-tenant leaks. Returns null
+ * when the finding doesn't exist or no useful description can be
+ * produced (UI degrades gracefully).
+ */
+ async getForFinding(
+ findingId: string,
+ organizationId: string,
+ ): Promise {
+ const request = await this.resolveFindingToRequest(
+ findingId,
+ organizationId,
+ );
+ if (!request) return null;
+ return this.getOrCreate(request);
+ }
+
+ private async resolveFindingToRequest(
+ findingId: string,
+ organizationId: string,
+ ): Promise {
+ // Try new-platform first (IntegrationCheckResult — id prefix `icx_`).
+ const newResult = await db.integrationCheckResult.findFirst({
+ where: {
+ id: findingId,
+ checkRun: { connection: { organizationId } },
+ },
+ select: {
+ title: true,
+ description: true,
+ severity: true,
+ remediation: true,
+ resourceId: true,
+ evidence: true,
+ checkRun: {
+ select: {
+ checkId: true,
+ connection: { select: { provider: { select: { slug: true } } } },
+ },
+ },
+ },
+ });
+
+ if (newResult) {
+ const evidence =
+ newResult.evidence && typeof newResult.evidence === 'object'
+ ? (newResult.evidence as Record)
+ : null;
+ const findingKey =
+ evidence && typeof evidence.findingKey === 'string'
+ ? evidence.findingKey
+ : newResult.checkRun.checkId;
+ const serviceName =
+ evidence && typeof evidence.serviceName === 'string'
+ ? evidence.serviceName
+ : evidence && typeof evidence.service === 'string'
+ ? evidence.service
+ : null;
+ return {
+ organizationId,
+ checkId: normalizeCheckId(findingKey, newResult.resourceId),
+ provider: newResult.checkRun.connection.provider.slug,
+ serviceName,
+ title: newResult.title ?? '',
+ description: newResult.description,
+ severity: newResult.severity,
+ remediation: newResult.remediation,
+ evidence,
+ };
+ }
+
+ // Fall back to legacy IntegrationResult (id prefix `itr_`).
+ const legacy = await db.integrationResult.findFirst({
+ where: { id: findingId, organizationId },
+ select: {
+ title: true,
+ description: true,
+ severity: true,
+ remediation: true,
+ resultDetails: true,
+ integration: { select: { integrationId: true } },
+ },
+ });
+
+ if (!legacy) return null;
+
+ const details =
+ legacy.resultDetails && typeof legacy.resultDetails === 'object'
+ ? (legacy.resultDetails as Record)
+ : null;
+ return {
+ organizationId,
+ // Legacy results have no granular checkKey — use title as the cache key
+ // (low-fidelity but stable across instances).
+ checkId: legacy.title ?? findingId,
+ provider: legacy.integration.integrationId,
+ serviceName: null,
+ title: legacy.title ?? '',
+ description: legacy.description,
+ severity: legacy.severity,
+ remediation: legacy.remediation,
+ evidence: details,
+ };
+ }
+
+ /**
+ * Resolve a check description for a finding.
+ *
+ * - AWS: cached per (orgId, checkId) with source-hash invalidation.
+ * First-view triggers a Haiku call (~1-2s); all subsequent views and
+ * all other findings of the same check type hit the cache (~50ms).
+ * - GCP / Azure: derived synchronously from provider evidence — no AI,
+ * no DB cache. Returns null when evidence doesn't carry enough context.
+ */
+ async getOrCreate(
+ req: CheckDescriptionRequest,
+ ): Promise {
+ if (req.provider === 'gcp' || req.provider === 'azure') {
+ return buildProviderPassthroughDescription({
+ provider: req.provider,
+ title: req.title,
+ description: req.description,
+ evidence: req.evidence,
+ });
+ }
+ return this.fromCacheOrGenerate(req);
+ }
+
+ private async fromCacheOrGenerate(
+ req: CheckDescriptionRequest,
+ ): Promise {
+ const sourceHash = computeSourceHash(req);
+
+ const cached = await db.checkDefinition.findUnique({
+ where: {
+ organizationId_checkId: {
+ organizationId: req.organizationId,
+ checkId: req.checkId,
+ },
+ },
+ });
+
+ if (cached && cached.sourceHash === sourceHash) {
+ return {
+ title: cached.title,
+ description: cached.description,
+ passCriteria: cached.passCriteria,
+ failCriteria: cached.failCriteria,
+ whyItMatters: cached.whyItMatters,
+ source: 'ai',
+ };
+ }
+
+ const generated = await this.aiDescription.generate({
+ provider: req.provider,
+ serviceName: req.serviceName,
+ title: req.title,
+ description: req.description,
+ severity: req.severity,
+ remediation: req.remediation,
+ });
+
+ if (!generated) return null;
+
+ try {
+ await db.checkDefinition.upsert({
+ where: {
+ organizationId_checkId: {
+ organizationId: req.organizationId,
+ checkId: req.checkId,
+ },
+ },
+ create: {
+ organizationId: req.organizationId,
+ checkId: req.checkId,
+ sourceHash,
+ modelVersion: DESCRIPTION_MODEL_VERSION,
+ ...generated,
+ },
+ update: {
+ sourceHash,
+ modelVersion: DESCRIPTION_MODEL_VERSION,
+ generatedAt: new Date(),
+ ...generated,
+ },
+ });
+ } catch (err) {
+ // Persist failure shouldn't break the read path — log and serve the
+ // freshly-generated content anyway.
+ this.logger.warn(
+ `CheckDefinition cache write failed for ${req.checkId}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ return { ...generated, source: 'ai' };
+ }
+
+}
diff --git a/apps/api/src/cloud-security/check-definition.utils.ts b/apps/api/src/cloud-security/check-definition.utils.ts
new file mode 100644
index 0000000000..5b67ef314e
--- /dev/null
+++ b/apps/api/src/cloud-security/check-definition.utils.ts
@@ -0,0 +1,67 @@
+/**
+ * Pure helpers shared between CheckDefinitionService and its tests.
+ * Kept in a separate file so tests can import them without pulling in
+ * the Prisma client (which throws at import time when DATABASE_URL is
+ * missing in a unit-test env).
+ */
+
+import { createHash } from 'node:crypto';
+import { DESCRIPTION_MODEL_VERSION } from './ai-description.service';
+
+/**
+ * Strip the resource-specific suffix from a finding's `findingKey` so all
+ * resource instances of the same check share a single cache entry.
+ *
+ * Examples:
+ * ("iam-no-mfa-john", "john") -> "iam-no-mfa"
+ * ("cloudtrail-not-logging-prod-trail", "prod-trail") -> "cloudtrail-not-logging"
+ * ("iam-no-password-policy", "account-level") -> "iam-no-password-policy"
+ */
+export function normalizeCheckId(
+ findingKey: string,
+ resourceId: string | null,
+): string {
+ if (!resourceId) return findingKey;
+
+ const suffix = `-${resourceId}`;
+ if (findingKey.endsWith(suffix)) {
+ return findingKey.slice(0, -suffix.length);
+ }
+ // Try each segment of compound resource ids (e.g. "api/route-1" -> ["api", "route-1"]).
+ for (const segment of resourceId.split(/[/.]/)) {
+ if (!segment) continue;
+ const segSuffix = `-${segment}`;
+ if (findingKey.endsWith(segSuffix)) {
+ return findingKey.slice(0, -segSuffix.length);
+ }
+ }
+ return findingKey;
+}
+
+export interface SourceHashInput {
+ provider: string;
+ serviceName: string | null;
+ title: string;
+ description: string | null;
+ severity: string | null;
+ remediation: string | null;
+}
+
+/**
+ * Hash of the inputs that drive Haiku output. When this changes — because
+ * the adapter altered the finding's title/description/severity/remediation
+ * — the cache entry is regenerated on the next view. Includes the model
+ * version so flipping DESCRIPTION_MODEL_VERSION forces a global refresh.
+ */
+export function computeSourceHash(input: SourceHashInput): string {
+ const payload = JSON.stringify({
+ provider: input.provider,
+ serviceName: input.serviceName,
+ title: input.title,
+ description: input.description,
+ severity: input.severity,
+ remediation: input.remediation,
+ model: DESCRIPTION_MODEL_VERSION,
+ });
+ return createHash('sha256').update(payload).digest('hex');
+}
diff --git a/apps/api/src/cloud-security/cloud-security-audit.ts b/apps/api/src/cloud-security/cloud-security-audit.ts
index 75285b6827..9e3f4ea0b9 100644
--- a/apps/api/src/cloud-security/cloud-security-audit.ts
+++ b/apps/api/src/cloud-security/cloud-security-audit.ts
@@ -11,7 +11,9 @@ interface CloudSecurityAuditParams {
| 'remediation_failed'
| 'rollback_executed'
| 'rollback_failed'
- | 'service_toggled';
+ | 'service_toggled'
+ | 'exception_marked'
+ | 'exception_revoked';
description: string;
metadata?: Record;
}
diff --git a/apps/api/src/cloud-security/cloud-security-query.legacy.ts b/apps/api/src/cloud-security/cloud-security-query.legacy.ts
new file mode 100644
index 0000000000..e6984cc0c5
--- /dev/null
+++ b/apps/api/src/cloud-security/cloud-security-query.legacy.ts
@@ -0,0 +1,89 @@
+/**
+ * Legacy (pre-integration-platform) cloud security queries.
+ *
+ * Extracted from cloud-security-query.service.ts so the main service stays
+ * under the 300-line cap and so the legacy path can be deleted as a unit
+ * once the legacy Integration / IntegrationResult tables are retired.
+ */
+
+import { db } from '@db';
+import { sanitizeEvidence } from './evidence-sanitizer';
+import type { CloudFinding } from './cloud-security-query.types';
+
+const CLOUD_PROVIDER_SLUGS = ['aws', 'gcp', 'azure'] as const;
+
+/** Scan window for filtering legacy results to latest scan only */
+const SCAN_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
+
+export async function getLegacyFindings(
+ organizationId: string,
+): Promise {
+ const legacyIntegrations = await db.integration.findMany({
+ where: { organizationId },
+ });
+
+ const activeLegacy = legacyIntegrations.filter((i) =>
+ (CLOUD_PROVIDER_SLUGS as readonly string[]).includes(i.integrationId),
+ );
+
+ const legacyIds = activeLegacy.map((i) => i.id);
+ if (legacyIds.length === 0) return [];
+
+ const lastRunMap = new Map(
+ activeLegacy.filter((i) => i.lastRunAt).map((i) => [i.id, i.lastRunAt!]),
+ );
+
+ const results = await db.integrationResult.findMany({
+ where: { integrationId: { in: legacyIds } },
+ select: {
+ id: true,
+ title: true,
+ description: true,
+ remediation: true,
+ status: true,
+ severity: true,
+ completedAt: true,
+ resultDetails: true,
+ integration: {
+ select: { integrationId: true, id: true, lastRunAt: true },
+ },
+ },
+ orderBy: { completedAt: 'desc' },
+ });
+
+ // Only include results from the most recent scan window per integration.
+ const filtered = results.filter((result) => {
+ const lastRunAt = lastRunMap.get(result.integration.id);
+ if (!lastRunAt) return result.completedAt !== null;
+ if (!result.completedAt) return false;
+
+ const lastRunTime = lastRunAt.getTime();
+ const completedTime = result.completedAt.getTime();
+ return (
+ completedTime <= lastRunTime &&
+ completedTime >= lastRunTime - SCAN_WINDOW_MS
+ );
+ });
+
+ return filtered.map((result) => ({
+ id: result.id,
+ title: result.title,
+ description: result.description,
+ remediation: result.remediation,
+ status: result.status,
+ severity: result.severity,
+ completedAt: result.completedAt,
+ connectionId: result.integration.id,
+ providerSlug: result.integration.integrationId,
+ serviceId: null,
+ findingKey: null,
+ resourceId: null,
+ // Legacy IntegrationResult model has neither resourceType nor checkId
+ resourceType: null,
+ checkId: null,
+ checkKey: null,
+ evidence: sanitizeEvidence(result.resultDetails ?? null),
+ projectDisplayName: null,
+ integration: { integrationId: result.integration.integrationId },
+ }));
+}
diff --git a/apps/api/src/cloud-security/cloud-security-query.service.ts b/apps/api/src/cloud-security/cloud-security-query.service.ts
index 1cdba70c00..2388b5324b 100644
--- a/apps/api/src/cloud-security/cloud-security-query.service.ts
+++ b/apps/api/src/cloud-security/cloud-security-query.service.ts
@@ -1,12 +1,21 @@
import { Injectable } from '@nestjs/common';
import { db } from '@db';
import { getManifest } from '@trycompai/integration-platform';
+import { sanitizeEvidence } from './evidence-sanitizer';
+import { getLegacyFindings } from './cloud-security-query.legacy';
+import { normalizeCheckId } from './check-definition.utils';
+import type {
+ CloudFinding,
+ CloudProvider,
+ CloudProviderLatestRun,
+} from './cloud-security-query.types';
+
+// Re-export so existing imports of CloudProvider/CloudFinding from the service
+// path keep working (the controller imports them).
+export type { CloudFinding, CloudProvider, CloudProviderLatestRun };
const CLOUD_PROVIDER_SLUGS = ['aws', 'gcp', 'azure'] as const;
-/** Scan window for filtering legacy results to latest scan only */
-const SCAN_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
-
/** Extract project ID from a GCP resource path like //iam.googleapis.com/projects/my-proj/... */
function extractProjectIdFromResource(
resourceId: string | null,
@@ -16,45 +25,6 @@ function extractProjectIdFromResource(
return match?.[1] ?? null;
}
-export interface CloudProvider {
- id: string;
- integrationId: string;
- name: string;
- displayName?: string;
- organizationId: string;
- lastRunAt: Date | null;
- status: string;
- createdAt: Date;
- updatedAt: Date;
- reconnectedAt?: Date;
- isLegacy: boolean;
- variables: Record | null;
- requiredVariables: string[];
- accountId?: string;
- awsType?: string;
- regions?: string[];
- tenantId?: string;
- subscriptionId?: string;
- supportsMultipleConnections?: boolean;
-}
-
-export interface CloudFinding {
- id: string;
- title: string | null;
- description: string | null;
- remediation: string | null;
- status: string | null;
- severity: string | null;
- completedAt: Date | null;
- connectionId: string;
- providerSlug: string;
- serviceId: string | null;
- findingKey: string | null;
- resourceId: string | null;
- projectDisplayName: string | null;
- integration: { integrationId: string };
-}
-
/** Get required variables from manifest (both manifest-level and check-level) */
function getRequiredVariables(providerSlug: string): string[] {
const manifest = getManifest(providerSlug);
@@ -103,6 +73,12 @@ export class CloudSecurityQueryService {
(CLOUD_PROVIDER_SLUGS as readonly string[]).includes(i.integrationId),
);
+ // Per-connection latest scan summary for new-platform connections — one
+ // query, distinct-by-connection so we get only the most recent run each.
+ const latestRunByConnection = await this.getLatestRunsByConnection(
+ newConnections.map((c) => c.id),
+ );
+
// Map new connections
const newProviders: CloudProvider[] = newConnections.map((conn) => {
const metadata = (conn.metadata || {}) as Record;
@@ -147,6 +123,7 @@ export class CloudSecurityQueryService {
: undefined,
supportsMultipleConnections:
manifest?.supportsMultipleConnections ?? false,
+ latestRun: latestRunByConnection.get(conn.id) ?? null,
};
});
@@ -187,21 +164,107 @@ export class CloudSecurityQueryService {
: undefined,
supportsMultipleConnections:
manifest?.supportsMultipleConnections ?? false,
+ latestRun: integration.lastRunAt
+ ? {
+ completedAt: integration.lastRunAt,
+ durationMs: null,
+ totalChecked: null,
+ passedCount: null,
+ failedCount: null,
+ status: 'success',
+ }
+ : null,
};
});
return [...newProviders, ...legacyProviders];
}
- async getFindings(organizationId: string): Promise {
- const newFindings = await this.getNewPlatformFindings(organizationId);
- const legacyFindings = await this.getLegacyFindings(organizationId);
+ async getFindings(
+ organizationId: string,
+ options: { includeExceptions?: boolean } = {},
+ ): Promise {
+ const [newFindings, legacyFindings] = await Promise.all([
+ this.getNewPlatformFindings(organizationId),
+ getLegacyFindings(organizationId),
+ ]);
- return [...newFindings, ...legacyFindings].sort((a, b) => {
+ const combined = [...newFindings, ...legacyFindings].sort((a, b) => {
const dateA = a.completedAt ? new Date(a.completedAt).getTime() : 0;
const dateB = b.completedAt ? new Date(b.completedAt).getTime() : 0;
return dateB - dateA;
});
+
+ if (options.includeExceptions) return combined;
+
+ // Filter out findings under an active (non-revoked, non-expired)
+ // FindingException. Looked up in one query keyed by org so the cost
+ // stays constant regardless of finding count.
+ const activeExceptionKeys = await this.loadActiveExceptionKeys(organizationId);
+ if (activeExceptionKeys.size === 0) return combined;
+
+ return combined.filter((finding) => {
+ if (!finding.checkKey || !finding.resourceId) return true;
+ const key = `${finding.connectionId}::${finding.checkKey}::${finding.resourceId}`;
+ return !activeExceptionKeys.has(key);
+ });
+ }
+
+ /**
+ * Return the set of (connectionId, checkId, resourceId) tuples that have
+ * an active exception in this org. One DB query per getFindings call.
+ */
+ private async loadActiveExceptionKeys(
+ organizationId: string,
+ ): Promise> {
+ const active = await db.findingException.findMany({
+ where: {
+ organizationId,
+ revokedAt: null,
+ OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
+ },
+ select: { connectionId: true, checkId: true, resourceId: true },
+ });
+ return new Set(
+ active.map((e) => `${e.connectionId}::${e.checkId}::${e.resourceId}`),
+ );
+ }
+
+ private async getLatestRunsByConnection(
+ connectionIds: string[],
+ ): Promise
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CheckGroupBlock.test.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CheckGroupBlock.test.tsx
new file mode 100644
index 0000000000..b21e2e1072
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CheckGroupBlock.test.tsx
@@ -0,0 +1,190 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import type { Finding } from '../types';
+import type { CheckGroup } from './check-groups';
+import { CheckGroupBlock } from './CheckGroupBlock';
+
+function makeFinding(overrides: Partial): Finding {
+ return {
+ id: `icx_${Math.random().toString(36).slice(2, 8)}`,
+ title: 'A finding',
+ description: null,
+ remediation: null,
+ status: 'failed',
+ severity: 'medium',
+ serviceId: 'iam',
+ findingKey: null,
+ resourceId: null,
+ resourceType: null,
+ checkId: null,
+ checkKey: 'iam-test',
+ evidence: null,
+ projectDisplayName: null,
+ completedAt: null,
+ connectionId: 'icn_test',
+ providerSlug: 'aws',
+ integration: { integrationId: 'aws' },
+ ...overrides,
+ };
+}
+
+function makeGroup(overrides: Partial = {}): CheckGroup {
+ const failed = overrides.failed ?? [
+ makeFinding({ id: 'f1', status: 'failed', resourceId: 'r1' }),
+ ];
+ const passed = overrides.passed ?? [];
+ return {
+ checkKey: 'iam-test',
+ checkTitle: 'IAM users have MFA enabled',
+ failed,
+ passed,
+ all: [...failed, ...passed],
+ severity: 'high',
+ ...overrides,
+ };
+}
+
+describe('CheckGroupBlock', () => {
+ it('renders the check title and failing/total count', () => {
+ const group = makeGroup({
+ failed: [
+ makeFinding({ id: 'f1', status: 'failed' }),
+ makeFinding({ id: 'f2', status: 'failed' }),
+ ],
+ passed: [
+ makeFinding({ id: 'p1', status: 'passed' }),
+ makeFinding({ id: 'p2', status: 'passed' }),
+ makeFinding({ id: 'p3', status: 'passed' }),
+ ],
+ });
+ render(
+ {f.id}
}
+ />,
+ );
+ expect(screen.getByText('IAM users have MFA enabled')).toBeInTheDocument();
+ expect(screen.getByText(/2 of 5 failing/i)).toBeInTheDocument();
+ });
+
+ it('renders only failing rows by default', () => {
+ const group = makeGroup({
+ failed: [makeFinding({ id: 'f1', status: 'failed' })],
+ passed: [
+ makeFinding({ id: 'p1', status: 'passed' }),
+ makeFinding({ id: 'p2', status: 'passed' }),
+ ],
+ });
+ render(
+ {f.id}
}
+ />,
+ );
+ expect(screen.getByTestId('row-f1')).toBeInTheDocument();
+ expect(screen.queryByTestId('row-p1')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('row-p2')).not.toBeInTheDocument();
+ });
+
+ it('reveals passing rows when "Show all" is clicked', () => {
+ const group = makeGroup({
+ failed: [makeFinding({ id: 'f1', status: 'failed' })],
+ passed: [
+ makeFinding({ id: 'p1', status: 'passed' }),
+ makeFinding({ id: 'p2', status: 'passed' }),
+ ],
+ });
+ render(
+ {f.id}
}
+ />,
+ );
+ fireEvent.click(screen.getByRole('button', { name: /show all 3 results/i }));
+ expect(screen.getByTestId('row-f1')).toBeInTheDocument();
+ expect(screen.getByTestId('row-p1')).toBeInTheDocument();
+ expect(screen.getByTestId('row-p2')).toBeInTheDocument();
+ });
+
+ it('renders compact all-passing line when no failures and no severity filter', () => {
+ const group = makeGroup({
+ failed: [],
+ passed: [
+ makeFinding({ id: 'p1', status: 'passed' }),
+ makeFinding({ id: 'p2', status: 'passed' }),
+ ],
+ });
+ render(
+ {f.id}
}
+ />,
+ );
+ expect(screen.getByText(/all 2 passing/i)).toBeInTheDocument();
+ expect(screen.queryByTestId('row-p1')).not.toBeInTheDocument();
+ });
+
+ it('hides the check entirely when severity filter is active and there is no failure', () => {
+ const group = makeGroup({
+ failed: [],
+ passed: [makeFinding({ id: 'p1', status: 'passed' })],
+ });
+ const { container } = render(
+ {f.id}
}
+ />,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('filters failing rows by active severity filter', () => {
+ const group = makeGroup({
+ failed: [
+ makeFinding({ id: 'f1', status: 'failed', severity: 'critical' }),
+ makeFinding({ id: 'f2', status: 'failed', severity: 'medium' }),
+ ],
+ passed: [],
+ });
+ render(
+ {f.id}
}
+ />,
+ );
+ expect(screen.getByTestId('row-f1')).toBeInTheDocument();
+ expect(screen.queryByTestId('row-f2')).not.toBeInTheDocument();
+ });
+
+ it('caps rendered rows at 100 and shows a Show-more affordance', () => {
+ // 150 failing rows — only 100 should render initially.
+ const failed = Array.from({ length: 150 }, (_, i) =>
+ makeFinding({ id: `f${i}`, status: 'failed', resourceId: `r${i}` }),
+ );
+ const group = makeGroup({ failed, passed: [] });
+ render(
+ {f.id}
}
+ />,
+ );
+ // 100 rows rendered, 50 hidden behind a "Show more" button.
+ expect(screen.getByTestId('row-f0')).toBeInTheDocument();
+ expect(screen.getByTestId('row-f99')).toBeInTheDocument();
+ expect(screen.queryByTestId('row-f100')).not.toBeInTheDocument();
+ const showMore = screen.getByRole('button', {
+ name: /show 50 more results/i,
+ });
+ expect(showMore).toBeInTheDocument();
+ fireEvent.click(showMore);
+ expect(screen.getByTestId('row-f100')).toBeInTheDocument();
+ expect(screen.getByTestId('row-f149')).toBeInTheDocument();
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CheckGroupBlock.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CheckGroupBlock.tsx
new file mode 100644
index 0000000000..ecdebad4a3
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CheckGroupBlock.tsx
@@ -0,0 +1,138 @@
+'use client';
+
+import { Badge } from '@trycompai/ui/badge';
+import { ChevronDown, ChevronRight, ShieldCheck } from 'lucide-react';
+import { useMemo, useState } from 'react';
+import type { Finding } from '../types';
+import type { CheckGroup } from './check-groups';
+
+/**
+ * Per-check display cap. If a single check produces more failing rows
+ * than this we render the first N and surface a "Show all N" affordance
+ * — keeps the browser responsive on the rare account with hundreds of
+ * resources of a single kind.
+ */
+const PER_CHECK_DISPLAY_LIMIT = 100;
+
+const SEVERITY_DOTS: Record = {
+ critical: 'bg-red-500',
+ high: 'bg-orange-500',
+ medium: 'bg-yellow-500',
+ low: 'bg-blue-500',
+ info: 'bg-gray-400',
+};
+
+export interface CheckGroupBlockProps {
+ group: CheckGroup;
+ /** Active severity filter from the parent — used to filter failing rows. */
+ severityFilter: string | null;
+ /** Renders the per-finding row (FindingRow lives in the parent file). */
+ renderRow: (finding: Finding) => React.ReactNode;
+}
+
+export function CheckGroupBlock({
+ group,
+ severityFilter,
+ renderRow,
+}: CheckGroupBlockProps) {
+ const [showAll, setShowAll] = useState(false);
+ const [revealedMore, setRevealedMore] = useState(false);
+
+ // Apply severity filter to the failing rows shown by default.
+ const visibleFailing = useMemo(() => {
+ if (!severityFilter) return group.failed;
+ return group.failed.filter(
+ (f) => f.severity?.toLowerCase() === severityFilter,
+ );
+ }, [group.failed, severityFilter]);
+
+ // When user toggles "Show all results" we render every instance (passed +
+ // failed). Severity filter is intentionally ignored in this mode because
+ // the user is explicitly asking for a full audit list.
+ const fullList = group.all;
+ const rendered = showAll ? fullList : visibleFailing;
+ const displayCap = revealedMore ? Infinity : PER_CHECK_DISPLAY_LIMIT;
+ const visibleRows = rendered.slice(0, displayCap);
+ const hiddenRowsCount = Math.max(0, rendered.length - visibleRows.length);
+
+ // Compact line for checks with no failures (and no severity filter).
+ if (group.failed.length === 0 && !showAll) {
+ if (severityFilter) return null;
+ return (
+
+
+ {group.checkTitle}
+
+ — all {group.all.length} passing
+
+ {group.all.length > 0 && (
+
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {group.checkTitle}
+
+
+ {group.failed.length} of {group.all.length} failing
+
+ {group.all.length > group.failed.length && (
+
+ )}
+
+ {visibleRows.length === 0 && severityFilter && (
+
+ No {severityFilter}-severity failures for this check.
+
+ )}
+ {visibleRows.map((finding) => (
+
{renderRow(finding)}
+ ))}
+ {hiddenRowsCount > 0 && !revealedMore && (
+
+ )}
+
+ );
+}
+
+export { PER_CHECK_DISPLAY_LIMIT };
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
index 414469f937..829521d988 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
@@ -1,6 +1,7 @@
'use client';
import { useApi } from '@/hooks/use-api';
+import { usePermissions } from '@/hooks/use-permissions';
import {
getAwsCloudShellUrl,
getAwsRemediationScript,
@@ -41,7 +42,12 @@ import { GcpSetupGuide } from './GcpSetupGuide';
import { RemediationDialog } from './RemediationDialog';
import { ScheduledScanPopover } from './ScheduledScanPopover';
-import type { Finding } from '../types';
+import type { Finding, ProviderLatestRun } from '../types';
+import { CheckDefinitionPanel } from './CheckDefinitionPanel';
+import { CheckGroupBlock } from './CheckGroupBlock';
+import { buildCheckGroups } from './check-groups';
+import { EvidenceJsonViewer } from './EvidenceJsonViewer';
+import { MarkExceptionModal } from './MarkExceptionModal';
interface RemediationCapabilities {
enabled: boolean;
@@ -63,6 +69,8 @@ interface CloudTestsSectionProps {
orgId: string;
/** When the last scan completed — null means never scanned */
lastRunAt?: Date | null;
+ /** Latest scan summary (counts, duration). null for legacy or never-scanned. */
+ latestRun?: ProviderLatestRun | null;
/** Connection variables (e.g., GCP org ID) */
variables?: Record;
awsType?: string;
@@ -155,6 +163,7 @@ const SERVICE_NAMES: Record = {
interface ServiceGroup {
serviceId: string;
name: string;
+ /** ALL findings for this service (passed + failed), already filtered by search query. */
findings: Finding[];
passed: number;
failed: number;
@@ -166,6 +175,7 @@ export function CloudTestsSection({
onScanComplete,
orgId,
lastRunAt,
+ latestRun,
variables,
awsType,
}: CloudTestsSectionProps) {
@@ -198,6 +208,13 @@ export function CloudTestsSection({
description?: string;
} | null>(null);
const [showSetupDialog, setShowSetupDialog] = useState(false);
+ const { hasPermission } = usePermissions();
+ const canMarkException = hasPermission('integration', 'update');
+ const [exceptionTarget, setExceptionTarget] = useState<{
+ findingId: string;
+ findingTitle: string;
+ resourceLabel: string | null;
+ } | null>(null);
const findingsResponse = api.useSWR<{ data: Finding[]; count: number }>(
'/v1/cloud-security/findings',
@@ -290,7 +307,9 @@ export function CloudTestsSection({
const failedFindings = findings.filter((f) => f.status === 'failed' || f.status === 'FAILED');
const passedFindings = findings.filter((f) => f.status === 'passed' || f.status === 'success');
- // Group findings by serviceId
+ // Group findings by serviceId. `findings` on the resulting group holds the
+ // full set (passed + failed) matching the search query — per-check sub-
+ // grouping and the severity filter are applied at render time.
const serviceGroups = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
const groupMap = new Map();
@@ -306,35 +325,48 @@ export function CloudTestsSection({
const serviceName = SERVICE_NAMES[serviceId] ?? serviceId;
const serviceMatches = q ? serviceName.toLowerCase().includes(q) : true;
- const failed = groupFindings.filter((f) => f.status === 'failed' || f.status === 'FAILED');
- const passed = groupFindings.filter((f) => f.status === 'passed' || f.status === 'success');
-
- let filteredFailed = severityFilter
- ? failed.filter((f) => f.severity?.toLowerCase() === severityFilter)
- : failed;
+ // If search query exists and service name doesn't match, filter findings
+ // by title/description/findingKey. Otherwise include all findings.
+ const matching =
+ q && !serviceMatches
+ ? groupFindings.filter(
+ (f) =>
+ f.title?.toLowerCase().includes(q) ||
+ f.description?.toLowerCase().includes(q) ||
+ f.findingKey?.toLowerCase().includes(q),
+ )
+ : groupFindings;
+
+ const failed = matching.filter(
+ (f) => f.status === 'failed' || f.status === 'FAILED',
+ );
+ const passed = matching.filter(
+ (f) => f.status === 'passed' || f.status === 'success',
+ );
- // If search query exists and service name doesn't match, filter findings by title
- if (q && !serviceMatches) {
- filteredFailed = filteredFailed.filter(
- (f) =>
- f.title?.toLowerCase().includes(q) ||
- f.description?.toLowerCase().includes(q) ||
- f.findingKey?.toLowerCase().includes(q),
+ // With severity filter active, hide services that have no matching
+ // failures. Without filters, keep services that have any findings.
+ if (severityFilter) {
+ const hasMatching = failed.some(
+ (f) => f.severity?.toLowerCase() === severityFilter,
);
+ if (!hasMatching) continue;
+ } else if (q && failed.length === 0 && passed.length === 0) {
+ continue;
}
groups.push({
serviceId,
name: serviceName,
- findings: filteredFailed,
+ findings: matching,
passed: passed.length,
failed: failed.length,
});
}
- return groups
- .filter((g) => g.findings.length > 0 || (!severityFilter && !q && g.passed > 0))
- .sort((a, b) => b.failed - a.failed || a.name.localeCompare(b.name));
+ return groups.sort(
+ (a, b) => b.failed - a.failed || a.name.localeCompare(b.name),
+ );
}, [findings, severityFilter, searchQuery]);
// Split into baseline (security fundamentals) vs service-specific
@@ -479,9 +511,9 @@ export function CloudTestsSection({
{/* Header with scan button */}
-
Security Findings
+
Scan Results
- {findings.length} total findings for this account
+ {findings.length} total results for this account
@@ -497,6 +529,9 @@ export function CloudTestsSection({
+ {/* Last scan metadata strip — surfaces what's already in IntegrationCheckRun */}
+
+
{/* Selected projects indicator (GCP) */}
{providerSlug === 'gcp' &&
(() => {
@@ -673,7 +708,7 @@ export function CloudTestsSection({
setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/40"
@@ -752,36 +787,43 @@ export function CloudTestsSection({
{isGroupExpanded && (
- {group.findings.length > 0 ? (
- group.findings.map((finding) => {
- const match = canFixFinding(finding);
- return (
-
toggleExpanded(finding.id)}
- remediationKey={match?.key ?? null}
- remediationEnabled={match?.enabled ?? false}
- capabilitiesLoaded={capabilitiesLoaded}
- onFix={(key) =>
- setRemediationTarget({
- connectionId: finding.connectionId,
- checkResultId: finding.id,
- remediationKey: key,
- findingTitle: finding.title ?? 'Finding',
- })
- }
- onSetup={() => setShowSetupDialog(true)}
- />
- );
- })
- ) : (
-
-
- All {group.passed} checks passed
-
- )}
+ {buildCheckGroups(group.findings).map((checkGroup) => (
+ {
+ const match = canFixFinding(finding);
+ return (
+ toggleExpanded(finding.id)}
+ remediationKey={match?.key ?? null}
+ remediationEnabled={match?.enabled ?? false}
+ capabilitiesLoaded={capabilitiesLoaded}
+ onFix={(key) =>
+ setRemediationTarget({
+ connectionId: finding.connectionId,
+ checkResultId: finding.id,
+ remediationKey: key,
+ findingTitle: finding.title ?? 'Finding',
+ })
+ }
+ onSetup={() => setShowSetupDialog(true)}
+ canMarkException={canMarkException}
+ onMarkException={() =>
+ setExceptionTarget({
+ findingId: finding.id,
+ findingTitle: finding.title ?? 'Finding',
+ resourceLabel: formatResourceLabel(finding),
+ })
+ }
+ />
+ );
+ }}
+ />
+ ))}
)}
@@ -847,36 +889,43 @@ export function CloudTestsSection({
{isGroupExpanded && (
- {group.findings.length > 0 ? (
- group.findings.map((finding) => {
- const match = canFixFinding(finding);
- return (
-
toggleExpanded(finding.id)}
- remediationKey={match?.key ?? null}
- remediationEnabled={match?.enabled ?? false}
- capabilitiesLoaded={capabilitiesLoaded}
- onFix={(key) =>
- setRemediationTarget({
- connectionId: finding.connectionId,
- checkResultId: finding.id,
- remediationKey: key,
- findingTitle: finding.title ?? 'Finding',
- })
- }
- onSetup={() => setShowSetupDialog(true)}
- />
- );
- })
- ) : (
-
-
- All {group.passed} checks passed
-
- )}
+ {buildCheckGroups(group.findings).map((checkGroup) => (
+ {
+ const match = canFixFinding(finding);
+ return (
+ toggleExpanded(finding.id)}
+ remediationKey={match?.key ?? null}
+ remediationEnabled={match?.enabled ?? false}
+ capabilitiesLoaded={capabilitiesLoaded}
+ onFix={(key) =>
+ setRemediationTarget({
+ connectionId: finding.connectionId,
+ checkResultId: finding.id,
+ remediationKey: key,
+ findingTitle: finding.title ?? 'Finding',
+ })
+ }
+ onSetup={() => setShowSetupDialog(true)}
+ canMarkException={canMarkException}
+ onMarkException={() =>
+ setExceptionTarget({
+ findingId: finding.id,
+ findingTitle: finding.title ?? 'Finding',
+ resourceLabel: formatResourceLabel(finding),
+ })
+ }
+ />
+ );
+ }}
+ />
+ ))}
)}
@@ -890,7 +939,7 @@ export function CloudTestsSection({
- No findings matching "{searchQuery}"
+ No results matching "{searchQuery}"
+
+
{
+ if (!open) setExceptionTarget(null);
+ }}
+ findingId={exceptionTarget?.findingId ?? null}
+ findingTitle={exceptionTarget?.findingTitle ?? ''}
+ resourceLabel={exceptionTarget?.resourceLabel ?? null}
+ onMarked={() => {
+ findingsResponse.mutate();
+ setExceptionTarget(null);
+ }}
+ />
);
}
@@ -1090,6 +1153,70 @@ function StatCard({
);
}
+function formatRelativeTime(date: Date): string {
+ const diff = Date.now() - date.getTime();
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 5) return 'just now';
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days === 1) return 'yesterday';
+ if (days < 7) return `${days}d ago`;
+ return date.toLocaleDateString();
+}
+
+function formatDuration(ms: number): string {
+ if (ms < 1000) return `${ms}ms`;
+ const seconds = ms / 1000;
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
+ const totalSeconds = Math.floor(seconds);
+ const m = Math.floor(totalSeconds / 60);
+ const s = totalSeconds % 60;
+ return s === 0 ? `${m}m` : `${m}m ${s}s`;
+}
+
+function LastScanStrip({
+ latestRun,
+ fallbackLastRunAt,
+}: {
+ latestRun: ProviderLatestRun | null;
+ fallbackLastRunAt: Date | null;
+}) {
+ const completedRaw = latestRun?.completedAt ?? fallbackLastRunAt;
+ if (!completedRaw) return null;
+
+ const completedAt =
+ completedRaw instanceof Date ? completedRaw : new Date(completedRaw);
+ if (Number.isNaN(completedAt.getTime())) return null;
+
+ const parts: string[] = [`Ran ${formatRelativeTime(completedAt)}`];
+
+ if (latestRun) {
+ if (latestRun.totalChecked !== null) {
+ const noun = latestRun.totalChecked === 1 ? 'check' : 'checks';
+ parts.push(`${latestRun.totalChecked} ${noun} evaluated`);
+ }
+ if (latestRun.passedCount !== null) {
+ parts.push(`${latestRun.passedCount} passed`);
+ }
+ if (latestRun.failedCount !== null) {
+ parts.push(`${latestRun.failedCount} failed`);
+ }
+ if (latestRun.durationMs !== null && latestRun.durationMs > 0) {
+ parts.push(`Duration ${formatDuration(latestRun.durationMs)}`);
+ }
+ }
+
+ return (
+
+ {parts.join(' • ')}
+
+ );
+}
+
function RemediationSetupDialog({
open,
onOpenChange,
@@ -1257,6 +1384,59 @@ function RemediationSetupDialog({
);
}
+/**
+ * Format a resource label for a finding row, e.g. "IAM User: john" or
+ * just "sg-abc123" when type is unknown. Returns null when there's
+ * nothing meaningful to show.
+ */
+function formatResourceLabel(finding: Finding): string | null {
+ const id = finding.resourceId;
+ const type = finding.resourceType;
+ if (!id && !type) return null;
+ if (id && type) return `${type}: ${id}`;
+ return id ?? type ?? null;
+}
+
+function EvidenceSection({ evidence }: { evidence: unknown }) {
+ const [open, setOpen] = useState(false);
+
+ // Don't render the section when evidence is null, undefined, or an empty
+ // container — keeps the expanded state focused.
+ if (evidence === null || evidence === undefined) return null;
+ if (typeof evidence === 'object') {
+ const isEmpty = Array.isArray(evidence)
+ ? evidence.length === 0
+ : Object.keys(evidence as Record).length === 0;
+ if (isEmpty) return null;
+ }
+
+ return (
+
+
+ {open && (
+
+
+
+ )}
+
+ );
+}
+
function FindingRow({
finding,
expanded,
@@ -1266,6 +1446,8 @@ function FindingRow({
capabilitiesLoaded,
onFix,
onSetup,
+ onMarkException,
+ canMarkException,
}: {
finding: Finding;
expanded: boolean;
@@ -1275,9 +1457,12 @@ function FindingRow({
capabilitiesLoaded: boolean;
onFix: (key: string) => void;
onSetup: () => void;
+ onMarkException?: () => void;
+ canMarkException: boolean;
}) {
const severity = finding.severity?.toLowerCase() ?? 'info';
const styles = SEVERITY_STYLES[severity] ?? SEVERITY_STYLES.info;
+ const resourceLabel = formatResourceLabel(finding);
const handleFixClick = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -1339,7 +1524,14 @@ function FindingRow({
{expanded ? : }
- {finding.title ?? 'Untitled finding'}
+
+
{finding.title ?? 'Untitled finding'}
+ {resourceLabel && (
+
+ {resourceLabel}
+
+ )}
+
{finding.projectDisplayName && (
{finding.projectDisplayName}
@@ -1348,6 +1540,21 @@ function FindingRow({
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
{renderFixButton()}
+ {canMarkException &&
+ onMarkException &&
+ (finding.status === 'failed' || finding.status === 'FAILED') && (
+
+ )}
{expanded && (
+
{finding.description && (
-
{finding.description}
+
+
This account's result
+
+ {finding.description}
+
+
)}
+
{finding.remediation && (
Remediation
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.test.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.test.tsx
new file mode 100644
index 0000000000..a074448812
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+// Mock @uiw/react-json-view to avoid pulling its bundle into jsdom tests.
+// We only need to verify our wrapper renders + the wrapping behavior.
+vi.mock('@uiw/react-json-view', () => ({
+ default: ({ value }: { value: unknown }) => (
+
{JSON.stringify(value)}
+ ),
+}));
+
+import { EvidenceJsonViewer } from './EvidenceJsonViewer';
+
+describe('EvidenceJsonViewer', () => {
+ it('renders the empty state when evidence is null', () => {
+ render(
);
+ expect(
+ screen.getByText(/No evidence collected for this finding\./i),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the empty state when evidence is an empty object', () => {
+ render(
);
+ expect(
+ screen.getByText(/No evidence collected for this finding\./i),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the JSON view when evidence has content', () => {
+ render(
+
,
+ );
+ const view = screen.getByTestId('json-view');
+ expect(view.textContent).toContain('logs-archive');
+ expect(view.textContent).toContain('true');
+ });
+
+ it('wraps primitive evidence values under a "value" key', () => {
+ render(
);
+ const view = screen.getByTestId('json-view');
+ expect(view.textContent).toContain('just a string');
+ expect(view.textContent).toContain('value');
+ });
+
+ it('renders a Copy button', () => {
+ render(
);
+ expect(
+ screen.getByRole('button', {
+ name: /Copy evidence JSON to clipboard/i,
+ }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.tsx
new file mode 100644
index 0000000000..29bc9439c9
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import JsonView from '@uiw/react-json-view';
+import { Check, Copy } from 'lucide-react';
+import { useCallback, useMemo, useState } from 'react';
+
+interface EvidenceJsonViewerProps {
+ /** Sanitized evidence payload (server-side sanitizer redacts sensitive keys). */
+ evidence: unknown;
+}
+
+const isEmpty = (value: unknown): boolean => {
+ if (value === null || value === undefined) return true;
+ if (typeof value !== 'object') return false;
+ if (Array.isArray(value)) return value.length === 0;
+ return Object.keys(value as Record
).length === 0;
+};
+
+/**
+ * Read-only JSON viewer for cloud-test evidence. Sensitive keys are already
+ * redacted server-side by evidence-sanitizer.ts before they reach this
+ * component — this is render only.
+ */
+export function EvidenceJsonViewer({ evidence }: EvidenceJsonViewerProps) {
+ const [copied, setCopied] = useState(false);
+
+ const display = useMemo(() => {
+ // `@uiw/react-json-view` expects an object/array at the root. Wrap primitives
+ // so we never pass a plain string/number/null into the tree view.
+ if (evidence === null || evidence === undefined) return null;
+ if (typeof evidence === 'object') return evidence;
+ return { value: evidence };
+ }, [evidence]);
+
+ const jsonString = useMemo(() => {
+ if (display === null) return '';
+ try {
+ return JSON.stringify(display, null, 2);
+ } catch {
+ return '';
+ }
+ }, [display]);
+
+ const handleCopy = useCallback(async () => {
+ if (!jsonString) return;
+ try {
+ await navigator.clipboard.writeText(jsonString);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // Clipboard unavailable — silently no-op rather than throw.
+ }
+ }, [jsonString]);
+
+ if (isEmpty(evidence)) {
+ return (
+
+ No evidence collected for this finding.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/FindingsTable.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/FindingsTable.tsx
index 543e42c3ee..85fae82e3b 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/FindingsTable.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/FindingsTable.tsx
@@ -63,7 +63,7 @@ export function FindingsTable({ findings }: FindingsTableProps) {
if (findings.length === 0) {
return (
-
No findings available
+
No results available
);
}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.test.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.test.tsx
new file mode 100644
index 0000000000..61bee6932e
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.test.tsx
@@ -0,0 +1,214 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+type SwrShape = {
+ data: { data: unknown } | undefined;
+ error: unknown;
+ isLoading: boolean;
+ mutate: () => Promise | unknown;
+};
+
+const swrMock = vi.fn<() => SwrShape>();
+const deleteMock = vi.fn();
+const hasPermissionMock = vi.fn().mockReturnValue(true);
+
+vi.mock('@/hooks/use-api', () => ({
+ useApi: () => ({
+ useSWR: (_url: string) => swrMock(),
+ delete: (url: string) => deleteMock(url),
+ }),
+}));
+
+vi.mock('@/hooks/use-permissions', () => ({
+ usePermissions: () => ({
+ permissions: {},
+ hasPermission: hasPermissionMock,
+ }),
+}));
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock('@trycompai/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ }) => (
+
+ ),
+}));
+
+import { HistoryTab } from './HistoryTab';
+
+const baselinePayload = {
+ summary: {
+ resolutions: 3,
+ platformFixes: 1,
+ externalFixes: 1,
+ resourceDeleted: 1,
+ exceptionMarked: 0,
+ activeExceptions: 1,
+ regressions: 1,
+ },
+ resolutions: [
+ {
+ id: 'fres_1',
+ checkId: 'iam-no-mfa',
+ resourceId: 'john',
+ resourceType: 'AwsIamUser',
+ resolvedAt: '2026-05-12T10:00:00Z',
+ resolutionMethod: 'platform_fix',
+ daysOpen: 4,
+ },
+ {
+ id: 'fres_2',
+ checkId: 's3-public',
+ resourceId: 'old-backups',
+ resourceType: 'S3Bucket',
+ resolvedAt: '2026-05-10T10:00:00Z',
+ resolutionMethod: 'external_fix',
+ daysOpen: 23,
+ },
+ {
+ id: 'fres_3',
+ checkId: 'ec2-open-ssh',
+ resourceId: 'sg-abc',
+ resourceType: 'SecurityGroup',
+ resolvedAt: '2026-05-08T10:00:00Z',
+ resolutionMethod: 'resource_deleted',
+ daysOpen: 5,
+ },
+ ],
+ exceptions: [
+ {
+ id: 'fex_1',
+ checkId: 's3-public',
+ resourceId: 'marketing-assets',
+ reason: 'Public marketing bucket — intentional.',
+ reviewedBy: 'CISO 2026-Q1',
+ expiresAt: '2026-08-13T00:00:00Z',
+ markedAt: '2026-05-13T10:00:00Z',
+ },
+ ],
+ regressions: [
+ {
+ id: 'freg_1',
+ checkId: 'rds-not-encrypted',
+ resourceId: 'prod-db-2',
+ previouslyResolvedAt: '2026-03-10T10:00:00Z',
+ regressedAt: '2026-04-15T10:00:00Z',
+ daysClean: 36,
+ },
+ ],
+};
+
+describe('HistoryTab', () => {
+ beforeEach(() => {
+ swrMock.mockReset();
+ deleteMock.mockReset();
+ hasPermissionMock.mockReturnValue(true);
+ });
+
+ it('renders a loading state while the request is in flight', () => {
+ swrMock.mockReturnValue({
+ data: undefined,
+ error: null,
+ isLoading: true,
+ mutate: () => undefined,
+ });
+ render();
+ expect(screen.getByText(/Loading history/i)).toBeInTheDocument();
+ });
+
+ it('renders an empty state when there are no resolutions, exceptions, or regressions', () => {
+ swrMock.mockReturnValue({
+ data: {
+ data: {
+ data: {
+ summary: {
+ resolutions: 0,
+ platformFixes: 0,
+ externalFixes: 0,
+ resourceDeleted: 0,
+ exceptionMarked: 0,
+ activeExceptions: 0,
+ regressions: 0,
+ },
+ resolutions: [],
+ exceptions: [],
+ regressions: [],
+ },
+ },
+ },
+ error: null,
+ isLoading: false,
+ mutate: () => undefined,
+ });
+ render();
+ expect(screen.getByText(/No audit history yet/i)).toBeInTheDocument();
+ });
+
+ it('renders all three sections with sample data', () => {
+ swrMock.mockReturnValue({
+ data: { data: { data: baselinePayload } },
+ error: null,
+ isLoading: false,
+ mutate: () => undefined,
+ });
+ render();
+ // "Resolutions" and "Active exceptions" appear in both summary + section header —
+ // getAllByText proves both are rendered.
+ expect(screen.getAllByText(/Resolutions/).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/Active exceptions/).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/Regressions/).length).toBeGreaterThan(0);
+ expect(screen.getByText('Fixed via platform')).toBeInTheDocument();
+ expect(screen.getByText('Fixed externally')).toBeInTheDocument();
+ expect(screen.getByText('Resource deleted')).toBeInTheDocument();
+ expect(
+ screen.getByText(/Public marketing bucket/i),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/CISO 2026-Q1/i)).toBeInTheDocument();
+ });
+
+ it('calls DELETE /exceptions/:id and refreshes when "Remove exception" is clicked', async () => {
+ const mutate = vi.fn();
+ swrMock.mockReturnValue({
+ data: { data: { data: baselinePayload } },
+ error: null,
+ isLoading: false,
+ mutate,
+ });
+ deleteMock.mockResolvedValueOnce({ error: null });
+
+ render();
+ fireEvent.click(
+ screen.getByRole('button', { name: /Remove exception/i }),
+ );
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(deleteMock).toHaveBeenCalledWith(
+ '/v1/cloud-security/exceptions/fex_1',
+ );
+ expect(mutate).toHaveBeenCalled();
+ });
+
+ it('hides the "Remove exception" button for users without integration:update', () => {
+ hasPermissionMock.mockReturnValue(false);
+ swrMock.mockReturnValue({
+ data: { data: { data: baselinePayload } },
+ error: null,
+ isLoading: false,
+ mutate: () => undefined,
+ });
+ render();
+ expect(
+ screen.queryByRole('button', { name: /Remove exception/i }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.tsx
new file mode 100644
index 0000000000..2cc2846d22
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.tsx
@@ -0,0 +1,308 @@
+'use client';
+
+import { useApi } from '@/hooks/use-api';
+import { usePermissions } from '@/hooks/use-permissions';
+import { Button } from '@trycompai/ui/button';
+import { Loader2, ShieldCheck, RotateCcw, X } from 'lucide-react';
+import { toast } from 'sonner';
+
+interface ResolutionRow {
+ id: string;
+ checkId: string;
+ resourceId: string;
+ resourceType: string | null;
+ resolvedAt: string;
+ resolutionMethod:
+ | 'platform_fix'
+ | 'external_fix'
+ | 'resource_deleted'
+ | 'exception_marked';
+ daysOpen: number | null;
+}
+
+interface ExceptionRow {
+ id: string;
+ checkId: string;
+ resourceId: string;
+ reason: string;
+ reviewedBy: string | null;
+ expiresAt: string | null;
+ markedAt: string;
+}
+
+interface RegressionRow {
+ id: string;
+ checkId: string;
+ resourceId: string;
+ previouslyResolvedAt: string;
+ regressedAt: string;
+ daysClean: number | null;
+}
+
+interface HistoryPayload {
+ summary: {
+ resolutions: number;
+ platformFixes: number;
+ externalFixes: number;
+ resourceDeleted: number;
+ exceptionMarked: number;
+ activeExceptions: number;
+ regressions: number;
+ };
+ resolutions: ResolutionRow[];
+ exceptions: ExceptionRow[];
+ regressions: RegressionRow[];
+}
+
+const RESOLUTION_METHOD_LABEL: Record<
+ ResolutionRow['resolutionMethod'],
+ string
+> = {
+ platform_fix: 'Fixed via platform',
+ external_fix: 'Fixed externally',
+ resource_deleted: 'Resource deleted',
+ exception_marked: 'Marked as exception',
+};
+
+export interface HistoryTabProps {
+ connectionId: string;
+}
+
+/**
+ * Renders the audit trail for a connection — resolutions, active
+ * exceptions, and regressions. Pulls from
+ * GET /v1/cloud-security/history?connectionId=...
+ */
+export function HistoryTab({ connectionId }: HistoryTabProps) {
+ const api = useApi();
+ const { hasPermission } = usePermissions();
+ const canRevoke = hasPermission('integration', 'update');
+
+ const { data, error, isLoading, mutate } = api.useSWR<{
+ data: HistoryPayload;
+ }>(`/v1/cloud-security/history?connectionId=${connectionId}`, {
+ revalidateOnFocus: false,
+ });
+
+ const handleRevoke = async (exceptionId: string) => {
+ const response = await api.delete(
+ `/v1/cloud-security/exceptions/${exceptionId}`,
+ );
+ if (response.error) {
+ toast.error(
+ typeof response.error === 'string'
+ ? response.error
+ : 'Could not revoke exception',
+ );
+ return;
+ }
+ toast.success('Exception revoked');
+ mutate();
+ };
+
+ if (isLoading) {
+ return (
+
+
+ Loading history…
+
+ );
+ }
+ if (error || !data?.data?.data) {
+ return (
+
+ Could not load history for this connection.
+
+ );
+ }
+
+ const payload = data.data.data;
+ const isEmpty =
+ payload.resolutions.length === 0 &&
+ payload.exceptions.length === 0 &&
+ payload.regressions.length === 0;
+
+ if (isEmpty) {
+ return (
+
+
+
No audit history yet
+
+ Resolutions, exceptions, and regressions are recorded automatically
+ on each scan.
+
+
+ );
+ }
+
+ return (
+
+
+ {payload.resolutions.length > 0 && (
+
+ {payload.resolutions.map((row) => (
+
+ ))}
+
+ )}
+ {payload.exceptions.length > 0 && (
+
+ {payload.exceptions.map((row) => (
+ handleRevoke(row.id)}
+ />
+ ))}
+
+ )}
+ {payload.regressions.length > 0 && (
+
+ {payload.regressions.map((row) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function SummaryCard({ summary }: { summary: HistoryPayload['summary'] }) {
+ return (
+
+
+
Resolved
+
+ {summary.resolutions}
+
+
+
+
Active exceptions
+
+ {summary.activeExceptions}
+
+
+
+
Regressions
+
+ {summary.regressions}
+
+
+
+ );
+}
+
+function Section({
+ title,
+ subtitle,
+ count,
+ children,
+}: {
+ title: string;
+ subtitle: string;
+ count: number;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {title} ({count})
+
+
{subtitle}
+
+
{children}
+
+ );
+}
+
+function ResolutionRowView({ row }: { row: ResolutionRow }) {
+ return (
+
+
+
+
+ {row.checkId} · {row.resourceType ? `${row.resourceType}: ` : ''}
+ {row.resourceId}
+
+
+ {RESOLUTION_METHOD_LABEL[row.resolutionMethod]}
+
+
+
+ Resolved {new Date(row.resolvedAt).toLocaleString()}
+ {row.daysOpen !== null ? ` — was open ${row.daysOpen}d` : ''}
+
+
+ );
+}
+
+function ExceptionRowView({
+ row,
+ canRevoke,
+ onRevoke,
+}: {
+ row: ExceptionRow;
+ canRevoke: boolean;
+ onRevoke: () => void;
+}) {
+ return (
+
+
+
+ {row.checkId} · {row.resourceId}
+
+ {canRevoke && (
+
+ )}
+
+
{row.reason}
+
+ Marked {new Date(row.markedAt).toLocaleDateString()}
+ {row.reviewedBy && · Reviewed by: {row.reviewedBy}}
+ {row.expiresAt && (
+ · Auto-review: {new Date(row.expiresAt).toLocaleDateString()}
+ )}
+
+
+ );
+}
+
+function RegressionRowView({ row }: { row: RegressionRow }) {
+ return (
+
+
+
+
+ {row.checkId} · {row.resourceId}
+
+
+
+ Was clean {row.daysClean ?? '?'}d (previously resolved{' '}
+ {new Date(row.previouslyResolvedAt).toLocaleDateString()}). Failing
+ again as of {new Date(row.regressedAt).toLocaleString()}.
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.test.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.test.tsx
new file mode 100644
index 0000000000..7c2419456f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.test.tsx
@@ -0,0 +1,151 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const postMock = vi.fn();
+vi.mock('@/hooks/use-api', () => ({
+ useApi: () => ({ post: postMock }),
+}));
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
+// Stub the dialog wrapper so we don't pull a real portal in jsdom. Render
+// children inline when `open`.
+vi.mock('@trycompai/ui/dialog', () => {
+ const Pass = ({ children }: { children: React.ReactNode }) => <>{children}>;
+ return {
+ Dialog: ({
+ open,
+ children,
+ }: {
+ open: boolean;
+ children: React.ReactNode;
+ }) => (open ? {children}
: null),
+ DialogContent: Pass,
+ DialogDescription: Pass,
+ DialogHeader: Pass,
+ DialogTitle: Pass,
+ };
+});
+
+vi.mock('@trycompai/ui/button', () => ({
+ Button: ({
+ children,
+ disabled,
+ onClick,
+ }: {
+ children: React.ReactNode;
+ disabled?: boolean;
+ onClick?: () => void;
+ }) => (
+
+ ),
+}));
+
+import { MarkExceptionModal } from './MarkExceptionModal';
+
+describe('MarkExceptionModal', () => {
+ beforeEach(() => {
+ postMock.mockReset();
+ });
+
+ it('renders the finding title and resource label', () => {
+ render(
+ {}}
+ findingId="icx_1"
+ findingTitle="IAM password policy < 14 characters"
+ resourceLabel="IAM Account: 123456789012"
+ />,
+ );
+ expect(
+ screen.getByText('IAM password policy < 14 characters'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('IAM Account: 123456789012')).toBeInTheDocument();
+ });
+
+ it('keeps the submit button disabled until reason reaches min length', () => {
+ render(
+ {}}
+ findingId="icx_1"
+ findingTitle="X"
+ />,
+ );
+ const submit = screen.getByRole('button', { name: /^Mark as exception$/ });
+ expect(submit).toBeDisabled();
+
+ fireEvent.change(screen.getByLabelText(/Reason for exception/i), {
+ target: { value: 'too short' },
+ });
+ expect(submit).toBeDisabled();
+
+ fireEvent.change(screen.getByLabelText(/Reason for exception/i), {
+ target: {
+ value: 'This is a long enough documented reason for the exception.',
+ },
+ });
+ expect(submit).not.toBeDisabled();
+ });
+
+ it('submits to the exception endpoint and invokes onMarked on success', async () => {
+ postMock.mockResolvedValueOnce({ error: null, data: { data: { id: 'fex_1' } } });
+ const onMarked = vi.fn();
+ render(
+ {}}
+ findingId="icx_1"
+ findingTitle="X"
+ onMarked={onMarked}
+ />,
+ );
+ fireEvent.change(screen.getByLabelText(/Reason for exception/i), {
+ target: {
+ value: 'This is a long enough documented reason for the exception.',
+ },
+ });
+ fireEvent.click(
+ screen.getByRole('button', { name: /^Mark as exception$/ }),
+ );
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(postMock).toHaveBeenCalledWith(
+ '/v1/cloud-security/findings/icx_1/exception',
+ expect.objectContaining({
+ reason: 'This is a long enough documented reason for the exception.',
+ }),
+ );
+ expect(onMarked).toHaveBeenCalled();
+ });
+
+ it('does not invoke onMarked when the API responds with an error', async () => {
+ postMock.mockResolvedValueOnce({ error: 'Forbidden' });
+ const onMarked = vi.fn();
+ render(
+ {}}
+ findingId="icx_1"
+ findingTitle="X"
+ onMarked={onMarked}
+ />,
+ );
+ fireEvent.change(screen.getByLabelText(/Reason for exception/i), {
+ target: {
+ value: 'This is a long enough documented reason for the exception.',
+ },
+ });
+ fireEvent.click(
+ screen.getByRole('button', { name: /^Mark as exception$/ }),
+ );
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(onMarked).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.tsx
new file mode 100644
index 0000000000..d5896d26c8
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.tsx
@@ -0,0 +1,198 @@
+'use client';
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@trycompai/ui/dialog';
+import { Button } from '@trycompai/ui/button';
+import { useApi } from '@/hooks/use-api';
+import { Loader2 } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+
+const MIN_REASON_LENGTH = 20;
+
+export interface MarkExceptionModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ findingId: string | null;
+ findingTitle: string;
+ resourceLabel?: string | null;
+ onMarked?: () => void;
+}
+
+/**
+ * Modal that captures the reason + optional reviewer + optional expiration
+ * date for marking a finding as an exception. Talks to POST
+ * /v1/cloud-security/findings/:id/exception. Calls onMarked() on success
+ * so the parent can refresh its findings list.
+ */
+export function MarkExceptionModal({
+ open,
+ onOpenChange,
+ findingId,
+ findingTitle,
+ resourceLabel,
+ onMarked,
+}: MarkExceptionModalProps) {
+ const api = useApi();
+ const [reason, setReason] = useState('');
+ const [reviewedBy, setReviewedBy] = useState('');
+ const [expiresAt, setExpiresAt] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ const reasonTooShort = reason.trim().length < MIN_REASON_LENGTH;
+
+ const handleSubmit = async () => {
+ if (!findingId || reasonTooShort) return;
+ setSubmitting(true);
+ const response = await api.post(
+ `/v1/cloud-security/findings/${findingId}/exception`,
+ {
+ reason: reason.trim(),
+ reviewedBy: reviewedBy.trim() || undefined,
+ expiresAt: expiresAt || undefined,
+ },
+ );
+ setSubmitting(false);
+
+ if (response.error) {
+ const message =
+ typeof response.error === 'string'
+ ? response.error
+ : 'Could not mark exception — please try again.';
+ toast.error(message);
+ return;
+ }
+
+ toast.success('Marked as exception');
+ setReason('');
+ setReviewedBy('');
+ setExpiresAt('');
+ onMarked?.();
+ onOpenChange(false);
+ };
+
+ const handleClose = (next: boolean) => {
+ if (!next) {
+ setReason('');
+ setReviewedBy('');
+ setExpiresAt('');
+ }
+ onOpenChange(next);
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx
index 8bde66b771..cb10ab1d93 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx
@@ -9,6 +9,7 @@ import type { Finding, Provider } from '../types';
import { ActivitySection } from '@/app/(app)/[orgId]/integrations/[slug]/components/ActivitySection';
import { RemediationHistorySection } from '@/app/(app)/[orgId]/integrations/[slug]/components/RemediationHistorySection';
import { CloudTestsSection } from './CloudTestsSection';
+import { HistoryTab } from './HistoryTab';
import { ResultsView } from './ResultsView';
import { ServicesGrid } from './ServicesGrid';
@@ -146,7 +147,8 @@ function CloudConnectionContent({
return (
- Findings
+ Scan Results
+ History
Activity
Remediations
@@ -161,6 +163,7 @@ function CloudConnectionContent({
connectionId={connection.id}
orgId={orgId}
lastRunAt={connection.lastRunAt}
+ latestRun={connection.latestRun ?? null}
variables={connection.variables ?? undefined}
awsType={connection.awsType}
onScanComplete={onScanComplete}
@@ -168,6 +171,12 @@ function CloudConnectionContent({
+
+
+
+
+
+
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx
index 06a486e294..60407902a2 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx
@@ -197,12 +197,12 @@ export function ResultsView({
) : findings.length > 0 ? (
-
No findings match the selected filters
+
No results match the selected filters
Try adjusting your filters
) : (
-
No findings yet
+
No results yet
Click "Run Scan" above to check for security issues
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/check-groups.test.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/check-groups.test.ts
new file mode 100644
index 0000000000..c588df6881
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/check-groups.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it } from 'vitest';
+import { buildCheckGroups, deriveCheckTitle } from './check-groups';
+import type { Finding } from '../types';
+
+function makeFinding(overrides: Partial
): Finding {
+ return {
+ id: 'icx_test',
+ title: 'A finding',
+ description: null,
+ remediation: null,
+ status: 'failed',
+ severity: 'medium',
+ serviceId: 'iam',
+ findingKey: null,
+ resourceId: null,
+ resourceType: null,
+ checkId: null,
+ checkKey: null,
+ evidence: null,
+ projectDisplayName: null,
+ completedAt: null,
+ connectionId: 'icn_test',
+ providerSlug: 'aws',
+ integration: { integrationId: 'aws' },
+ ...overrides,
+ };
+}
+
+describe('deriveCheckTitle', () => {
+ it('strips a quoted resourceId from the title', () => {
+ const f = makeFinding({
+ title: 'IAM user "john" does not have MFA enabled',
+ resourceId: 'john',
+ });
+ expect(deriveCheckTitle(f)).toBe('IAM user does not have MFA enabled');
+ });
+
+ it('strips an unquoted resourceId from the title', () => {
+ const f = makeFinding({
+ title: 'CloudTrail trail prod-trail is not logging',
+ resourceId: 'prod-trail',
+ });
+ expect(deriveCheckTitle(f)).toBe('CloudTrail trail is not logging');
+ });
+
+ it('returns the title unchanged for fixed-id checks (resourceId = account-level)', () => {
+ const f = makeFinding({
+ title: 'IAM password policy minimum length is below 14 characters',
+ resourceId: 'account-level',
+ });
+ expect(deriveCheckTitle(f)).toBe(
+ 'IAM password policy minimum length is below 14 characters',
+ );
+ });
+
+ it('returns the title unchanged when resourceId is not present in title', () => {
+ const f = makeFinding({
+ title: 'S3 bucket has public access enabled',
+ resourceId: 'logs-archive',
+ });
+ expect(deriveCheckTitle(f)).toBe('S3 bucket has public access enabled');
+ });
+
+ it('falls back to checkKey or default when title is missing', () => {
+ expect(deriveCheckTitle(makeFinding({ title: null, checkKey: 'iam-no-mfa' }))).toBe(
+ 'iam-no-mfa',
+ );
+ expect(deriveCheckTitle(makeFinding({ title: null, checkKey: null }))).toBe(
+ 'Untitled check',
+ );
+ });
+});
+
+describe('buildCheckGroups', () => {
+ it('returns an empty array for empty input', () => {
+ expect(buildCheckGroups([])).toEqual([]);
+ });
+
+ it('groups findings by checkKey', () => {
+ const findings = [
+ makeFinding({
+ id: 'a',
+ checkKey: 'iam-no-mfa',
+ title: 'IAM user "john" does not have MFA',
+ resourceId: 'john',
+ status: 'failed',
+ severity: 'high',
+ }),
+ makeFinding({
+ id: 'b',
+ checkKey: 'iam-no-mfa',
+ title: 'IAM user "alice" does not have MFA',
+ resourceId: 'alice',
+ status: 'failed',
+ severity: 'high',
+ }),
+ makeFinding({
+ id: 'c',
+ checkKey: 'iam-no-mfa',
+ title: 'IAM user "bob" has MFA',
+ resourceId: 'bob',
+ status: 'passed',
+ severity: 'info',
+ }),
+ ];
+ const groups = buildCheckGroups(findings);
+ expect(groups).toHaveLength(1);
+ expect(groups[0].checkKey).toBe('iam-no-mfa');
+ expect(groups[0].failed).toHaveLength(2);
+ expect(groups[0].passed).toHaveLength(1);
+ expect(groups[0].all).toHaveLength(3);
+ });
+
+ it('produces one group per check kind', () => {
+ const findings = [
+ makeFinding({
+ id: 'a',
+ checkKey: 'iam-no-mfa',
+ status: 'failed',
+ }),
+ makeFinding({
+ id: 'b',
+ checkKey: 'iam-weak-password-length',
+ status: 'failed',
+ }),
+ ];
+ expect(buildCheckGroups(findings)).toHaveLength(2);
+ });
+
+ it('orders groups by highest severity first (failures lead)', () => {
+ const findings = [
+ makeFinding({
+ id: 'a',
+ checkKey: 'check-medium',
+ status: 'failed',
+ severity: 'medium',
+ }),
+ makeFinding({
+ id: 'b',
+ checkKey: 'check-critical',
+ status: 'failed',
+ severity: 'critical',
+ }),
+ makeFinding({
+ id: 'c',
+ checkKey: 'check-allpass',
+ status: 'passed',
+ severity: 'info',
+ }),
+ ];
+ const groups = buildCheckGroups(findings);
+ expect(groups.map((g) => g.checkKey)).toEqual([
+ 'check-critical',
+ 'check-medium',
+ 'check-allpass',
+ ]);
+ });
+
+ it('falls back to title-as-key when checkKey is missing (legacy findings)', () => {
+ const findings = [
+ makeFinding({ id: 'a', checkKey: null, title: 'Legacy finding' }),
+ makeFinding({ id: 'b', checkKey: null, title: 'Legacy finding' }),
+ makeFinding({ id: 'c', checkKey: null, title: 'Different legacy' }),
+ ];
+ const groups = buildCheckGroups(findings);
+ expect(groups).toHaveLength(2);
+ });
+
+ it('marks all-passing groups with severity info', () => {
+ const findings = [
+ makeFinding({
+ id: 'a',
+ checkKey: 'check-all-pass',
+ status: 'passed',
+ severity: 'info',
+ }),
+ ];
+ const groups = buildCheckGroups(findings);
+ expect(groups[0].severity).toBe('info');
+ expect(groups[0].failed).toHaveLength(0);
+ expect(groups[0].passed).toHaveLength(1);
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/check-groups.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/check-groups.ts
new file mode 100644
index 0000000000..bb135f20af
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/check-groups.ts
@@ -0,0 +1,125 @@
+/**
+ * Pure helpers for grouping findings by check within a service. Kept in
+ * its own file (no React imports) so the logic can be unit-tested without
+ * spinning up jsdom.
+ */
+
+import type { Finding } from '../types';
+
+export interface CheckGroup {
+ /** Stable identifier — matches Finding.checkKey, used as React key. */
+ checkKey: string;
+ /** Display title with resource-specific tokens stripped. */
+ checkTitle: string;
+ /** All findings for this check (both passed and failed) in input order. */
+ all: Finding[];
+ /** Failing findings only. */
+ failed: Finding[];
+ /** Passing findings only. */
+ passed: Finding[];
+ /** Severity displayed in the sub-header — highest among failures, info if none. */
+ severity: string;
+}
+
+const SEVERITY_RANK: Record = {
+ critical: 5,
+ high: 4,
+ medium: 3,
+ low: 2,
+ info: 1,
+};
+
+/**
+ * Derive a check-level title from a representative finding by stripping
+ * the resource-specific portion. Used as the sub-group header text.
+ *
+ * Heuristics applied in order:
+ * 1. If `resourceId` is wrapped in quotes in the title (`"john"`), drop the quoted segment.
+ * 2. Otherwise, drop the literal resourceId substring.
+ * 3. If neither matches, fall back to the title unchanged.
+ *
+ * Always collapses any resulting double-spaces.
+ */
+export function deriveCheckTitle(finding: Finding): string {
+ const raw = finding.title?.trim();
+ if (!raw) return finding.checkKey ?? 'Untitled check';
+
+ const resourceId = finding.resourceId;
+ if (!resourceId || resourceId === 'account-level') return raw;
+
+ let cleaned = raw;
+ const quoted = `"${resourceId}"`;
+ if (cleaned.includes(quoted)) {
+ cleaned = cleaned.replace(quoted, '').trim();
+ } else if (cleaned.includes(resourceId)) {
+ cleaned = cleaned.replace(resourceId, '').trim();
+ } else {
+ return raw;
+ }
+
+ return cleaned.replace(/\s{2,}/g, ' ').replace(/\s+([,.])/g, '$1');
+}
+
+/**
+ * Group an array of findings into per-check sub-groups. Each group carries
+ * the failed/passed split and a derived check-level title.
+ *
+ * Order: groups with failures first (sorted by highest severity), then
+ * all-passing groups alphabetically.
+ */
+export function buildCheckGroups(findings: Finding[]): CheckGroup[] {
+ if (findings.length === 0) return [];
+
+ const byKey = new Map();
+ for (const finding of findings) {
+ // Fall back to title-as-key for legacy findings with no checkKey — keeps
+ // them grouped sensibly enough.
+ const key = finding.checkKey ?? finding.title ?? finding.id;
+ const bucket = byKey.get(key) ?? [];
+ bucket.push(finding);
+ byKey.set(key, bucket);
+ }
+
+ const groups: CheckGroup[] = [];
+ for (const [checkKey, bucket] of byKey) {
+ const failed = bucket.filter(
+ (f) => f.status === 'failed' || f.status === 'FAILED',
+ );
+ const passed = bucket.filter(
+ (f) => f.status === 'passed' || f.status === 'success',
+ );
+ const representative = failed[0] ?? bucket[0];
+ const severity =
+ failed.length === 0
+ ? 'info'
+ : failed.reduce((highest, f) => {
+ const sev = (f.severity ?? 'info').toLowerCase();
+ return (SEVERITY_RANK[sev] ?? 0) > (SEVERITY_RANK[highest] ?? 0)
+ ? sev
+ : highest;
+ }, 'info');
+
+ groups.push({
+ checkKey,
+ checkTitle: deriveCheckTitle(representative),
+ all: bucket,
+ failed,
+ passed,
+ severity,
+ });
+ }
+
+ return groups.sort((a, b) => {
+ // Failures first.
+ if (a.failed.length > 0 && b.failed.length === 0) return -1;
+ if (a.failed.length === 0 && b.failed.length > 0) return 1;
+ // Among failures, highest severity first.
+ if (a.failed.length > 0 && b.failed.length > 0) {
+ const sevDiff =
+ (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0);
+ if (sevDiff !== 0) return sevDiff;
+ }
+ // Otherwise alphabetical for stability.
+ return a.checkTitle.localeCompare(b.checkTitle);
+ });
+}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts
index 7429a456c2..ad5ace4821 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts
@@ -8,6 +8,21 @@ export interface Finding {
serviceId: string | null;
findingKey: string | null;
resourceId: string | null;
+ /** Provider-side resource classification, e.g. "AwsIamUser", "S3Bucket". */
+ resourceType: string | null;
+ /** Run-level checkId (e.g. "aws-security-scan") — coarse. */
+ checkId: string | null;
+ /**
+ * Normalized per-check identifier (e.g. "iam-no-mfa") shared across all
+ * resource instances of the same logical check. Used to group findings
+ * into check sub-groups in the UI.
+ */
+ checkKey: string | null;
+ /**
+ * Sanitized provider payload — sensitive keys are redacted server-side
+ * by evidence-sanitizer before this is sent to the client.
+ */
+ evidence: unknown;
projectDisplayName: string | null;
completedAt: Date | null;
connectionId: string;
@@ -17,6 +32,23 @@ export interface Finding {
};
}
+/**
+ * Summary of the most recent scan run for a provider connection. Surfaced
+ * directly on the providers payload so the UI can render run metadata
+ * (count, duration, pass/fail breakdown) without a second fetch.
+ *
+ * Legacy connections only populate `completedAt` — per-run counters are
+ * not stored in the legacy Integration model.
+ */
+export interface ProviderLatestRun {
+ completedAt: Date | null;
+ durationMs: number | null;
+ totalChecked: number | null;
+ passedCount: number | null;
+ failedCount: number | null;
+ status: string;
+}
+
export interface Provider {
id: string;
integrationId: string;
@@ -37,6 +69,7 @@ export interface Provider {
tenantId?: string;
subscriptionId?: string;
supportsMultipleConnections?: boolean;
+ latestRun?: ProviderLatestRun | null;
}
export type FailedIntegration = {
id: string;
diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FindingDetailSheet.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FindingDetailSheet.tsx
index d8a009940d..79c7f1c2ac 100644
--- a/apps/app/src/app/(app)/[orgId]/overview/components/FindingDetailSheet.tsx
+++ b/apps/app/src/app/(app)/[orgId]/overview/components/FindingDetailSheet.tsx
@@ -12,6 +12,7 @@ import {
function capitalize(s: string) {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
+import { Comments } from '@/components/comments/Comments';
import { usePermissions } from '@/hooks/use-permissions';
import { useSession } from '@/utils/auth-client';
import { FindingSeverity, FindingStatus } from '@db';
@@ -409,6 +410,15 @@ export function FindingDetailSheet({
+
+
+ Comments
+
+ {finding ? (
+
+ ) : null}
+
+
Activity
diff --git a/packages/db/prisma/migrations/20260513213400_cloud_tests_check_definition/migration.sql b/packages/db/prisma/migrations/20260513213400_cloud_tests_check_definition/migration.sql
new file mode 100644
index 0000000000..27773690ef
--- /dev/null
+++ b/packages/db/prisma/migrations/20260513213400_cloud_tests_check_definition/migration.sql
@@ -0,0 +1,28 @@
+-- CreateTable
+CREATE TABLE "CheckDefinition" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('chd'::text),
+ "organizationId" TEXT NOT NULL,
+ "checkId" TEXT NOT NULL,
+ "sourceHash" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "passCriteria" TEXT NOT NULL,
+ "failCriteria" TEXT NOT NULL,
+ "whyItMatters" TEXT NOT NULL,
+ "modelVersion" TEXT NOT NULL,
+ "generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "CheckDefinition_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "CheckDefinition_organizationId_idx" ON "CheckDefinition"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "CheckDefinition_checkId_idx" ON "CheckDefinition"("checkId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CheckDefinition_organizationId_checkId_key" ON "CheckDefinition"("organizationId", "checkId");
+
+-- AddForeignKey
+ALTER TABLE "CheckDefinition" ADD CONSTRAINT "CheckDefinition_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/20260513220233_comment_entity_type_finding/migration.sql b/packages/db/prisma/migrations/20260513220233_comment_entity_type_finding/migration.sql
new file mode 100644
index 0000000000..cb81b52507
--- /dev/null
+++ b/packages/db/prisma/migrations/20260513220233_comment_entity_type_finding/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "CommentEntityType" ADD VALUE 'finding';
diff --git a/packages/db/prisma/migrations/20260513221148_cloud_tests_phase5_history/migration.sql b/packages/db/prisma/migrations/20260513221148_cloud_tests_phase5_history/migration.sql
new file mode 100644
index 0000000000..d21b8ac3b9
--- /dev/null
+++ b/packages/db/prisma/migrations/20260513221148_cloud_tests_phase5_history/migration.sql
@@ -0,0 +1,116 @@
+-- CreateEnum
+CREATE TYPE "FindingResolutionMethod" AS ENUM ('platform_fix', 'external_fix', 'resource_deleted', 'exception_marked');
+
+-- AlterTable
+ALTER TABLE "IntegrationCheckRun" ADD COLUMN "failedServices" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ADD COLUMN "scannedServices" TEXT[] DEFAULT ARRAY[]::TEXT[];
+
+-- CreateTable
+CREATE TABLE "FindingException" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fex'::text),
+ "organizationId" TEXT NOT NULL,
+ "connectionId" TEXT NOT NULL,
+ "checkId" TEXT NOT NULL,
+ "resourceId" TEXT NOT NULL,
+ "reason" TEXT NOT NULL,
+ "reviewedBy" TEXT,
+ "expiresAt" TIMESTAMP(3),
+ "markedById" TEXT NOT NULL,
+ "markedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "revokedAt" TIMESTAMP(3),
+ "revokedById" TEXT,
+
+ CONSTRAINT "FindingException_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "FindingResolution" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fres'::text),
+ "organizationId" TEXT NOT NULL,
+ "connectionId" TEXT NOT NULL,
+ "checkId" TEXT NOT NULL,
+ "resourceId" TEXT NOT NULL,
+ "resourceType" TEXT,
+ "resolvedAt" TIMESTAMP(3) NOT NULL,
+ "resolutionMethod" "FindingResolutionMethod" NOT NULL,
+ "resolvedById" TEXT,
+ "remediationActionId" TEXT,
+ "resolvedFromRunId" TEXT NOT NULL,
+ "detectedInRunId" TEXT NOT NULL,
+ "daysOpen" INTEGER,
+
+ CONSTRAINT "FindingResolution_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "FindingRegression" (
+ "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('freg'::text),
+ "organizationId" TEXT NOT NULL,
+ "connectionId" TEXT NOT NULL,
+ "checkId" TEXT NOT NULL,
+ "resourceId" TEXT NOT NULL,
+ "previouslyResolvedAt" TIMESTAMP(3) NOT NULL,
+ "previousResolutionId" TEXT,
+ "regressedAt" TIMESTAMP(3) NOT NULL,
+ "detectedInRunId" TEXT NOT NULL,
+ "daysClean" INTEGER,
+
+ CONSTRAINT "FindingRegression_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "FindingException_organizationId_idx" ON "FindingException"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "FindingException_connectionId_idx" ON "FindingException"("connectionId");
+
+-- CreateIndex
+CREATE INDEX "FindingException_checkId_resourceId_idx" ON "FindingException"("checkId", "resourceId");
+
+-- CreateIndex
+CREATE INDEX "FindingException_expiresAt_idx" ON "FindingException"("expiresAt");
+
+-- CreateIndex
+CREATE INDEX "FindingResolution_organizationId_idx" ON "FindingResolution"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "FindingResolution_connectionId_idx" ON "FindingResolution"("connectionId");
+
+-- CreateIndex
+CREATE INDEX "FindingResolution_resolvedAt_idx" ON "FindingResolution"("resolvedAt");
+
+-- CreateIndex
+CREATE INDEX "FindingResolution_resolutionMethod_idx" ON "FindingResolution"("resolutionMethod");
+
+-- CreateIndex
+CREATE INDEX "FindingResolution_checkId_resourceId_idx" ON "FindingResolution"("checkId", "resourceId");
+
+-- CreateIndex
+CREATE INDEX "FindingRegression_organizationId_idx" ON "FindingRegression"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "FindingRegression_connectionId_idx" ON "FindingRegression"("connectionId");
+
+-- CreateIndex
+CREATE INDEX "FindingRegression_regressedAt_idx" ON "FindingRegression"("regressedAt");
+
+-- CreateIndex
+CREATE INDEX "FindingRegression_checkId_resourceId_idx" ON "FindingRegression"("checkId", "resourceId");
+
+-- AddForeignKey
+ALTER TABLE "FindingException" ADD CONSTRAINT "FindingException_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FindingException" ADD CONSTRAINT "FindingException_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "IntegrationConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FindingResolution" ADD CONSTRAINT "FindingResolution_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FindingResolution" ADD CONSTRAINT "FindingResolution_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "IntegrationConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FindingRegression" ADD CONSTRAINT "FindingRegression_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FindingRegression" ADD CONSTRAINT "FindingRegression_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "IntegrationConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema/check-definition.prisma b/packages/db/prisma/schema/check-definition.prisma
new file mode 100644
index 0000000000..541c58c0ae
--- /dev/null
+++ b/packages/db/prisma/schema/check-definition.prisma
@@ -0,0 +1,48 @@
+/// Cached "About this check" content shown to auditors when they expand a
+/// finding in the cloud-tests UI.
+///
+/// Generation strategy:
+/// - AWS: lazily generated by Claude Haiku 4.5 from the relevant adapter
+/// code snippet on first user view. Cached forever per (orgId, checkId)
+/// until `sourceHash` no longer matches the current adapter code, at
+/// which point the next view regenerates.
+/// - GCP / Azure: not stored here. The API endpoint derives a definition
+/// from the provider's evidence payload (Google SCC / Microsoft Defender
+/// already supply descriptions) without an AI call.
+///
+/// Safety: content NEVER includes compliance control numbers, external
+/// URLs, or framework claims. The Haiku prompt explicitly forbids them and
+/// the API service strips them server-side. See ai-description.service.ts.
+model CheckDefinition {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('chd'::text)"))
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+ /// Normalized per-check identifier — e.g. "iam-weak-password-length".
+ /// Stripped of any resource-specific suffix before lookup.
+ checkId String
+
+ /// SHA-256 of the adapter source snippet that produced this finding.
+ /// If the current snippet's hash differs, the cache entry is stale and
+ /// the next read regenerates content.
+ sourceHash String
+
+ /// Plain-English summary used as the panel title.
+ title String
+ /// What the check actually verifies (1-3 sentences).
+ description String
+ /// Conditions under which the check passes.
+ passCriteria String
+ /// Conditions under which the check fails.
+ failCriteria String
+ /// Why this check matters from a security / risk perspective.
+ whyItMatters String
+
+ /// e.g. "claude-haiku-4-5" — flip the constant in code to invalidate all.
+ modelVersion String
+ generatedAt DateTime @default(now())
+
+ @@unique([organizationId, checkId])
+ @@index([organizationId])
+ @@index([checkId])
+}
diff --git a/packages/db/prisma/schema/comment.prisma b/packages/db/prisma/schema/comment.prisma
index e00cb1d5cd..b0c440f69d 100644
--- a/packages/db/prisma/schema/comment.prisma
+++ b/packages/db/prisma/schema/comment.prisma
@@ -24,4 +24,5 @@ enum CommentEntityType {
vendor
risk
policy
+ finding
}
diff --git a/packages/db/prisma/schema/finding-history.prisma b/packages/db/prisma/schema/finding-history.prisma
new file mode 100644
index 0000000000..9d145329ce
--- /dev/null
+++ b/packages/db/prisma/schema/finding-history.prisma
@@ -0,0 +1,121 @@
+// Cloud-tests audit trail models — record what was fixed, what is
+// intentionally accepted (exceptions), and what regressed. Surfaces in the
+// History tab so auditors can see the journey, not just current state.
+
+/// A customer-declared exception saying "this finding does not apply / is
+/// intentional / risk accepted, with this reason". Removes the finding
+/// from the active list until revoked or expired.
+///
+/// Required reason captures the documentation an auditor needs. Optional
+/// `reviewedBy` (free text — name or process) and `expiresAt` (auto-review
+/// date) give the customer a way to keep exceptions honest.
+model FindingException {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('fex'::text)"))
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+ connectionId String
+ connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+
+ /// Normalized check identifier (e.g. "iam-no-mfa") — matches CloudFinding.checkKey.
+ checkId String
+ /// Per-resource (e.g. user name, bucket name) — same source as CloudFinding.resourceId.
+ resourceId String
+
+ /// Required, min ~20 chars enforced at the service layer.
+ reason String
+ /// Optional free-text — e.g. "approved by CISO 2026-Q1".
+ reviewedBy String?
+ /// Optional. null = never expires. If set, getFindings re-introduces the
+ /// finding to the active list on the first scan after this date.
+ expiresAt DateTime?
+
+ markedById String
+ markedAt DateTime @default(now())
+
+ /// Set when the exception is revoked manually (separate from natural expiry).
+ revokedAt DateTime?
+ revokedById String?
+
+ @@index([organizationId])
+ @@index([connectionId])
+ @@index([checkId, resourceId])
+ @@index([expiresAt])
+}
+
+/// One row per "finding stopped failing" event — written by reconciliation
+/// after each scan. The `resolutionMethod` distinguishes a platform fix
+/// (we did it via the AI Fix button) from external (customer fixed in the
+/// provider console), resource deletion, or an exception being marked.
+model FindingResolution {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('fres'::text)"))
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+ connectionId String
+ connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+
+ /// Identity of the resolved finding (matches CloudFinding.checkKey + resourceId).
+ checkId String
+ resourceId String
+ resourceType String?
+
+ resolvedAt DateTime
+ resolutionMethod FindingResolutionMethod
+ /// Only set for `platform_fix`. Identifies the user who clicked Fix.
+ resolvedById String?
+ /// Only set for `platform_fix`. Links to the successful RemediationAction.
+ remediationActionId String?
+
+ /// Run-id pair lets the UI link "the prior scan that was failing" to
+ /// "the current scan that's clean", reproducible from the audit log.
+ resolvedFromRunId String
+ detectedInRunId String
+ /// How long the finding was open before being resolved.
+ daysOpen Int?
+
+ @@index([organizationId])
+ @@index([connectionId])
+ @@index([resolvedAt])
+ @@index([resolutionMethod])
+ @@index([checkId, resourceId])
+}
+
+enum FindingResolutionMethod {
+ /// Resolved by our Fix button — RemediationAction shows what was applied.
+ platform_fix
+ /// Resolved outside the platform (customer fixed in the AWS console, etc.).
+ external_fix
+ /// Resource no longer exists in the provider — finding is moot.
+ resource_deleted
+ /// User marked this finding as an exception. Filtered, not actually fixed.
+ exception_marked
+}
+
+/// A finding that was previously resolved but is now failing again. The
+/// History tab uses this to surface "fix didn't stick" patterns to auditors.
+model FindingRegression {
+ id String @id @default(dbgenerated("generate_prefixed_cuid('freg'::text)"))
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+ connectionId String
+ connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+
+ checkId String
+ resourceId String
+
+ previouslyResolvedAt DateTime
+ /// Optional FK to the FindingResolution that introduced the prior fix.
+ previousResolutionId String?
+
+ regressedAt DateTime
+ detectedInRunId String
+ /// Days between the last resolution and this regression.
+ daysClean Int?
+
+ @@index([organizationId])
+ @@index([connectionId])
+ @@index([regressedAt])
+ @@index([checkId, resourceId])
+}
diff --git a/packages/db/prisma/schema/integration-platform.prisma b/packages/db/prisma/schema/integration-platform.prisma
index 98cd949e9e..bbef9ee94d 100644
--- a/packages/db/prisma/schema/integration-platform.prisma
+++ b/packages/db/prisma/schema/integration-platform.prisma
@@ -75,6 +75,9 @@ model IntegrationConnection {
syncLogs IntegrationSyncLog[]
remediationActions RemediationAction[]
remediationBatches RemediationBatch[]
+ findingExceptions FindingException[]
+ findingResolutions FindingResolution[]
+ findingRegressions FindingRegression[]
@@index([organizationId])
@@index([providerId])
@@ -333,6 +336,16 @@ model IntegrationCheckRun {
/// Full execution logs (JSON array)
logs Json?
+ /// Service IDs (e.g. "s3", "iam") successfully scanned in this run.
+ /// Used by reconciliation to distinguish a passing finding from a
+ /// service that simply wasn't re-scanned (e.g. rate limit, transient
+ /// API failure) — prevents false "resolved" events on partial scans.
+ scannedServices String[] @default([])
+
+ /// Service IDs that errored out mid-scan. Findings under these services
+ /// are NOT considered for resolution detection on this run.
+ failedServices String[] @default([])
+
createdAt DateTime @default(now())
/// Results from this check run
diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma
index 04e9bc24cd..eb7e416eca 100644
--- a/packages/db/prisma/schema/organization.prisma
+++ b/packages/db/prisma/schema/organization.prisma
@@ -61,6 +61,10 @@ model Organization {
integrationConnections IntegrationConnection[]
integrationOAuthApps IntegrationOAuthApp[]
integrationSyncLogs IntegrationSyncLog[]
+ checkDefinitions CheckDefinition[]
+ findingExceptions FindingException[]
+ findingResolutions FindingResolution[]
+ findingRegressions FindingRegression[]
// Pentest credits — wallet of run-credits an org can spend.
// Source of credits (trial / future Stripe subscription / top-up)
From c05da233efe41a10baaeeb556a60c4ca51c927b9 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Wed, 13 May 2026 18:52:16 -0400
Subject: [PATCH 02/15] fix(cloud-tests): address cubic review findings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Real bugs flagged by cubic on PR #2838:
- comments-permission.guard.ts: API key / service-token requests were
granted without any scope check (authorization bypass). Now mirrors the
standard PermissionGuard's API-key + service-token enforcement, but
with the dynamic `${entityType}:${action}` scope so finding-permission
scopes work end-to-end.
- evidence-sanitizer.ts: snake_case credential keys (`access_key_id`,
`secret_access_key`) and arrays under sensitive keys (e.g. `tokens: [...]`)
bypassed redaction. Now normalizes case-separators before matching and
redacts string/object elements of arrays under sensitive keys. Added
plural variants (tokens / secrets / cookies / accessKeys / signingKeys
/ sessionTokens / publicKeys / privateKeys / apiKeys / passwords).
- exception.service.ts: concurrent markAsException could create duplicate
active exceptions. New `@@unique([orgId, connectionId, checkId, resourceId])`
constraint + Prisma upsert guarantees a single row per finding atomically.
Re-marking after revoke clears `revokedAt` on the same row; full history
still lives in AuditLog.
- reconciliation.service.ts: idempotency probe only checked FindingResolution
— a run that produced only regressions could be re-reconciled and write
duplicate regression rows. Now checks both tables.
- ai-description.prompt.ts: forbidden-content backstop missed bare control
citations like "CIS 1.8" / "PCI 8.2.3" / "NIST AC-2" / "HIPAA 164.312".
Added a broader regex catching `(FRAMEWORK) [LETTERS]?[- ]?N(.N)*`.
Smaller cleanups also flagged:
- MarkExceptionModal / cloud-security.controller: date-only exception
expiry was parsed as UTC midnight, expiring exceptions ~8h before the
user-selected local date. Frontend now computes `min` from local date,
backend now expands bare `YYYY-MM-DD` to end-of-day UTC.
- EvidenceJsonViewer copy button: added focus-visible styles so keyboard
users can see the focused control.
- exception.service.spec.ts: revocation test now asserts revokedAt is a
Date in the expected window (caught the missing assertion).
Skipped from cubic's list: cloud-security-query.legacy.ts filter — that
code is pre-existing and was not introduced by this PR.
Test counts after fixes: 105 (was 96). 4 Prisma migrations now.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../ai-description.prompt.spec.ts | 28 ++++++++
.../cloud-security/ai-description.prompt.ts | 11 ++-
.../cloud-security.controller.ts | 26 ++++++-
.../cloud-security/evidence-sanitizer.spec.ts | 51 ++++++++++++++
.../src/cloud-security/evidence-sanitizer.ts | 46 +++++++++++--
.../cloud-security/exception.service.spec.ts | 58 +++++++++-------
.../src/cloud-security/exception.service.ts | 59 ++++++++--------
.../reconciliation.service.spec.ts | 21 ++++++
.../cloud-security/reconciliation.service.ts | 20 ++++--
.../comments-permission.guard.spec.ts | 67 ++++++++++++++++++-
.../src/comments/comments-permission.guard.ts | 53 ++++++++++++---
.../components/EvidenceJsonViewer.tsx | 2 +-
.../components/MarkExceptionModal.tsx | 21 +++++-
.../migration.sql | 3 +
.../db/prisma/schema/finding-history.prisma | 7 +-
15 files changed, 390 insertions(+), 83 deletions(-)
create mode 100644 packages/db/prisma/migrations/20260513225000_finding_exception_unique/migration.sql
diff --git a/apps/api/src/cloud-security/ai-description.prompt.spec.ts b/apps/api/src/cloud-security/ai-description.prompt.spec.ts
index 28bfd3fe72..c5872ec485 100644
--- a/apps/api/src/cloud-security/ai-description.prompt.spec.ts
+++ b/apps/api/src/cloud-security/ai-description.prompt.spec.ts
@@ -107,8 +107,36 @@ describe('ai-description.prompt', () => {
}),
).toMatchObject({ field: 'passCriteria' });
});
+
+ it('flags bare CIS/PCI/NIST/HIPAA control numbers (e.g. "CIS 1.8")', () => {
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ whyItMatters: 'Aligns with CIS 1.8 best practices.',
+ }),
+ ).toMatchObject({ field: 'whyItMatters' });
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ whyItMatters: 'Required by PCI 8.2.3.',
+ }),
+ ).toMatchObject({ field: 'whyItMatters' });
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ whyItMatters: 'See also: NIST AC-2.',
+ }),
+ ).toMatchObject({ field: 'whyItMatters' });
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ whyItMatters: 'Maps to HIPAA 164.312.',
+ }),
+ ).toMatchObject({ field: 'whyItMatters' });
+ });
});
+
describe('buildCheckDescriptionPrompt', () => {
it('includes provider, severity, title and description', () => {
const prompt = buildCheckDescriptionPrompt({
diff --git a/apps/api/src/cloud-security/ai-description.prompt.ts b/apps/api/src/cloud-security/ai-description.prompt.ts
index f487ab37fe..d07d424081 100644
--- a/apps/api/src/cloud-security/ai-description.prompt.ts
+++ b/apps/api/src/cloud-security/ai-description.prompt.ts
@@ -92,7 +92,7 @@ export function buildCheckDescriptionPrompt(input: CheckDescriptionInput): strin
* patterns that should NEVER match in production output.
*/
const FORBIDDEN_PATTERNS: readonly RegExp[] = [
- // Compliance control numbers and framework citations
+ // Compliance framework names (full)
/\bSOC ?2\b/i,
/\bISO ?27001\b/i,
/\bISO ?27002\b/i,
@@ -100,8 +100,13 @@ const FORBIDDEN_PATTERNS: readonly RegExp[] = [
/\bNIST\b/i,
/\bPCI ?DSS\b/i,
/\bCIS ?Benchmark\b/i,
- /\bCC\d+\.\d+\b/i, // SOC 2 control numbers like CC6.1
- /\bA\.\d+\.\d+(\.\d+)?\b/, // ISO control numbers like A.9.4.3
+ // Bare control-number citations following any of the known framework
+ // prefixes — catches "CIS 1.8", "PCI 8.2.3", "NIST AC-2",
+ // "HIPAA 164.312" even without the full framework name re-mentioned.
+ /\b(CIS|PCI|NIST|HIPAA|HITRUST|FedRAMP) ?[A-Z]*[- ]?\d+(\.\d+){0,3}\b/i,
+ // SOC 2 / ISO control-number formats
+ /\bCC\d+\.\d+\b/i,
+ /\bA\.\d+\.\d+(\.\d+)?\b/,
// URLs
/https?:\/\//i,
/www\./i,
diff --git a/apps/api/src/cloud-security/cloud-security.controller.ts b/apps/api/src/cloud-security/cloud-security.controller.ts
index 8189f11929..183b237bd3 100644
--- a/apps/api/src/cloud-security/cloud-security.controller.ts
+++ b/apps/api/src/cloud-security/cloud-security.controller.ts
@@ -128,7 +128,7 @@ export class CloudSecurityController {
userId: req.userId,
reason: body.reason,
reviewedBy: body.reviewedBy ?? null,
- expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
+ expiresAt: parseExceptionExpiry(body.expiresAt),
});
return { data: result };
}
@@ -969,3 +969,27 @@ export class CloudSecurityController {
return { success: true };
}
}
+
+/**
+ * Convert a user-supplied exception expiry into a Date, treating bare
+ * date strings ("YYYY-MM-DD") as end-of-day UTC so the exception persists
+ * through the user's chosen calendar day in any reasonable time zone.
+ *
+ * Without this, `new Date("2026-08-13")` is parsed as `2026-08-13T00:00:00Z`,
+ * which is the START of Aug 13 UTC — already in the past by late evening
+ * Aug 12 in US Pacific, expiring the exception ~8h before the user expected.
+ */
+function parseExceptionExpiry(input: string | undefined): Date | null {
+ if (!input) return null;
+ // Bare YYYY-MM-DD → end of that day in UTC (close enough to "end of day
+ // anywhere on Earth" without per-user-TZ tracking).
+ const bareDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input);
+ if (bareDate) {
+ const [, y, m, d] = bareDate;
+ return new Date(Date.UTC(+y, +m - 1, +d, 23, 59, 59, 999));
+ }
+ // Full ISO timestamp — trust it as-is.
+ const parsed = new Date(input);
+ if (Number.isNaN(parsed.getTime())) return null;
+ return parsed;
+}
diff --git a/apps/api/src/cloud-security/evidence-sanitizer.spec.ts b/apps/api/src/cloud-security/evidence-sanitizer.spec.ts
index 3c8ba64fd9..0e0ab502fd 100644
--- a/apps/api/src/cloud-security/evidence-sanitizer.spec.ts
+++ b/apps/api/src/cloud-security/evidence-sanitizer.spec.ts
@@ -18,6 +18,32 @@ describe('evidence-sanitizer', () => {
});
});
+ it('redacts snake_case credential keys (e.g. AWS-style access_key_id)', () => {
+ expect(
+ sanitizeEvidence({
+ access_key_id: 'AKIAIOSFODNN7EXAMPLE',
+ secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
+ session_token: 'IQoJb3JpZ2luX2VjEHMa...',
+ }),
+ ).toEqual({
+ access_key_id: REDACTED_VALUE,
+ secret_access_key: REDACTED_VALUE,
+ session_token: REDACTED_VALUE,
+ });
+ });
+
+ it('redacts kebab-case and dotted credential keys (e.g. x-api-key)', () => {
+ expect(
+ sanitizeEvidence({
+ 'x-api-key': 'sk-abc',
+ 'auth.token': 'bearer-xyz',
+ }),
+ ).toEqual({
+ 'x-api-key': REDACTED_VALUE,
+ 'auth.token': REDACTED_VALUE,
+ });
+ });
+
it('redacts every configured sensitive suffix pattern', () => {
const input: Record = {
password: 'a',
@@ -155,6 +181,31 @@ describe('evidence-sanitizer', () => {
});
});
+ it('redacts string elements of an array under a sensitive key', () => {
+ expect(
+ sanitizeEvidence({
+ tokens: ['t1', 't2', 't3'],
+ numericMix: 'should stay visible',
+ }),
+ ).toEqual({
+ tokens: [REDACTED_VALUE, REDACTED_VALUE, REDACTED_VALUE],
+ numericMix: 'should stay visible',
+ });
+ });
+
+ it('redacts object elements of an array under a sensitive key', () => {
+ expect(
+ sanitizeEvidence({
+ credentials: [
+ { user: 'john', secret: 'x' },
+ { user: 'alice', secret: 'y' },
+ ],
+ }),
+ ).toEqual({
+ credentials: [REDACTED_VALUE, REDACTED_VALUE],
+ });
+ });
+
it('handles a top-level array', () => {
expect(sanitizeEvidence([{ token: 'a' }, { name: 'b' }])).toEqual([
{ token: REDACTED_VALUE },
diff --git a/apps/api/src/cloud-security/evidence-sanitizer.ts b/apps/api/src/cloud-security/evidence-sanitizer.ts
index 0e119746df..3a88acb658 100644
--- a/apps/api/src/cloud-security/evidence-sanitizer.ts
+++ b/apps/api/src/cloud-security/evidence-sanitizer.ts
@@ -21,23 +21,35 @@
export const REDACTED_VALUE = '[REDACTED]';
const SENSITIVE_SUFFIXES: readonly string[] = [
+ // singular + plural pairs — plurals can appear in arrays
+ // (e.g. `tokens: [...]`, `secrets: [...]`)
'password',
+ 'passwords',
'secret',
+ 'secrets',
'token',
+ 'tokens',
'credential',
'credentials',
'privatekey',
+ 'privatekeys',
'publickey',
+ 'publickeys',
'accesskey',
+ 'accesskeys',
'accesskeyid',
'secretaccesskey',
'apikey',
+ 'apikeys',
'signingkey',
+ 'signingkeys',
'sessionid',
'sessiontoken',
+ 'sessiontokens',
'bearer',
'authorization',
'cookie',
+ 'cookies',
];
function isRecord(value: unknown): value is Record {
@@ -45,10 +57,26 @@ function isRecord(value: unknown): value is Record {
}
function keyIsSensitive(key: string): boolean {
- const normalized = key.toLowerCase();
+ // Strip underscores, hyphens, dots, and whitespace so suffix matching
+ // catches snake_case (`access_key_id`), kebab-case (`access-key-id`),
+ // and human-formatted (`access key id`) variants alongside camelCase.
+ const normalized = key.toLowerCase().replace(/[\s._-]/g, '');
return SENSITIVE_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
}
+/**
+ * Replace every string element of an array with REDACTED. Arrays under
+ * sensitive keys (e.g. `tokens: ["t1","t2"]`, `accessKeys: [{...}]`) need
+ * their values scrubbed but their length preserved.
+ */
+function redactArray(value: unknown[]): unknown[] {
+ return value.map((item) => {
+ if (typeof item === 'string') return REDACTED_VALUE;
+ if (isRecord(item)) return REDACTED_VALUE;
+ return item;
+ });
+}
+
/**
* Recursively walks a JSON-like value and replaces values under sensitive
* keys with REDACTED_VALUE. Structure (keys, arrays, primitive types) is
@@ -64,9 +92,19 @@ export function sanitizeEvidence(value: unknown): unknown {
const result: Record = {};
for (const key of Object.keys(value)) {
const child = value[key];
- const shouldRedact =
- keyIsSensitive(key) && (typeof child === 'string' || isRecord(child));
- result[key] = shouldRedact ? REDACTED_VALUE : sanitizeEvidence(child);
+ if (keyIsSensitive(key)) {
+ if (typeof child === 'string' || isRecord(child)) {
+ result[key] = REDACTED_VALUE;
+ } else if (Array.isArray(child)) {
+ result[key] = redactArray(child);
+ } else {
+ // Booleans / numbers under a sensitive key stay visible — they're
+ // typically config flags (e.g. `requirePassword: true`).
+ result[key] = child;
+ }
+ } else {
+ result[key] = sanitizeEvidence(child);
+ }
}
return result;
}
diff --git a/apps/api/src/cloud-security/exception.service.spec.ts b/apps/api/src/cloud-security/exception.service.spec.ts
index 2a033eba7a..573503fd15 100644
--- a/apps/api/src/cloud-security/exception.service.spec.ts
+++ b/apps/api/src/cloud-security/exception.service.spec.ts
@@ -8,6 +8,7 @@ const dbMock = {
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
+ upsert: jest.fn(),
},
auditLog: { create: jest.fn() },
};
@@ -45,6 +46,7 @@ describe('CloudExceptionService.markAsException', () => {
dbMock.findingException.findFirst.mockReset();
dbMock.findingException.create.mockReset();
dbMock.findingException.update.mockReset();
+ dbMock.findingException.upsert.mockReset();
});
it('rejects reasons shorter than 20 characters', async () => {
@@ -70,14 +72,13 @@ describe('CloudExceptionService.markAsException', () => {
).rejects.toThrow(BadRequestException);
});
- it('creates a new exception when none exists for this finding', async () => {
+ it('upserts atomically — the unique constraint prevents concurrent duplicates', async () => {
withFinding({
findingKey: 'iam-no-mfa-john',
resourceId: 'john',
connectionId: 'icn_aws',
});
- dbMock.findingException.findFirst.mockResolvedValueOnce(null);
- dbMock.findingException.create.mockResolvedValueOnce({ id: 'fex_new' });
+ dbMock.findingException.upsert.mockResolvedValueOnce({ id: 'fex_new' });
const result = await buildService().markAsException({
findingId: 'icx_1',
@@ -87,15 +88,16 @@ describe('CloudExceptionService.markAsException', () => {
});
expect(result.id).toBe('fex_new');
- expect(dbMock.findingException.create).toHaveBeenCalledWith(
+ expect(dbMock.findingException.upsert).toHaveBeenCalledWith(
expect.objectContaining({
- data: expect.objectContaining({
- organizationId: 'org_1',
- connectionId: 'icn_aws',
- checkId: 'iam-no-mfa', // normalized from finding key
- resourceId: 'john',
- markedById: 'usr_1',
- }),
+ where: {
+ organizationId_connectionId_checkId_resourceId: {
+ organizationId: 'org_1',
+ connectionId: 'icn_aws',
+ checkId: 'iam-no-mfa', // normalized from finding key
+ resourceId: 'john',
+ },
+ },
}),
);
expect(auditLogMock).toHaveBeenCalledWith(
@@ -103,25 +105,29 @@ describe('CloudExceptionService.markAsException', () => {
);
});
- it('updates an existing active exception instead of creating a duplicate', async () => {
+ it('upsert update branch clears prior revocation and refreshes markedById/At', async () => {
withFinding({
findingKey: 'iam-no-mfa-alice',
resourceId: 'alice',
connectionId: 'icn_aws',
});
- dbMock.findingException.findFirst.mockResolvedValueOnce({ id: 'fex_old' });
- dbMock.findingException.update.mockResolvedValueOnce({ id: 'fex_old' });
+ dbMock.findingException.upsert.mockResolvedValueOnce({ id: 'fex_old' });
- const result = await buildService().markAsException({
+ await buildService().markAsException({
findingId: 'icx_2',
organizationId: 'org_1',
- userId: 'usr_1',
+ userId: 'usr_2',
reason: 'Updated reason now exceeds the twenty char minimum length.',
});
- expect(result.id).toBe('fex_old');
- expect(dbMock.findingException.update).toHaveBeenCalled();
- expect(dbMock.findingException.create).not.toHaveBeenCalled();
+ const call = dbMock.findingException.upsert.mock.calls[0][0];
+ expect(call.update).toEqual(
+ expect.objectContaining({
+ revokedAt: null,
+ revokedById: null,
+ markedById: 'usr_2',
+ }),
+ );
});
it('rejects findings that lack a stable check/resource identity', async () => {
@@ -186,17 +192,21 @@ describe('CloudExceptionService.revokeException', () => {
});
dbMock.findingException.update.mockResolvedValueOnce({ id: 'fex_1' });
+ const before = Date.now();
await buildService().revokeException({
exceptionId: 'fex_1',
organizationId: 'org_1',
userId: 'usr_1',
});
+ const after = Date.now();
- expect(dbMock.findingException.update).toHaveBeenCalledWith(
- expect.objectContaining({
- data: expect.objectContaining({ revokedById: 'usr_1' }),
- }),
- );
+ expect(dbMock.findingException.update).toHaveBeenCalled();
+ const call = dbMock.findingException.update.mock.calls[0][0];
+ expect(call.data.revokedById).toBe('usr_1');
+ // revokedAt must be a Date set during this call window.
+ expect(call.data.revokedAt).toBeInstanceOf(Date);
+ expect(call.data.revokedAt.getTime()).toBeGreaterThanOrEqual(before);
+ expect(call.data.revokedAt.getTime()).toBeLessThanOrEqual(after);
expect(auditLogMock).toHaveBeenCalledWith(
expect.objectContaining({ action: 'exception_revoked' }),
);
diff --git a/apps/api/src/cloud-security/exception.service.ts b/apps/api/src/cloud-security/exception.service.ts
index 9fd3238e48..a89fb83bc7 100644
--- a/apps/api/src/cloud-security/exception.service.ts
+++ b/apps/api/src/cloud-security/exception.service.ts
@@ -49,45 +49,42 @@ export class CloudExceptionService {
input.organizationId,
);
- // Reuse an active exception if one already exists for this finding —
- // mark-then-edit avoids duplicate rows under the same (org, conn, check,
- // resource) tuple.
- const existing = await db.findingException.findFirst({
+ // Atomic upsert against the (orgId, connectionId, checkId, resourceId)
+ // unique constraint — guarantees a single row per finding even under
+ // concurrent mark-as-exception requests. The unique constraint allows
+ // re-marking after a revoke to clear `revokedAt` on the same row; the
+ // full mark/revoke history still lives in AuditLog.
+ const upserted = await db.findingException.upsert({
where: {
+ organizationId_connectionId_checkId_resourceId: {
+ organizationId: input.organizationId,
+ connectionId: lookup.connectionId,
+ checkId: lookup.checkId,
+ resourceId: lookup.resourceId,
+ },
+ },
+ create: {
organizationId: input.organizationId,
connectionId: lookup.connectionId,
checkId: lookup.checkId,
resourceId: lookup.resourceId,
+ reason: input.reason.trim(),
+ reviewedBy: input.reviewedBy ?? null,
+ expiresAt: input.expiresAt ?? null,
+ markedById: input.userId,
+ },
+ update: {
+ reason: input.reason.trim(),
+ reviewedBy: input.reviewedBy ?? null,
+ expiresAt: input.expiresAt ?? null,
+ // Clear revocation when re-marking — same row, refreshed metadata.
revokedAt: null,
+ revokedById: null,
+ markedById: input.userId,
+ markedAt: new Date(),
},
});
-
- let exceptionId: string;
- if (existing) {
- const updated = await db.findingException.update({
- where: { id: existing.id },
- data: {
- reason: input.reason.trim(),
- reviewedBy: input.reviewedBy ?? null,
- expiresAt: input.expiresAt ?? null,
- },
- });
- exceptionId = updated.id;
- } else {
- const created = await db.findingException.create({
- data: {
- organizationId: input.organizationId,
- connectionId: lookup.connectionId,
- checkId: lookup.checkId,
- resourceId: lookup.resourceId,
- reason: input.reason.trim(),
- reviewedBy: input.reviewedBy ?? null,
- expiresAt: input.expiresAt ?? null,
- markedById: input.userId,
- },
- });
- exceptionId = created.id;
- }
+ const exceptionId = upserted.id;
await logCloudSecurityActivity({
organizationId: input.organizationId,
diff --git a/apps/api/src/cloud-security/reconciliation.service.spec.ts b/apps/api/src/cloud-security/reconciliation.service.spec.ts
index 41d56e9037..a63c6fab28 100644
--- a/apps/api/src/cloud-security/reconciliation.service.spec.ts
+++ b/apps/api/src/cloud-security/reconciliation.service.spec.ts
@@ -13,6 +13,7 @@ const dbMock = {
},
findingRegression: {
create: jest.fn(),
+ findFirst: jest.fn(),
},
remediationAction: {
findFirst: jest.fn(),
@@ -87,6 +88,7 @@ describe('CloudReconciliationService.reconcile', () => {
beforeEach(() => {
jest.clearAllMocks();
dbMock.findingResolution.findFirst.mockResolvedValue(null);
+ dbMock.findingRegression.findFirst.mockResolvedValue(null);
dbMock.findingRegression.create.mockResolvedValue({ id: 'freg_x' });
dbMock.findingResolution.create.mockResolvedValue({ id: 'fres_x' });
dbMock.remediationAction.findFirst.mockResolvedValue(null);
@@ -120,6 +122,25 @@ describe('CloudReconciliationService.reconcile', () => {
expect(dbMock.findingResolution.create).not.toHaveBeenCalled();
});
+ it('is idempotent for runs that produced only regressions (no resolutions)', async () => {
+ // Regression-only runs would otherwise re-execute reconciliation and
+ // duplicate FindingRegression rows.
+ dbMock.integrationCheckRun.findUnique.mockResolvedValueOnce({
+ id: 'icr_current',
+ status: 'success',
+ connection: { organizationId: 'org_1' },
+ results: [],
+ scannedServices: [],
+ });
+ dbMock.findingResolution.findFirst.mockResolvedValueOnce(null);
+ dbMock.findingRegression.findFirst.mockResolvedValueOnce({ id: 'freg_existing' });
+
+ const service = new CloudReconciliationService(makeExceptionsStub());
+ const result = await service.reconcile({ currentRunId: 'icr_current' });
+ expect(result.skipped).toBe(true);
+ expect(dbMock.findingRegression.create).not.toHaveBeenCalled();
+ });
+
it('returns 0/0 on first scan (no prior run)', async () => {
dbMock.integrationCheckRun.findUnique.mockResolvedValueOnce({
id: 'icr_current',
diff --git a/apps/api/src/cloud-security/reconciliation.service.ts b/apps/api/src/cloud-security/reconciliation.service.ts
index e2b1a356d6..bf3d81d7dc 100644
--- a/apps/api/src/cloud-security/reconciliation.service.ts
+++ b/apps/api/src/cloud-security/reconciliation.service.ts
@@ -65,12 +65,20 @@ export class CloudReconciliationService {
}
// Idempotency guard — if reconciliation already wrote rows for this run,
- // skip silently.
- const alreadyDone = await db.findingResolution.findFirst({
- where: { detectedInRunId: currentRun.id },
- select: { id: true },
- });
- if (alreadyDone) {
+ // skip silently. Check BOTH resolutions and regressions: a run that
+ // produced only regressions (no resolutions) would otherwise be
+ // re-reconciled and duplicate the regression rows.
+ const [priorResolution, priorRegression] = await Promise.all([
+ db.findingResolution.findFirst({
+ where: { detectedInRunId: currentRun.id },
+ select: { id: true },
+ }),
+ db.findingRegression.findFirst({
+ where: { detectedInRunId: currentRun.id },
+ select: { id: true },
+ }),
+ ]);
+ if (priorResolution || priorRegression) {
return { resolutions: 0, regressions: 0, skipped: true };
}
diff --git a/apps/api/src/comments/comments-permission.guard.spec.ts b/apps/api/src/comments/comments-permission.guard.spec.ts
index 99284221b5..533b8887c5 100644
--- a/apps/api/src/comments/comments-permission.guard.spec.ts
+++ b/apps/api/src/comments/comments-permission.guard.spec.ts
@@ -21,6 +21,12 @@ jest.mock('../auth/permission.guard', () => ({
PERMISSIONS_KEY: 'permissions',
}));
+const resolveServiceByNameMock = jest.fn();
+jest.mock('../auth/service-token.config', () => ({
+ resolveServiceByName: (...args: unknown[]) =>
+ resolveServiceByNameMock(...args),
+}));
+
jest.mock('../auth/auth.server', () => ({
auth: { api: { hasPermission: jest.fn() } },
}));
@@ -158,7 +164,7 @@ describe('CommentsPermissionGuard', () => {
);
});
- it('returns true without invoking better-auth for API keys (handled by scope check upstream)', async () => {
+ it('allows API keys whose scopes include the resolved permission', async () => {
const guard = new CommentsPermissionGuard(
reflectorWith('task', 'update'),
);
@@ -168,10 +174,69 @@ describe('CommentsPermissionGuard', () => {
headers: {},
isApiKey: true,
});
+ // Inject explicit scope set on the request — entityType=finding requires
+ // `finding:update`, NOT `task:update`.
+ (context.switchToHttp().getRequest() as { apiKeyScopes: string[] }).apiKeyScopes =
+ ['finding:update'];
await expect(guard.canActivate(context)).resolves.toBe(true);
expect(hasPermissionMock).not.toHaveBeenCalled();
});
+ it('rejects API keys whose scopes do not include the resolved permission (no bypass)', async () => {
+ const guard = new CommentsPermissionGuard(
+ reflectorWith('task', 'update'),
+ );
+ const context = makeContext({
+ method: 'POST',
+ body: { entityType: 'finding' },
+ headers: {},
+ isApiKey: true,
+ });
+ (context.switchToHttp().getRequest() as { apiKeyScopes: string[] }).apiKeyScopes =
+ ['task:update']; // wrong scope
+ await expect(guard.canActivate(context)).rejects.toThrow(
+ ForbiddenException,
+ );
+ });
+
+ it('rejects service tokens whose allowlist lacks the resolved permission', async () => {
+ resolveServiceByNameMock.mockReturnValueOnce({
+ permissions: ['task:update'],
+ });
+ const guard = new CommentsPermissionGuard(
+ reflectorWith('task', 'update'),
+ );
+ const context = makeContext({
+ method: 'POST',
+ body: { entityType: 'finding' },
+ headers: {},
+ isServiceToken: true,
+ });
+ (context.switchToHttp().getRequest() as { serviceName: string }).serviceName =
+ 'svc-test';
+ await expect(guard.canActivate(context)).rejects.toThrow(
+ ForbiddenException,
+ );
+ });
+
+ it('allows service tokens whose allowlist includes the resolved permission', async () => {
+ resolveServiceByNameMock.mockReturnValueOnce({
+ permissions: ['finding:update'],
+ });
+ const guard = new CommentsPermissionGuard(
+ reflectorWith('task', 'update'),
+ );
+ const context = makeContext({
+ method: 'POST',
+ body: { entityType: 'finding' },
+ headers: {},
+ isServiceToken: true,
+ });
+ (context.switchToHttp().getRequest() as { serviceName: string }).serviceName =
+ 'svc-test';
+ await expect(guard.canActivate(context)).resolves.toBe(true);
+ });
+
it('returns true without invoking better-auth for platform admins', async () => {
const guard = new CommentsPermissionGuard(
reflectorWith('task', 'update'),
diff --git a/apps/api/src/comments/comments-permission.guard.ts b/apps/api/src/comments/comments-permission.guard.ts
index 3b6d375649..96da4bb6e6 100644
--- a/apps/api/src/comments/comments-permission.guard.ts
+++ b/apps/api/src/comments/comments-permission.guard.ts
@@ -13,6 +13,7 @@ import {
PERMISSIONS_KEY,
type RequiredPermission,
} from '../auth/permission.guard';
+import { resolveServiceByName } from '../auth/service-token.config';
import type { AuthenticatedRequest } from '../auth/types';
/**
@@ -62,23 +63,59 @@ export class CommentsPermissionGuard implements CanActivate {
const request = context.switchToHttp().getRequest();
- // API keys and service tokens use the existing fallback behaviour from
- // the standard PermissionGuard — no entity-type-aware logic needed.
- // Defer to scope checks against the literal metadata.
- if (request.isApiKey || request.isServiceToken || request.isPlatformAdmin) {
- return true;
- }
+ if (request.isPlatformAdmin) return true;
const fallback = required[0];
const action = fallback.actions[0];
const resource = this.resolveEntityResource(request, fallback.resource);
+ const requiredScope = `${resource}:${action}`;
- const permissions: Record = { [resource]: [action] };
+ // API keys: scope must explicitly include `${resource}:${action}`.
+ // Mirrors PermissionGuard's API-key handling so the dynamic-resource
+ // resolution doesn't accidentally bypass scope enforcement.
+ if (request.isApiKey) {
+ const scopes = request.apiKeyScopes;
+ if (!scopes || scopes.length === 0) {
+ // Legacy keys: same deprecation behavior as the standard guard —
+ // allow until April 20 2026, deny after.
+ const deprecationDate = new Date('2026-04-20T00:00:00Z');
+ if (new Date() >= deprecationDate) {
+ this.logger.warn(
+ `[CommentsPermissionGuard] Legacy API key with empty scopes BLOCKED on ${request.method} ${request.url}.`,
+ );
+ throw new ForbiddenException(
+ 'This API key is no longer supported. Please regenerate your API key with explicit scopes.',
+ );
+ }
+ return true;
+ }
+ if (!scopes.includes(requiredScope)) {
+ this.logger.warn(
+ `[CommentsPermissionGuard] API key lacks scope ${requiredScope}`,
+ );
+ throw new ForbiddenException('API key lacks required permission scope');
+ }
+ return true;
+ }
+ // Service tokens: same scope check against the token's allowlist.
+ if (request.isServiceToken) {
+ const service = resolveServiceByName(request.serviceName);
+ if (!service) throw new ForbiddenException('Unknown service');
+ if (!service.permissions.includes(requiredScope)) {
+ this.logger.warn(
+ `[CommentsPermissionGuard] Service "${request.serviceName}" lacks ${requiredScope}`,
+ );
+ throw new ForbiddenException('Service token lacks required permission');
+ }
+ return true;
+ }
+
+ const permissions: Record = { [resource]: [action] };
const allowed = await this.checkPermission(request, permissions);
if (!allowed) {
this.logger.warn(
- `[CommentsPermissionGuard] Denied ${request.method} ${request.url}. Required: ${resource}:${action}`,
+ `[CommentsPermissionGuard] Denied ${request.method} ${request.url}. Required: ${requiredScope}`,
);
throw new ForbiddenException('Access denied');
}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.tsx
index 29bc9439c9..4b17162cf2 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EvidenceJsonViewer.tsx
@@ -65,7 +65,7 @@ export function EvidenceJsonViewer({ evidence }: EvidenceJsonViewerProps) {
- {/* Last scan metadata strip — surfaces what's already in IntegrationCheckRun */}
-
{/* Selected projects indicator (GCP) */}
{providerSlug === 'gcp' &&
@@ -1153,70 +1148,6 @@ function StatCard({
);
}
-function formatRelativeTime(date: Date): string {
- const diff = Date.now() - date.getTime();
- const seconds = Math.floor(diff / 1000);
- if (seconds < 5) return 'just now';
- if (seconds < 60) return `${seconds}s ago`;
- const minutes = Math.floor(seconds / 60);
- if (minutes < 60) return `${minutes}m ago`;
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}h ago`;
- const days = Math.floor(hours / 24);
- if (days === 1) return 'yesterday';
- if (days < 7) return `${days}d ago`;
- return date.toLocaleDateString();
-}
-
-function formatDuration(ms: number): string {
- if (ms < 1000) return `${ms}ms`;
- const seconds = ms / 1000;
- if (seconds < 60) return `${seconds.toFixed(1)}s`;
- const totalSeconds = Math.floor(seconds);
- const m = Math.floor(totalSeconds / 60);
- const s = totalSeconds % 60;
- return s === 0 ? `${m}m` : `${m}m ${s}s`;
-}
-
-function LastScanStrip({
- latestRun,
- fallbackLastRunAt,
-}: {
- latestRun: ProviderLatestRun | null;
- fallbackLastRunAt: Date | null;
-}) {
- const completedRaw = latestRun?.completedAt ?? fallbackLastRunAt;
- if (!completedRaw) return null;
-
- const completedAt =
- completedRaw instanceof Date ? completedRaw : new Date(completedRaw);
- if (Number.isNaN(completedAt.getTime())) return null;
-
- const parts: string[] = [`Ran ${formatRelativeTime(completedAt)}`];
-
- if (latestRun) {
- if (latestRun.totalChecked !== null) {
- const noun = latestRun.totalChecked === 1 ? 'check' : 'checks';
- parts.push(`${latestRun.totalChecked} ${noun} evaluated`);
- }
- if (latestRun.passedCount !== null) {
- parts.push(`${latestRun.passedCount} passed`);
- }
- if (latestRun.failedCount !== null) {
- parts.push(`${latestRun.failedCount} failed`);
- }
- if (latestRun.durationMs !== null && latestRun.durationMs > 0) {
- parts.push(`Duration ${formatDuration(latestRun.durationMs)}`);
- }
- }
-
- return (
-
- {parts.join(' • ')}
-
- );
-}
-
function RemediationSetupDialog({
open,
onOpenChange,
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx
index cb10ab1d93..516da323d1 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx
@@ -163,7 +163,6 @@ function CloudConnectionContent({
connectionId={connection.id}
orgId={orgId}
lastRunAt={connection.lastRunAt}
- latestRun={connection.latestRun ?? null}
variables={connection.variables ?? undefined}
awsType={connection.awsType}
onScanComplete={onScanComplete}
From 80232bc9e966c4b754c650d06fcfa5ed7262543b Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Fri, 15 May 2026 13:55:13 -0400
Subject: [PATCH 07/15] refactor(cloud-tests): move "Mark as exception" into
expanded finding view
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The shape mismatch between the primary Fix pill (accent-filled, with
icon) and the Mark-as-exception button (rectangular, outlined) made
every failing row look visually noisy. Worse, both competing for the
same right-side action area pushed the severity badge around.
Marking a finding as an exception is meant to be a DELIBERATE action —
the user must provide a written reason that an auditor will read. So
forcing them to expand the row first (read the title, the description,
the evidence, the remediation) before they can mark it accepted is
actually better auditor-experience UX, not worse.
- Removes the row-level "Mark as exception" button
- Adds it to the bottom of the expanded panel, right-aligned, separated
by a divider so it reads as a discrete final action
- All the modal wiring stays exactly the same — same canMarkException
gate (`integration:update` permission), same onMarkException
callback, same MarkExceptionModal opens
Row right side is now just [Fix] [severity-badge] — clean and
consistent.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../components/CloudTestsSection.tsx | 31 ++++++++++---------
1 file changed, 16 insertions(+), 15 deletions(-)
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
index 107ffc30ea..f7d46574a7 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
@@ -1471,21 +1471,6 @@ function FindingRow({
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
{renderFixButton()}
- {canMarkException &&
- onMarkException &&
- (finding.status === 'failed' || finding.status === 'FAILED') && (
-
- )}
{finding.remediation}
)}
+ {canMarkException &&
+ onMarkException &&
+ (finding.status === 'failed' || finding.status === 'FAILED') && (
+
+
+
+ )}
)}
From 11d717fa2b9fdf0797e0845c70482e734ae62e2b Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Fri, 15 May 2026 14:13:28 -0400
Subject: [PATCH 08/15] fix(cloud-tests): teach AI fix-plan to describe
create-from-scratch remediations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
For findings like "No CloudTrail trails configured" or "No GuardDuty
detector", the AI fix-plan dialog rendered CURRENT and PROPOSED as
empty {} boxes — the user couldn't see what the fix was about to create.
Root cause: the SYSTEM_PROMPT for fix plans tells the model to populate
currentState/proposedState ONLY with fields from the scan evidence, and
to "show absence as false or null". For mutate-existing-resource
remediations that worked fine. But for create-from-scratch remediations
the evidence has nothing to describe (the resource doesn't exist), so
the model returned `{}` for both — collapsing to an empty diff.
Adds a new "CREATE-FROM-SCRATCH REMEDIATIONS" section to the prompt
that:
- Requires currentState to include `"exists": false` (plus other known
absence facts) so the user sees that nothing is there today.
- Requires proposedState to describe the resource that WILL be created
using the same values the model is putting into its fixSteps
(CreateTrail name, multi-region flag, log validation, bucket name,
etc.) — concrete, not abstract.
- Explicitly forbids returning empty objects for both. At minimum the
model must emit `{ exists: false }` / `{ exists: true }` so the
user sees a readable false→true diff.
No schema change — `currentState`/`proposedState` are already
`z.record(z.string(), z.unknown())`. No UI change either — the existing
StateBlock renders whatever JSON the AI returns. The fix is purely on
the prompt side.
Other findings likely affected by the same pattern (now improved):
"No multi-region CloudTrail", "No AWS Config recorder", "No GuardDuty
detector", "No CloudWatch log group for X", "No S3 bucket configured
as the log target", etc.
Reported by sales via Tofik — empty CURRENT/PROPOSED boxes on the
"No CloudTrail trails configured" Auto-Remediate dialog.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../src/cloud-security/ai-remediation.prompt.ts | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/apps/api/src/cloud-security/ai-remediation.prompt.ts b/apps/api/src/cloud-security/ai-remediation.prompt.ts
index a471be0eda..1902dd0a23 100644
--- a/apps/api/src/cloud-security/ai-remediation.prompt.ts
+++ b/apps/api/src/cloud-security/ai-remediation.prompt.ts
@@ -277,7 +277,21 @@ A human will ALWAYS review your plan before execution. Be precise and correct.
- Example: { "versioning": "Enabled" }
- Example: { "metricFilterExists": true, "filterName": "cis-4.8-s3-bucket-policy-changes", "alarmExists": true, "alarmName": "cis-4.8-s3-bucket-policy-changes" }
- Both must use the SAME keys so the user can compare side by side
-- Do NOT include fields you don't know the value of`;
+- Do NOT include fields you don't know the value of
+
+## CREATE-FROM-SCRATCH REMEDIATIONS
+Some fixes create a resource that doesn't exist yet — e.g. "No CloudTrail trails configured", "No AWS Config recorder", "No GuardDuty detector", "No S3 bucket for log storage". For these:
+- currentState MUST include "exists: false" so the user sees that nothing is there today.
+ - Add other known-from-evidence absence facts when relevant.
+ - Example: { "exists": false }
+ - Example: { "exists": false, "trailsCount": 0 }
+- proposedState MUST describe the resource that will be created, in concrete terms.
+ - Use the fixSteps you generated as the source of truth — the values you'll pass to CreateTrail / CreateBucket / etc. go here.
+ - Include "exists: true" plus the key configuration the user is being asked to accept.
+ - Example: { "exists": true, "trailName": "compai-cloudtrail", "multiRegion": true, "logFileValidation": true, "s3Bucket": "compai-cloudtrail-logs-" }
+ - Example: { "exists": true, "detectorEnabled": true, "findingPublishingFrequency": "FIFTEEN_MINUTES" }
+- Both blocks STILL must share the same keys so the user can see "false → true" diffs at a glance.
+- NEVER leave both currentState and proposedState empty. An empty diff is unreadable for the user — if you cannot describe the resource concretely, at minimum return { "exists": false } / { "exists": true }.`;
export function buildFixPlanPrompt(finding: {
title: string;
From 8111faf787087bbb967e3ec3fe6a9c897186efbf Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Fri, 15 May 2026 15:47:14 -0400
Subject: [PATCH 09/15] fix(cloud-tests): deterministic backstop for empty
CURRENT/PROPOSED in fix-plan dialog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The previous prompt change asked the AI to emit `{ exists: false }` /
`{ exists: true, ... }` for create-from-scratch findings, but the model
doesn't reliably follow it — Tofik still sees `{} → {}` in the
Auto-Remediate Finding dialog for "No CloudTrail trails configured"
even with the new prompt in place.
Server-side backstop: after `generateFixPlan` and `refineFixPlan`
return, if BOTH currentState and proposedState are empty, fill them in
deterministically. `currentState` becomes `{ exists: false }`.
`proposedState` becomes `{ exists: true }` or, when the plan's fixSteps
include any `Create*Command` ops, `{ exists: true, willCreate:
["service:Resource", ...] }` derived from those steps. Never depends on
the model.
Only kicks in when BOTH sides are empty — verify-only plans that
legitimately have one side blank are untouched. Mutate-existing plans
(IAM, S3 versioning, etc.) already have rich state, so they're
untouched too. Risk of regression on the working cases: zero.
Includes ai-remediation.service.spec.ts (4 tests):
- CloudTrail-style with Create* steps → currentState { exists: false },
proposedState { exists: true, willCreate: ["cloudtrail:Trail",
"s3:Bucket"] }
- Plan with no Create* steps → proposedState falls back to `{ exists:
true }` without a willCreate list
- Plan with non-empty currentState → untouched
- Plan with one side empty → untouched (legitimate verify-only)
The prompt change from 11d717fa2 stays in — it's still useful when the
model does follow it (richer output than the backstop). This fix
guarantees a readable diff regardless of model behavior.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../ai-remediation.service.spec.ts | 144 ++++++++++++++++++
.../cloud-security/ai-remediation.service.ts | 48 +++++-
2 files changed, 189 insertions(+), 3 deletions(-)
create mode 100644 apps/api/src/cloud-security/ai-remediation.service.spec.ts
diff --git a/apps/api/src/cloud-security/ai-remediation.service.spec.ts b/apps/api/src/cloud-security/ai-remediation.service.spec.ts
new file mode 100644
index 0000000000..748b3e3940
--- /dev/null
+++ b/apps/api/src/cloud-security/ai-remediation.service.spec.ts
@@ -0,0 +1,144 @@
+// Mock @db before importing the service so the Prisma client doesn't try
+// to connect at import time in this unit-test env.
+jest.mock('@db', () => ({}));
+jest.mock('@ai-sdk/anthropic', () => ({
+ anthropic: () => null,
+}));
+jest.mock('ai', () => ({
+ generateObject: jest.fn(),
+}));
+
+import type { FixPlan } from './ai-remediation.prompt';
+import { AiRemediationService } from './ai-remediation.service';
+
+// `enrichEmptyState` isn't exported — exercise it through the service's
+// public methods by mocking generateObject to return a known-empty plan.
+import { generateObject } from 'ai';
+
+function basePlan(overrides: Partial = {}): FixPlan {
+ return {
+ canAutoFix: true,
+ risk: 'low',
+ description: 'desc',
+ currentState: {},
+ proposedState: {},
+ requiredPermissions: [],
+ readSteps: [],
+ fixSteps: [],
+ rollbackSteps: [],
+ rollbackSupported: false,
+ requiresAcknowledgment: false,
+ ...overrides,
+ } as FixPlan;
+}
+
+describe('AiRemediationService.generateFixPlan empty-state backstop', () => {
+ const generateObjectMock = generateObject as unknown as jest.Mock;
+
+ beforeEach(() => {
+ generateObjectMock.mockReset();
+ });
+
+ it('fills empty state with { exists: false } / { exists: true } when AI returns both empty', async () => {
+ generateObjectMock.mockResolvedValueOnce({
+ object: basePlan({
+ fixSteps: [
+ { service: 'cloudtrail', command: 'CreateTrailCommand', params: {} },
+ { service: 's3', command: 'CreateBucketCommand', params: {} },
+ { service: 'cloudtrail', command: 'StartLoggingCommand', params: {} },
+ ],
+ }),
+ });
+
+ const service = new AiRemediationService();
+ const plan = await service.generateFixPlan({
+ title: 'No CloudTrail trails configured',
+ description: 'No CloudTrail trails exist.',
+ severity: 'critical',
+ resourceType: 'AwsAccount',
+ resourceId: 'account-level',
+ remediation: 'Create a multi-region trail.',
+ findingKey: 'cloudtrail-no-trails',
+ evidence: { awsAccountId: '123', service: 'CloudTrail' },
+ });
+
+ expect(plan.currentState).toEqual({ exists: false });
+ expect(plan.proposedState).toEqual({
+ exists: true,
+ willCreate: ['cloudtrail:Trail', 's3:Bucket'],
+ });
+ });
+
+ it('falls back to { exists: true } without willCreate when no Create* steps are present', async () => {
+ generateObjectMock.mockResolvedValueOnce({
+ object: basePlan({
+ fixSteps: [
+ { service: 'iam', command: 'UpdateAccountPasswordPolicyCommand', params: {} },
+ ],
+ }),
+ });
+
+ const service = new AiRemediationService();
+ const plan = await service.generateFixPlan({
+ title: 'Weak password policy',
+ description: null,
+ severity: null,
+ resourceType: 'AwsIamPolicy',
+ resourceId: 'account-level',
+ remediation: null,
+ findingKey: 'iam-weak-password',
+ evidence: {},
+ });
+
+ expect(plan.currentState).toEqual({ exists: false });
+ expect(plan.proposedState).toEqual({ exists: true });
+ });
+
+ it('leaves a plan untouched when currentState is non-empty', async () => {
+ generateObjectMock.mockResolvedValueOnce({
+ object: basePlan({
+ currentState: { versioning: 'Disabled' },
+ proposedState: { versioning: 'Enabled' },
+ }),
+ });
+
+ const service = new AiRemediationService();
+ const plan = await service.generateFixPlan({
+ title: 'S3 versioning disabled',
+ description: null,
+ severity: null,
+ resourceType: 'S3Bucket',
+ resourceId: 'logs-archive',
+ remediation: null,
+ findingKey: 's3-versioning-disabled',
+ evidence: {},
+ });
+
+ expect(plan.currentState).toEqual({ versioning: 'Disabled' });
+ expect(plan.proposedState).toEqual({ versioning: 'Enabled' });
+ });
+
+ it('leaves a plan alone when only one side is empty (legitimate verify-only case)', async () => {
+ generateObjectMock.mockResolvedValueOnce({
+ object: basePlan({
+ currentState: { someField: 'X' },
+ proposedState: {},
+ }),
+ });
+
+ const service = new AiRemediationService();
+ const plan = await service.generateFixPlan({
+ title: 't',
+ description: null,
+ severity: null,
+ resourceType: 'X',
+ resourceId: 'y',
+ remediation: null,
+ findingKey: 'fk',
+ evidence: {},
+ });
+
+ expect(plan.currentState).toEqual({ someField: 'X' });
+ expect(plan.proposedState).toEqual({});
+ });
+});
diff --git a/apps/api/src/cloud-security/ai-remediation.service.ts b/apps/api/src/cloud-security/ai-remediation.service.ts
index b3549e06ff..775b4592ff 100644
--- a/apps/api/src/cloud-security/ai-remediation.service.ts
+++ b/apps/api/src/cloud-security/ai-remediation.service.ts
@@ -57,7 +57,7 @@ export class AiRemediationService {
this.logger.log(
`AI plan for ${finding.findingKey}: canAutoFix=${object.canAutoFix}, risk=${object.risk}`,
);
- return object;
+ return enrichEmptyState(object);
} catch (err) {
this.logger.error(
`AI plan failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -99,13 +99,13 @@ Generate the complete fix plan with EXACT values from the real AWS state.`,
});
this.logger.log(`AI refined plan for ${params.finding.findingKey}`);
- return object;
+ return enrichEmptyState(object);
} catch (err) {
this.logger.error(
`AI refine failed: ${err instanceof Error ? err.message : String(err)}`,
);
// Fall back to original plan
- return params.originalPlan;
+ return enrichEmptyState(params.originalPlan);
}
}
@@ -444,3 +444,45 @@ Generate the complete fix plan with EXACT values from the real Azure state.`,
};
}
}
+
+/**
+ * Deterministic backstop: if the AI returns BOTH currentState and
+ * proposedState as empty (which is what was rendering as `{} → {}` in the
+ * Auto-Remediate dialog for "No CloudTrail trails configured" and similar
+ * create-from-scratch findings), fill in a basic `{ exists: false }` →
+ * `{ exists: true, willCreate: [...] }` derived from the plan's Create*
+ * fix steps. Guarantees the user sees a meaningful diff regardless of
+ * model behavior.
+ *
+ * Only kicks in when BOTH states are empty — verify-only plans that
+ * legitimately have one side blank are untouched.
+ */
+function enrichEmptyState(plan: FixPlan): FixPlan {
+ const currentEmpty = isEmptyState(plan.currentState);
+ const proposedEmpty = isEmptyState(plan.proposedState);
+ if (!currentEmpty || !proposedEmpty) return plan;
+
+ const willCreate: string[] = [];
+ for (const step of plan.fixSteps ?? []) {
+ const command = typeof step?.command === 'string' ? step.command : '';
+ if (!command.startsWith('Create')) continue;
+ const resource = command.replace(/Command$/, '').replace(/^Create/, '');
+ const label = step.service ? `${step.service}:${resource}` : resource;
+ if (!willCreate.includes(label)) willCreate.push(label);
+ }
+
+ return {
+ ...plan,
+ currentState: { exists: false },
+ proposedState:
+ willCreate.length > 0
+ ? { exists: true, willCreate }
+ : { exists: true },
+ };
+}
+
+function isEmptyState(
+ state: Record | null | undefined,
+): boolean {
+ return !state || Object.keys(state).length === 0;
+}
From 9faf03637cb1ec4f30f2c371fc8bd3cb533c2028 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Fri, 15 May 2026 17:01:18 -0400
Subject: [PATCH 10/15] fix(cloud-tests): differentiate check-definition fields
+ structured remediation rendering
GCP/Azure passthrough was using the SCC `description` for both
"description" and "fail criteria", causing the same paragraph to
appear under multiple labels in the expanded finding view. Each of
the four fields (description, passCriteria, failCriteria,
whyItMatters) now derives a distinct sentence from the machine-
readable category code.
Remediation was rendered as a single paragraph that included the
reference URL and compliance metadata inline. A new
`parseRemediation` helper splits the API-concatenated string back
into `{ steps, referenceUrl, compliance[] }`, and a new
`RemediationSection` component renders steps as readable text, the
reference as a link, and frameworks as chips.
Also tightened the visual hierarchy of the expanded view so the
"about this check" card, the per-account failure, the evidence
viewer, and the remediation card each have a distinct treatment
instead of looking like five identical white boxes.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
...ck-definition-provider-passthrough.spec.ts | 140 ++++++++++++++++++
.../check-definition-provider-passthrough.ts | 36 ++---
.../components/CloudTestsSection.tsx | 16 +-
.../cloud-tests/components/FindingsTable.tsx | 23 +--
.../components/RemediationSection.tsx | 90 +++++++++++
.../components/remediation-parser.test.ts | 127 ++++++++++++++++
.../components/remediation-parser.ts | 110 ++++++++++++++
7 files changed, 495 insertions(+), 47 deletions(-)
create mode 100644 apps/api/src/cloud-security/check-definition-provider-passthrough.spec.ts
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationSection.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.test.ts
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.ts
diff --git a/apps/api/src/cloud-security/check-definition-provider-passthrough.spec.ts b/apps/api/src/cloud-security/check-definition-provider-passthrough.spec.ts
new file mode 100644
index 0000000000..48f6645e3d
--- /dev/null
+++ b/apps/api/src/cloud-security/check-definition-provider-passthrough.spec.ts
@@ -0,0 +1,140 @@
+import { buildProviderPassthroughDescription } from './check-definition-provider-passthrough';
+
+describe('buildProviderPassthroughDescription', () => {
+ describe('GCP', () => {
+ it('produces four distinct sentences for the four check-definition fields', () => {
+ const result = buildProviderPassthroughDescription({
+ provider: 'gcp',
+ title: 'Public IP Address',
+ description:
+ 'A Compute Engine instance has a public IP address attached.',
+ evidence: {
+ category: 'PUBLIC_IP_ADDRESS',
+ findingClass: 'VULNERABILITY',
+ },
+ });
+
+ expect(result).not.toBeNull();
+ // Each of the four fields must read differently — auditors should
+ // never see the same paragraph repeated under different labels.
+ const fields = [
+ result!.description,
+ result!.passCriteria,
+ result!.failCriteria,
+ result!.whyItMatters,
+ ];
+ const uniqueFields = new Set(fields);
+ expect(uniqueFields.size).toBe(4);
+ });
+
+ it('derives content from the machine-readable category, not the per-finding description', () => {
+ const description =
+ 'A Compute Engine instance has a public IP address attached.';
+ const result = buildProviderPassthroughDescription({
+ provider: 'gcp',
+ title: 'Public IP Address',
+ description,
+ evidence: {
+ category: 'PUBLIC_IP_ADDRESS',
+ findingClass: 'VULNERABILITY',
+ },
+ });
+
+ // The four header fields must NOT contain the per-finding description
+ // verbatim — that text belongs to "This account's result", not the
+ // generic check definition.
+ expect(result!.description).not.toContain(description);
+ expect(result!.failCriteria).not.toContain(description);
+
+ // But the humanized category must appear so the reader knows what
+ // SCC is checking for.
+ expect(result!.description).toContain('Public Ip Address');
+ expect(result!.passCriteria).toContain('Public Ip Address');
+ expect(result!.failCriteria).toContain('Public Ip Address');
+ });
+
+ it('returns null when evidence is missing', () => {
+ const result = buildProviderPassthroughDescription({
+ provider: 'gcp',
+ title: 't',
+ description: 'd',
+ evidence: null,
+ });
+ expect(result).toBeNull();
+ });
+
+ it('returns null when category is missing from evidence', () => {
+ const result = buildProviderPassthroughDescription({
+ provider: 'gcp',
+ title: 't',
+ description: 'd',
+ evidence: { findingClass: 'VULNERABILITY' },
+ });
+ expect(result).toBeNull();
+ });
+
+ it('uses a generic whyItMatters when findingClass is missing', () => {
+ const result = buildProviderPassthroughDescription({
+ provider: 'gcp',
+ title: 't',
+ description: 'd',
+ evidence: { category: 'WEAK_PASSWORD_POLICY' },
+ });
+ expect(result!.whyItMatters).toBeTruthy();
+ expect(result!.whyItMatters).toContain('Weak Password Policy');
+ });
+ });
+
+ describe('Azure', () => {
+ it('produces four distinct sentences for the four check-definition fields', () => {
+ const result = buildProviderPassthroughDescription({
+ provider: 'azure',
+ title: 'Storage account allows public access',
+ description: 'The storage account allows anonymous read access.',
+ evidence: { alertType: 'Anonymous_Blob_Access' },
+ });
+
+ expect(result).not.toBeNull();
+ const fields = [
+ result!.description,
+ result!.passCriteria,
+ result!.failCriteria,
+ result!.whyItMatters,
+ ];
+ expect(new Set(fields).size).toBe(4);
+ });
+
+ it('handles Azure findings without alertType by falling back to serviceName', () => {
+ const result = buildProviderPassthroughDescription({
+ provider: 'azure',
+ title: 't',
+ description: 'd',
+ evidence: { serviceName: 'Defender for Storage' },
+ });
+ expect(result).not.toBeNull();
+ expect(result!.description).toContain('Defender for Storage');
+ });
+
+ it('produces a description even when evidence is null (Defender baseline)', () => {
+ const result = buildProviderPassthroughDescription({
+ provider: 'azure',
+ title: 't',
+ description: 'd',
+ evidence: null,
+ });
+ expect(result).not.toBeNull();
+ // Generic Defender phrasing — no alertType available.
+ expect(result!.description).toContain('Microsoft Defender for Cloud');
+ });
+ });
+
+ it('returns null for unknown provider', () => {
+ const result = buildProviderPassthroughDescription({
+ provider: 'aws',
+ title: 't',
+ description: 'd',
+ evidence: {},
+ });
+ expect(result).toBeNull();
+ });
+});
diff --git a/apps/api/src/cloud-security/check-definition-provider-passthrough.ts b/apps/api/src/cloud-security/check-definition-provider-passthrough.ts
index 1aadc588ed..a91b99eae8 100644
--- a/apps/api/src/cloud-security/check-definition-provider-passthrough.ts
+++ b/apps/api/src/cloud-security/check-definition-provider-passthrough.ts
@@ -37,18 +37,21 @@ function fromGcpEvidence(
: null;
if (!category) return null;
+ const humanCategory = humanizeCategory(category);
+
+ // Each of the four fields needs a genuinely different sentence — auditors
+ // shouldn't see the same paragraph three times under different labels.
+ // Derived from the category code (stable, machine-readable), not from
+ // SCC's per-finding `description` (which is generic category info that
+ // duplicates across fields when used naively).
return {
title: input.title,
- description: input.description ?? humanizeCategory(category),
- passCriteria: `No ${category
- .toLowerCase()
- .replace(/_/g, ' ')} detected by Google Security Command Center.`,
- failCriteria: input.description
- ? input.description
- : `Google Security Command Center flagged this resource under category ${category}.`,
+ description: `This check surfaces findings from Google Security Command Center under the "${humanCategory}" detector. SCC continuously evaluates your projects against Google's security baseline and flags resources matching the ${category} pattern.`,
+ passCriteria: `Google Security Command Center reports no active "${humanCategory}" findings for this project.`,
+ failCriteria: `Google Security Command Center detected a resource matching the "${humanCategory}" criteria in this project.`,
whyItMatters: findingClass
- ? `Google classifies this finding as a "${findingClass}" — addressing it reduces the underlying security risk Google identified.`
- : 'Findings from Google Security Command Center indicate a security concern Google detected in the customer environment.',
+ ? `Google classifies this finding as a ${findingClass.toLowerCase()} — addressing it reduces the underlying security risk Google identified for the ${humanCategory} category.`
+ : `Findings under the ${humanCategory} category indicate a security concern Google detected in the customer environment.`,
source: 'provider',
};
}
@@ -63,18 +66,15 @@ function fromAzureEvidence(
? (input.evidence.serviceName as string)
: null;
+ const subject = alertType ?? 'this configuration';
+
return {
title: input.title,
- description:
- input.description ??
- 'Surfaced from Microsoft Defender for Cloud. Defender flagged this resource as not meeting its recommended secure configuration.',
- passCriteria:
- 'Microsoft Defender for Cloud reports the resource as healthy.',
- failCriteria: input.description
- ? input.description
- : 'Microsoft Defender for Cloud reports the resource as unhealthy.',
+ description: `This check surfaces findings from Microsoft Defender for Cloud${alertType ? ` under the "${alertType}" assessment` : ''}. Defender continuously evaluates the subscription against Microsoft's secure-configuration baseline and flags resources that diverge from the recommended state.`,
+ passCriteria: `Microsoft Defender for Cloud reports ${subject} as healthy on this subscription.`,
+ failCriteria: `Microsoft Defender for Cloud reports ${subject} as unhealthy on this resource.`,
whyItMatters: alertType
- ? `Defender raised this finding under "${alertType}" — addressing it brings the resource in line with Microsoft\'s recommended configuration.`
+ ? `Defender raised this finding under "${alertType}" — addressing it brings the resource in line with Microsoft's recommended configuration.`
: 'Defender for Cloud findings indicate a deviation from Microsoft-recommended secure configuration in the customer environment.',
source: 'provider',
};
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
index f7d46574a7..c1fb3e2c05 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
@@ -48,6 +48,7 @@ import { CheckGroupBlock } from './CheckGroupBlock';
import { buildCheckGroups } from './check-groups';
import { EvidenceJsonViewer } from './EvidenceJsonViewer';
import { MarkExceptionModal } from './MarkExceptionModal';
+import { RemediationSection } from './RemediationSection';
interface RemediationCapabilities {
enabled: boolean;
@@ -1482,20 +1483,17 @@ function FindingRow({
{finding.description && (
-
-
This account's result
-
+
+
+
This account's result
+
+
{finding.description}
)}
- {finding.remediation && (
-
-
Remediation
-
{finding.remediation}
-
- )}
+ {finding.remediation &&
}
{canMarkException &&
onMarkException &&
(finding.status === 'failed' || finding.status === 'FAILED') && (
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/FindingsTable.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/FindingsTable.tsx
index 85fae82e3b..1050c5093e 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/FindingsTable.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/FindingsTable.tsx
@@ -13,6 +13,8 @@ import {
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Fragment, useState } from 'react';
+import { RemediationSection } from './RemediationSection';
+
interface Finding {
id: string;
title: string | null;
@@ -127,26 +129,7 @@ export function FindingsTable({ findings }: FindingsTableProps) {
)}
{finding.remediation && (
-
-
Remediation
-
- {finding.remediation.split(/\b(https?:\/\/\S+)\b/).map((part, i) => {
- return /^https?:\/\/\S+$/.test(part) ? (
-
- {part}
-
- ) : (
-
{part}
- );
- })}
-
-
+
)}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationSection.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationSection.tsx
new file mode 100644
index 0000000000..2cfec0bb04
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationSection.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import { ExternalLink, Wrench } from 'lucide-react';
+
+import { parseRemediation } from './remediation-parser';
+
+interface RemediationSectionProps {
+ remediation: string;
+}
+
+/**
+ * Renders a finding's remediation guidance as structured pieces:
+ * - the actual fix steps as readable text
+ * - an optional "Reference" link to the provider's documentation
+ * - compliance framework chips
+ *
+ * For GCP findings the API concatenates these into a single string;
+ * `parseRemediation` splits them back apart. AWS / Azure remediations
+ * render as plain step text with no metadata chips.
+ */
+export function RemediationSection({ remediation }: RemediationSectionProps) {
+ const parsed = parseRemediation(remediation);
+ if (!parsed.steps && !parsed.referenceUrl && parsed.compliance.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
Remediation
+
+
+ {parsed.steps && (
+
+ {parsed.steps}
+
+ )}
+ {parsed.referenceUrl && (
+
+
+ Reference documentation
+
+ )}
+ {parsed.compliance.length > 0 && (
+
+
+ Compliance
+
+
+ {parsed.compliance.map((framework, idx) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+function ComplianceChip({
+ framework,
+}: {
+ framework: {
+ standard: string;
+ version: string | null;
+ ids: string[];
+ };
+}) {
+ const label = framework.version
+ ? `${framework.standard.toUpperCase()} ${framework.version}`
+ : framework.standard.toUpperCase();
+ const detail = framework.ids.join(', ');
+
+ return (
+
+ {label}
+ {detail && {detail}}
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.test.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.test.ts
new file mode 100644
index 0000000000..5ffa9f2c7a
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from 'vitest';
+import { parseRemediation } from './remediation-parser';
+
+describe('parseRemediation', () => {
+ it('parses a full GCP remediation with nextSteps + reference + compliance', () => {
+ const input =
+ 'Set the appropriate value for the enableNetworkPolicy field. See https://cloud.google.com/docs.\n\n' +
+ 'More info: https://cloud.google.com/security-command-center/docs/cluster\n\n' +
+ 'Compliance: cis 1.0 (5.6.7); pci 3.2.1 (1.2.1, 1.3.1); nist 800-53 (SC-7)';
+
+ const parsed = parseRemediation(input);
+
+ expect(parsed.steps).toBe(
+ 'Set the appropriate value for the enableNetworkPolicy field. See https://cloud.google.com/docs.',
+ );
+ expect(parsed.referenceUrl).toBe(
+ 'https://cloud.google.com/security-command-center/docs/cluster',
+ );
+ expect(parsed.compliance).toEqual([
+ { standard: 'cis', version: '1.0', ids: ['5.6.7'] },
+ { standard: 'pci', version: '3.2.1', ids: ['1.2.1', '1.3.1'] },
+ { standard: 'nist', version: '800-53', ids: ['SC-7'] },
+ ]);
+ });
+
+ it('returns AWS-style remediation (single paragraph) verbatim as steps', () => {
+ const input =
+ "Use s3:PutBucketEncryptionCommand with Bucket set to 'my-bucket' " +
+ "and ServerSideEncryptionConfiguration containing SSEAlgorithm 'AES256'.";
+
+ const parsed = parseRemediation(input);
+
+ expect(parsed.steps).toBe(input);
+ expect(parsed.referenceUrl).toBeNull();
+ expect(parsed.compliance).toEqual([]);
+ });
+
+ it('handles GCP findings that have only nextSteps (no reference, no compliance)', () => {
+ const input = 'Review the IAM policy and remove primitive role grants.';
+
+ const parsed = parseRemediation(input);
+
+ expect(parsed.steps).toBe(input);
+ expect(parsed.referenceUrl).toBeNull();
+ expect(parsed.compliance).toEqual([]);
+ });
+
+ it('handles a reference URL without compliance section', () => {
+ const input =
+ 'Enable Cloud Audit Logs for the project.\n\n' +
+ 'More info: https://cloud.google.com/audit-logs';
+
+ const parsed = parseRemediation(input);
+
+ expect(parsed.steps).toBe('Enable Cloud Audit Logs for the project.');
+ expect(parsed.referenceUrl).toBe('https://cloud.google.com/audit-logs');
+ expect(parsed.compliance).toEqual([]);
+ });
+
+ it('handles compliance frameworks with multiple IDs each', () => {
+ const input =
+ 'Fix the issue.\n\nCompliance: cis 1.0 (1.1, 1.2, 1.3); pci 3.2.1 (2.1.1, 2.2.2)';
+
+ const parsed = parseRemediation(input);
+
+ expect(parsed.steps).toBe('Fix the issue.');
+ expect(parsed.compliance).toEqual([
+ { standard: 'cis', version: '1.0', ids: ['1.1', '1.2', '1.3'] },
+ { standard: 'pci', version: '3.2.1', ids: ['2.1.1', '2.2.2'] },
+ ]);
+ });
+
+ it('keeps multi-paragraph steps intact when no metadata sections are present', () => {
+ const input = 'First do this.\n\nThen do that.\n\nFinally verify.';
+
+ const parsed = parseRemediation(input);
+
+ expect(parsed.steps).toBe(input);
+ expect(parsed.referenceUrl).toBeNull();
+ expect(parsed.compliance).toEqual([]);
+ });
+
+ it('handles malformed compliance entries by surfacing the raw label', () => {
+ const input = 'Fix it.\n\nCompliance: weird-format-no-parens; cis 1.0 (5.1)';
+
+ const parsed = parseRemediation(input);
+
+ expect(parsed.compliance).toEqual([
+ { standard: 'weird-format-no-parens', version: null, ids: [] },
+ { standard: 'cis', version: '1.0', ids: ['5.1'] },
+ ]);
+ });
+
+ it('ignores empty "More info: " line so the UI does not render a broken link', () => {
+ const input = 'Do something.\n\nMore info: ';
+
+ const parsed = parseRemediation(input);
+
+ expect(parsed.steps).toBe('Do something.');
+ expect(parsed.referenceUrl).toBeNull();
+ });
+
+ it('preserves Azure remediation steps joined by newlines', () => {
+ // Azure remediation steps come as `array.join('\n')`, not '\n\n'.
+ const input =
+ '1. Open Microsoft Defender for Cloud.\n2. Click Resolve.\n3. Apply the recommended fix.';
+
+ const parsed = parseRemediation(input);
+
+ expect(parsed.steps).toBe(input);
+ expect(parsed.referenceUrl).toBeNull();
+ expect(parsed.compliance).toEqual([]);
+ });
+
+ it('trims whitespace around sections without losing content', () => {
+ const input =
+ ' Steps text. \n\n More info: https://example.com \n\n Compliance: cis 1.0 (1.1) ';
+
+ const parsed = parseRemediation(input);
+
+ expect(parsed.steps).toBe('Steps text.');
+ expect(parsed.referenceUrl).toBe('https://example.com');
+ expect(parsed.compliance).toEqual([
+ { standard: 'cis', version: '1.0', ids: ['1.1'] },
+ ]);
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.ts
new file mode 100644
index 0000000000..9a2687317f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.ts
@@ -0,0 +1,110 @@
+/**
+ * GCP findings produce a remediation string with embedded sections —
+ * a "More info: " line and a "Compliance: " line are
+ * appended to the raw SCC `nextSteps` text by the API. Rendering that
+ * concatenated string verbatim looks like a wall of metadata. This
+ * parser splits the string back into structured pieces so the UI can
+ * present steps, reference link, and compliance frameworks distinctly.
+ *
+ * AWS / Azure remediations have no embedded sections — the parser
+ * returns the input verbatim as `steps` with empty/null metadata.
+ */
+
+export interface ComplianceFramework {
+ standard: string;
+ version: string | null;
+ ids: string[];
+}
+
+export interface ParsedRemediation {
+ steps: string;
+ referenceUrl: string | null;
+ compliance: ComplianceFramework[];
+}
+
+// Use prefixes without trailing whitespace so we match even after the
+// section has been trimmed (an empty URL renders as "More info:" with
+// no trailing space).
+const COMPLIANCE_PREFIX = 'Compliance:';
+const REFERENCE_PREFIX = 'More info:';
+
+/**
+ * Parses a remediation string built by the API into structured pieces.
+ *
+ * Input shape (GCP):
+ * "\n\nMore info: \n\nCompliance: cis 1.0 (5.6.7); pci 3.2.1 (1.2.1)"
+ * The sections are appended in fixed order by `GCPSecurityService.buildRemediation`.
+ * Either section is optional (older findings may omit one).
+ */
+export function parseRemediation(input: string): ParsedRemediation {
+ const sections = input.split('\n\n').map((s) => s.trim());
+
+ const stepLines: string[] = [];
+ let referenceUrl: string | null = null;
+ let complianceLine: string | null = null;
+
+ for (const section of sections) {
+ if (section.startsWith(REFERENCE_PREFIX)) {
+ const candidate = section.slice(REFERENCE_PREFIX.length).trim();
+ if (candidate.length > 0) {
+ referenceUrl = candidate;
+ }
+ continue;
+ }
+ if (section.startsWith(COMPLIANCE_PREFIX)) {
+ complianceLine = section.slice(COMPLIANCE_PREFIX.length).trim();
+ continue;
+ }
+ if (section.length > 0) {
+ stepLines.push(section);
+ }
+ }
+
+ return {
+ steps: stepLines.join('\n\n'),
+ referenceUrl,
+ compliance: complianceLine ? parseComplianceLine(complianceLine) : [],
+ };
+}
+
+/**
+ * Parses a line like:
+ * "cis 1.0 (5.6.7); pci 3.2.1 (1.2.1, 1.3.1); nist 800-53 (SC-7)"
+ *
+ * The format mirrors the join performed in
+ * `GCPSecurityService.buildRemediation`:
+ * parts.push(`Compliance: ${standards.join('; ')}`)
+ * where each `standard` = `${c.standard} ${c.version} (${c.ids.join(', ')})`.
+ *
+ * Falls back gracefully on unexpected input — a malformed entry becomes
+ * a framework with only `standard` populated, never throws.
+ */
+function parseComplianceLine(line: string): ComplianceFramework[] {
+ const entries = line.split(/;\s*/).filter((p) => p.length > 0);
+ const frameworks: ComplianceFramework[] = [];
+
+ for (const entry of entries) {
+ const match = entry.match(/^(.+?)\s+(\S+?)\s*\(([^)]+)\)\s*$/);
+ if (match) {
+ const [, standard, version, idsRaw] = match;
+ frameworks.push({
+ standard: standard.trim(),
+ version: version.trim(),
+ ids: idsRaw
+ .split(/,\s*/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0),
+ });
+ continue;
+ }
+ // No version/ids in parens — surface the raw label so we don't drop
+ // a compliance reference we don't fully understand.
+ frameworks.push({
+ standard: entry.trim(),
+ version: null,
+ ids: [],
+ });
+ }
+
+ return frameworks;
+}
From 79bfb0f328ad41ec4f30c442a7a35aadeb0d6e00 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Fri, 15 May 2026 17:15:15 -0400
Subject: [PATCH 11/15] fix(cloud-tests): address cubic review on
RemediationSection and enrichEmptyState
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P1 — RemediationSection now validates the reference URL scheme before
assigning it to an href. `parseRemediation` extracts the URL from
provider-supplied text (today: SCC's externalUri); a new `safeHttpUrl`
helper only honors http(s) URLs and rejects javascript:, data:, vbscript:,
file:, and protocol-relative URLs. The link is hidden when validation
fails so we never render an unsafe href.
P2 — enrichEmptyState was firing for every {}/{} plan, including
update-style remediations (e.g. UpdateAccountPasswordPolicyCommand),
fabricating `currentState: { exists: false } → proposedState: { exists: true }`
which told the UI "we'll create this resource" when the truth was
"we'll update the existing one". The backstop now only enriches when
at least one `Create*` command is present in fixSteps. Update-style
plans are left untouched.
Tests:
- 10 new safeHttpUrl tests (parser test file)
- Updated the existing backstop test that asserted the wrong behavior
- Added `purpose` to fix-step test data to satisfy the strict schema
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../ai-remediation.service.spec.ts | 19 ++++---
.../cloud-security/ai-remediation.service.ts | 12 +++--
.../components/RemediationSection.tsx | 10 ++--
.../components/remediation-parser.test.ts | 50 ++++++++++++++++++-
.../components/remediation-parser.ts | 22 ++++++++
5 files changed, 97 insertions(+), 16 deletions(-)
diff --git a/apps/api/src/cloud-security/ai-remediation.service.spec.ts b/apps/api/src/cloud-security/ai-remediation.service.spec.ts
index 748b3e3940..f4c821b3eb 100644
--- a/apps/api/src/cloud-security/ai-remediation.service.spec.ts
+++ b/apps/api/src/cloud-security/ai-remediation.service.spec.ts
@@ -43,9 +43,9 @@ describe('AiRemediationService.generateFixPlan empty-state backstop', () => {
generateObjectMock.mockResolvedValueOnce({
object: basePlan({
fixSteps: [
- { service: 'cloudtrail', command: 'CreateTrailCommand', params: {} },
- { service: 's3', command: 'CreateBucketCommand', params: {} },
- { service: 'cloudtrail', command: 'StartLoggingCommand', params: {} },
+ { service: 'cloudtrail', command: 'CreateTrailCommand', params: {}, purpose: 'Create trail' },
+ { service: 's3', command: 'CreateBucketCommand', params: {}, purpose: 'Create bucket' },
+ { service: 'cloudtrail', command: 'StartLoggingCommand', params: {}, purpose: 'Start logging' },
],
}),
});
@@ -69,11 +69,16 @@ describe('AiRemediationService.generateFixPlan empty-state backstop', () => {
});
});
- it('falls back to { exists: true } without willCreate when no Create* steps are present', async () => {
+ it('leaves both states empty when AI returns {}/{} for an update-style plan (no Create* commands)', async () => {
+ // Previously this test asserted the backstop fabricated
+ // `{ exists: false }` / `{ exists: true }` even for updates, which
+ // misrepresented the diff in the UI ("we'll create it" when the truth
+ // was "we'll update the existing one"). The backstop now only fires
+ // when at least one `Create*` command is present.
generateObjectMock.mockResolvedValueOnce({
object: basePlan({
fixSteps: [
- { service: 'iam', command: 'UpdateAccountPasswordPolicyCommand', params: {} },
+ { service: 'iam', command: 'UpdateAccountPasswordPolicyCommand', params: {}, purpose: 'Update password policy' },
],
}),
});
@@ -90,8 +95,8 @@ describe('AiRemediationService.generateFixPlan empty-state backstop', () => {
evidence: {},
});
- expect(plan.currentState).toEqual({ exists: false });
- expect(plan.proposedState).toEqual({ exists: true });
+ expect(plan.currentState).toEqual({});
+ expect(plan.proposedState).toEqual({});
});
it('leaves a plan untouched when currentState is non-empty', async () => {
diff --git a/apps/api/src/cloud-security/ai-remediation.service.ts b/apps/api/src/cloud-security/ai-remediation.service.ts
index 775b4592ff..be81aa9424 100644
--- a/apps/api/src/cloud-security/ai-remediation.service.ts
+++ b/apps/api/src/cloud-security/ai-remediation.service.ts
@@ -471,13 +471,17 @@ function enrichEmptyState(plan: FixPlan): FixPlan {
if (!willCreate.includes(label)) willCreate.push(label);
}
+ // Only enrich when we have actual `Create*` commands — otherwise we'd
+ // fabricate "exists: false → exists: true" for updates/reads (e.g.
+ // UpdateAccountPasswordPolicy), which misrepresents the diff in the UI.
+ // For non-create remediations we leave the AI's (admittedly empty) output
+ // alone rather than inventing state.
+ if (willCreate.length === 0) return plan;
+
return {
...plan,
currentState: { exists: false },
- proposedState:
- willCreate.length > 0
- ? { exists: true, willCreate }
- : { exists: true },
+ proposedState: { exists: true, willCreate },
};
}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationSection.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationSection.tsx
index 2cfec0bb04..7e9e64c6b9 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationSection.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationSection.tsx
@@ -2,7 +2,7 @@
import { ExternalLink, Wrench } from 'lucide-react';
-import { parseRemediation } from './remediation-parser';
+import { parseRemediation, safeHttpUrl } from './remediation-parser';
interface RemediationSectionProps {
remediation: string;
@@ -20,7 +20,9 @@ interface RemediationSectionProps {
*/
export function RemediationSection({ remediation }: RemediationSectionProps) {
const parsed = parseRemediation(remediation);
- if (!parsed.steps && !parsed.referenceUrl && parsed.compliance.length === 0) {
+ // Only honor `http(s)` schemes — never assign `javascript:` / `data:` to href.
+ const safeReferenceUrl = safeHttpUrl(parsed.referenceUrl);
+ if (!parsed.steps && !safeReferenceUrl && parsed.compliance.length === 0) {
return null;
}
@@ -36,9 +38,9 @@ export function RemediationSection({ remediation }: RemediationSectionProps) {
{parsed.steps}
)}
- {parsed.referenceUrl && (
+ {safeReferenceUrl && (
{
it('parses a full GCP remediation with nextSteps + reference + compliance', () => {
@@ -125,3 +125,51 @@ describe('parseRemediation', () => {
]);
});
});
+
+describe('safeHttpUrl', () => {
+ it('returns http URLs unchanged', () => {
+ expect(safeHttpUrl('http://example.com/path')).toBe('http://example.com/path');
+ });
+
+ it('returns https URLs unchanged', () => {
+ expect(safeHttpUrl('https://cloud.google.com/docs')).toBe(
+ 'https://cloud.google.com/docs',
+ );
+ });
+
+ it('rejects javascript: URLs', () => {
+ expect(safeHttpUrl('javascript:alert(1)')).toBeNull();
+ });
+
+ it('rejects data: URLs', () => {
+ expect(safeHttpUrl('data:text/html,')).toBeNull();
+ });
+
+ it('rejects vbscript: URLs', () => {
+ expect(safeHttpUrl('vbscript:msgbox(1)')).toBeNull();
+ });
+
+ it('rejects file: URLs', () => {
+ expect(safeHttpUrl('file:///etc/passwd')).toBeNull();
+ });
+
+ it('rejects relative URLs (no protocol)', () => {
+ expect(safeHttpUrl('//evil.example/path')).toBeNull();
+ expect(safeHttpUrl('/relative/path')).toBeNull();
+ });
+
+ it('rejects malformed URLs', () => {
+ expect(safeHttpUrl('not a url at all')).toBeNull();
+ expect(safeHttpUrl('')).toBeNull();
+ });
+
+ it('rejects null input', () => {
+ expect(safeHttpUrl(null)).toBeNull();
+ });
+
+ it('case-insensitively rejects upper-case JAVASCRIPT:', () => {
+ // `new URL` normalizes the protocol to lowercase, so we still match the
+ // safe-list correctly even if attacker uses unusual casing.
+ expect(safeHttpUrl('JavaScript:alert(1)')).toBeNull();
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.ts
index 9a2687317f..cf466feeb0 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.ts
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/remediation-parser.ts
@@ -22,6 +22,28 @@ export interface ParsedRemediation {
compliance: ComplianceFramework[];
}
+/**
+ * Returns the URL only if it parses as an absolute http/https URL.
+ * Returns `null` for `javascript:`, `data:`, `vbscript:`, relative URLs,
+ * or anything malformed — so callers can safely assign the result to an
+ * `href` attribute without enabling script-URL execution.
+ *
+ * Today's source for remediation URLs is Google SCC's `externalUri`
+ * field, but the parser is generic — defense in depth applies.
+ */
+export function safeHttpUrl(url: string | null): string | null {
+ if (!url) return null;
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
+ return parsed.toString();
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
// Use prefixes without trailing whitespace so we match even after the
// section has been trimmed (an empty URL renders as "More info:" with
// no trailing space).
From 0e193c2ff5b187496554a38d20268edd92b4bab4 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Fri, 15 May 2026 17:45:01 -0400
Subject: [PATCH 12/15] fix(cloud-tests): address cubic round 4 findings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Eight real issues from cubic's review on 79bfb0f32. Two findings about
Jest hoisting (exception/reconciliation specs) skipped — those are
babel-jest's rule but the project uses ts-jest, where the pattern works
and all 145 tests currently pass.
P3 — "All passed" badge unreachable
CloudTestsSection.tsx used `group.findings.length > 0` for hasFailures,
but `findings` is the merged failed+passed set, so groups with only
passing checks were never recognized. Use `group.failed > 0` instead.
P2 — Dangling FK on FindingRegression.previousResolutionId
Schema documented it as an FK but didn't declare a relation. Added
`@relation(..., onDelete: SetNull)` plus a back-relation on
FindingResolution.regressions, with a Prisma-generated migration that
adds the constraint.
P2 — DTO accepted whitespace-only exception reasons
@MinLength(20) counts spaces. Added @Matches(/(?:\\S.*?){20}/s) so
twenty literal spaces no longer satisfy the contract.
P2 — Placeholder leak in fix-plan prompt
CREATE-FROM-SCRATCH example used `` while the prompt
forbids placeholders. Replaced with a concrete account ID + region
pair so the model has a clean reference.
P2 — exception-expiry fallback was too permissive
`new Date(input)` accepted locale-specific strings like
"January 1, 2026" and "2026/08/13", bypassing the documented
ISO 8601 contract. Added a strict ISO 8601 regex check ahead of the
Date constructor; spec updated to assert the new strict behavior.
P2 — History tab silently truncated rows
Service capped resolutions/exceptions/regressions at 200/100 with no
totals. Now returns true counts from separate `count()` queries plus
a `truncated` flag per category; HistoryTab renders "shown of total"
when truncation is active so auditors don't see misleading numbers.
P2 — CommentsPermissionGuard missing try/catch around checkPermission
Standard PermissionGuard wraps the call so network/auth-service
failures convert to ForbiddenException('Unable to verify permissions')
rather than leaking 500s. Mirrored that here.
P2 — platform_fix attribution scope too broad
Reconciliation looked up RemediationActions by connection + resource
alone, so a fix for check A on a resource was mis-attributed to
check B on the same resource. Plumbed `priorCheckResultId` through
indexResults → determineResolutionMethod and now filter by the
specific `checkResultId` instead. Spec updated to populate `id` on
test results.
Test counts: 145 API tests + 20 parser tests pass; the single failing
suite (remediation.controller.spec.ts) is the pre-existing Postgres
TLS environmental issue, unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../cloud-security/ai-remediation.prompt.ts | 2 +-
.../cloud-security/dto/mark-exception.dto.ts | 9 +-
.../exception-expiry.utils.spec.ts | 24 ++++-
.../cloud-security/exception-expiry.utils.ts | 10 ++
.../api/src/cloud-security/history.service.ts | 94 +++++++++++++------
.../reconciliation.service.spec.ts | 3 +
.../cloud-security/reconciliation.service.ts | 16 +++-
.../src/comments/comments-permission.guard.ts | 23 +++--
.../components/CloudTestsSection.tsx | 10 +-
.../cloud-tests/components/HistoryTab.tsx | 31 +++++-
.../migration.sql | 2 +
.../db/prisma/schema/finding-history.prisma | 6 ++
12 files changed, 179 insertions(+), 51 deletions(-)
create mode 100644 packages/db/prisma/migrations/20260515214156_finding_regression_resolution_fk/migration.sql
diff --git a/apps/api/src/cloud-security/ai-remediation.prompt.ts b/apps/api/src/cloud-security/ai-remediation.prompt.ts
index 1902dd0a23..d4bd3d1eaf 100644
--- a/apps/api/src/cloud-security/ai-remediation.prompt.ts
+++ b/apps/api/src/cloud-security/ai-remediation.prompt.ts
@@ -288,7 +288,7 @@ Some fixes create a resource that doesn't exist yet — e.g. "No CloudTrail trai
- proposedState MUST describe the resource that will be created, in concrete terms.
- Use the fixSteps you generated as the source of truth — the values you'll pass to CreateTrail / CreateBucket / etc. go here.
- Include "exists: true" plus the key configuration the user is being asked to accept.
- - Example: { "exists": true, "trailName": "compai-cloudtrail", "multiRegion": true, "logFileValidation": true, "s3Bucket": "compai-cloudtrail-logs-" }
+ - Example: { "exists": true, "trailName": "compai-cloudtrail", "multiRegion": true, "logFileValidation": true, "s3Bucket": "compai-cloudtrail-logs-013388577167-us-east-1" } — use the concrete AWS account ID and region from evidence, never a placeholder
- Example: { "exists": true, "detectorEnabled": true, "findingPublishingFrequency": "FIFTEEN_MINUTES" }
- Both blocks STILL must share the same keys so the user can see "false → true" diffs at a glance.
- NEVER leave both currentState and proposedState empty. An empty diff is unreadable for the user — if you cannot describe the resource concretely, at minimum return { "exists": false } / { "exists": true }.`;
diff --git a/apps/api/src/cloud-security/dto/mark-exception.dto.ts b/apps/api/src/cloud-security/dto/mark-exception.dto.ts
index 8b7bdbfd90..3cf47ebfd3 100644
--- a/apps/api/src/cloud-security/dto/mark-exception.dto.ts
+++ b/apps/api/src/cloud-security/dto/mark-exception.dto.ts
@@ -1,13 +1,18 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsDateString, IsOptional, IsString, MinLength } from 'class-validator';
+import { IsDateString, IsOptional, IsString, Matches, MinLength } from 'class-validator';
export class MarkExceptionDto {
@ApiProperty({
- description: 'Documentation for why this finding does not apply or is being accepted. Minimum 20 characters.',
+ description: 'Documentation for why this finding does not apply or is being accepted. Minimum 20 non-whitespace characters.',
example: 'Bucket hosts intentionally public marketing assets; writes restricted to the marketing IAM role.',
})
@IsString()
@MinLength(20, { message: 'Reason must be at least 20 characters.' })
+ // @MinLength alone counts whitespace, so 20 spaces would pass. Require
+ // at least 20 non-whitespace characters anywhere in the string.
+ @Matches(/(?:\S.*?){20}/s, {
+ message: 'Reason must contain at least 20 non-whitespace characters.',
+ })
reason!: string;
@ApiPropertyOptional({
diff --git a/apps/api/src/cloud-security/exception-expiry.utils.spec.ts b/apps/api/src/cloud-security/exception-expiry.utils.spec.ts
index b72abc90da..0de0204322 100644
--- a/apps/api/src/cloud-security/exception-expiry.utils.spec.ts
+++ b/apps/api/src/cloud-security/exception-expiry.utils.spec.ts
@@ -60,10 +60,24 @@ describe('parseExceptionExpiry', () => {
expect(() => parseExceptionExpiry('xyz')).toThrow(BadRequestException);
});
- it('accepts non-canonical-but-parseable date forms via the fallback', () => {
- // Slash-separated form is non-canonical but `new Date()` parses it on
- // most engines — we don't reject these. Calendar validation only kicks
- // in for bare `YYYY-MM-DD` (the canonical form the picker emits).
- expect(parseExceptionExpiry('2026/08/13')).not.toBeNull();
+ it('rejects non-ISO date forms even when `new Date()` would parse them', () => {
+ // Defense in depth — the documented contract is "YYYY-MM-DD" OR strict
+ // ISO 8601. Locale-specific forms (slash-separated, "January 1, 2026")
+ // are parseable by JS Date but outside the contract, so we reject them.
+ expect(() => parseExceptionExpiry('2026/08/13')).toThrow(
+ BadRequestException,
+ );
+ expect(() => parseExceptionExpiry('January 1, 2026')).toThrow(
+ BadRequestException,
+ );
+ expect(() => parseExceptionExpiry('Aug 13, 2026')).toThrow(
+ BadRequestException,
+ );
+ });
+
+ it('accepts strict ISO 8601 timestamps via the fallback', () => {
+ expect(parseExceptionExpiry('2026-08-13T23:59:59Z')).not.toBeNull();
+ expect(parseExceptionExpiry('2026-08-13T23:59:59.999Z')).not.toBeNull();
+ expect(parseExceptionExpiry('2026-08-13T23:59:59+02:00')).not.toBeNull();
});
});
diff --git a/apps/api/src/cloud-security/exception-expiry.utils.ts b/apps/api/src/cloud-security/exception-expiry.utils.ts
index 7dc5d1d928..d3d9334dbb 100644
--- a/apps/api/src/cloud-security/exception-expiry.utils.ts
+++ b/apps/api/src/cloud-security/exception-expiry.utils.ts
@@ -43,6 +43,16 @@ export function parseExceptionExpiry(
return new Date(Date.UTC(y, m - 1, d, 23, 59, 59, 999));
}
+ // Reject anything that isn't strict ISO 8601 — `new Date()` happily parses
+ // locale-specific strings like "January 1, 2026" and "2026/08/13", which
+ // would silently bypass the documented contract.
+ const ISO_8601 =
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/;
+ if (!ISO_8601.test(input)) {
+ throw new BadRequestException(
+ 'expiresAt must be a valid ISO date or YYYY-MM-DD calendar date.',
+ );
+ }
const parsed = new Date(input);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException(
diff --git a/apps/api/src/cloud-security/history.service.ts b/apps/api/src/cloud-security/history.service.ts
index 1a27060c56..10748d5ed4 100644
--- a/apps/api/src/cloud-security/history.service.ts
+++ b/apps/api/src/cloud-security/history.service.ts
@@ -11,56 +11,90 @@ export class CloudHistoryService {
organizationId: string;
connectionId: string;
}) {
- const [resolutions, exceptions, regressions] = await Promise.all([
+ // Cap rows returned to keep payload bounded; pull true totals from
+ // separate count queries so the UI can show "showing 200 of 432"
+ // instead of pretending the truncated set is the full picture.
+ const resolutionsWhere = {
+ organizationId: params.organizationId,
+ connectionId: params.connectionId,
+ };
+ const exceptionsWhere = {
+ organizationId: params.organizationId,
+ connectionId: params.connectionId,
+ revokedAt: null,
+ OR: [
+ { expiresAt: null },
+ { expiresAt: { gt: new Date() } },
+ ] as Array<{ expiresAt: null } | { expiresAt: { gt: Date } }>,
+ };
+ const regressionsWhere = {
+ organizationId: params.organizationId,
+ connectionId: params.connectionId,
+ };
+
+ const [
+ resolutions,
+ resolutionsTotal,
+ exceptions,
+ exceptionsTotal,
+ regressions,
+ regressionsTotal,
+ platformFixes,
+ externalFixes,
+ resourceDeleted,
+ exceptionMarked,
+ ] = await Promise.all([
db.findingResolution.findMany({
- where: {
- organizationId: params.organizationId,
- connectionId: params.connectionId,
- },
+ where: resolutionsWhere,
orderBy: { resolvedAt: 'desc' },
take: 200,
}),
+ db.findingResolution.count({ where: resolutionsWhere }),
db.findingException.findMany({
- where: {
- organizationId: params.organizationId,
- connectionId: params.connectionId,
- revokedAt: null,
- OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
- },
+ where: exceptionsWhere,
orderBy: { markedAt: 'desc' },
take: 100,
}),
+ db.findingException.count({ where: exceptionsWhere }),
db.findingRegression.findMany({
- where: {
- organizationId: params.organizationId,
- connectionId: params.connectionId,
- },
+ where: regressionsWhere,
orderBy: { regressedAt: 'desc' },
take: 100,
}),
+ db.findingRegression.count({ where: regressionsWhere }),
+ db.findingResolution.count({
+ where: { ...resolutionsWhere, resolutionMethod: 'platform_fix' },
+ }),
+ db.findingResolution.count({
+ where: { ...resolutionsWhere, resolutionMethod: 'external_fix' },
+ }),
+ db.findingResolution.count({
+ where: { ...resolutionsWhere, resolutionMethod: 'resource_deleted' },
+ }),
+ db.findingResolution.count({
+ where: { ...resolutionsWhere, resolutionMethod: 'exception_marked' },
+ }),
]);
return {
summary: {
- resolutions: resolutions.length,
- platformFixes: resolutions.filter(
- (r) => r.resolutionMethod === 'platform_fix',
- ).length,
- externalFixes: resolutions.filter(
- (r) => r.resolutionMethod === 'external_fix',
- ).length,
- resourceDeleted: resolutions.filter(
- (r) => r.resolutionMethod === 'resource_deleted',
- ).length,
- exceptionMarked: resolutions.filter(
- (r) => r.resolutionMethod === 'exception_marked',
- ).length,
- activeExceptions: exceptions.length,
- regressions: regressions.length,
+ resolutions: resolutionsTotal,
+ platformFixes,
+ externalFixes,
+ resourceDeleted,
+ exceptionMarked,
+ activeExceptions: exceptionsTotal,
+ regressions: regressionsTotal,
},
resolutions,
exceptions,
regressions,
+ // Lets the UI surface "Showing 200 of 432" when truncation is active.
+ truncated: {
+ resolutions: resolutionsTotal > resolutions.length,
+ exceptions: exceptionsTotal > exceptions.length,
+ regressions: regressionsTotal > regressions.length,
+ },
};
}
}
diff --git a/apps/api/src/cloud-security/reconciliation.service.spec.ts b/apps/api/src/cloud-security/reconciliation.service.spec.ts
index a63c6fab28..75c91616e6 100644
--- a/apps/api/src/cloud-security/reconciliation.service.spec.ts
+++ b/apps/api/src/cloud-security/reconciliation.service.spec.ts
@@ -44,14 +44,17 @@ function makeExceptionsStub(active = false): CloudExceptionService {
const PRIOR_RUN_TIME = new Date('2026-05-01T00:00:00Z');
const CURRENT_RUN_TIME = new Date('2026-05-13T00:00:00Z');
+let resultIdCounter = 0;
function makeResult(opts: {
findingKey: string;
resourceId: string;
passed: boolean;
resourceType?: string;
serviceId?: string;
+ id?: string;
}) {
return {
+ id: opts.id ?? `icrr_${++resultIdCounter}`,
passed: opts.passed,
resourceId: opts.resourceId,
resourceType: opts.resourceType ?? 'AwsIamUser',
diff --git a/apps/api/src/cloud-security/reconciliation.service.ts b/apps/api/src/cloud-security/reconciliation.service.ts
index bf3d81d7dc..342ef86532 100644
--- a/apps/api/src/cloud-security/reconciliation.service.ts
+++ b/apps/api/src/cloud-security/reconciliation.service.ts
@@ -4,6 +4,7 @@ import { normalizeCheckId } from './check-definition.utils';
import { CloudExceptionService } from './exception.service';
interface NormalizedResult {
+ id: string;
findingKey: string;
resourceId: string;
resourceType: string | null;
@@ -49,6 +50,7 @@ export class CloudReconciliationService {
connection: { select: { organizationId: true } },
results: {
select: {
+ id: true,
passed: true,
resourceId: true,
resourceType: true,
@@ -95,6 +97,7 @@ export class CloudReconciliationService {
completedAt: true,
results: {
select: {
+ id: true,
passed: true,
resourceId: true,
resourceType: true,
@@ -143,6 +146,10 @@ export class CloudReconciliationService {
checkKey,
resourceId: prior.resourceId,
currentExists: Boolean(current),
+ // Scope the platform_fix lookup to THIS specific check — without it,
+ // an unrelated fix on the same resource gets mis-attributed as
+ // having resolved this check.
+ priorCheckResultId: prior.id,
});
const daysOpen =
@@ -225,6 +232,7 @@ export class CloudReconciliationService {
checkKey: string;
resourceId: string;
currentExists: boolean;
+ priorCheckResultId: string;
}): Promise<{
method: FindingResolutionMethod;
resolvedById?: string;
@@ -240,11 +248,15 @@ export class CloudReconciliationService {
if (exceptionActive) return { method: 'exception_marked' };
// 2) Did our Fix button apply a successful remediation between scans?
+ // Scope by `checkResultId` so a fix for check A on resource X doesn't
+ // get attributed to check B on the same resource. RemediationAction
+ // is created with the prior run's checkResultId, so matching against
+ // `prior.id` ties the fix to THIS check, not any check on the resource.
if (input.priorRunCompletedAt) {
const fix = await db.remediationAction.findFirst({
where: {
connectionId: input.connectionId,
- resourceId: input.resourceId,
+ checkResultId: input.priorCheckResultId,
status: 'success',
createdAt: {
gte: input.priorRunCompletedAt,
@@ -273,6 +285,7 @@ export class CloudReconciliationService {
function indexResults(
results: Array<{
+ id: string;
passed: boolean;
resourceId: string | null;
resourceType: string | null;
@@ -298,6 +311,7 @@ function indexResults(
const checkKey = normalizeCheckId(findingKey, r.resourceId);
const compositeKey = `${checkKey}::${r.resourceId}`;
map.set(compositeKey, {
+ id: r.id,
findingKey,
resourceId: r.resourceId,
resourceType: r.resourceType,
diff --git a/apps/api/src/comments/comments-permission.guard.ts b/apps/api/src/comments/comments-permission.guard.ts
index 96da4bb6e6..3981cdf6f5 100644
--- a/apps/api/src/comments/comments-permission.guard.ts
+++ b/apps/api/src/comments/comments-permission.guard.ts
@@ -112,14 +112,25 @@ export class CommentsPermissionGuard implements CanActivate {
}
const permissions: Record = { [resource]: [action] };
- const allowed = await this.checkPermission(request, permissions);
- if (!allowed) {
- this.logger.warn(
- `[CommentsPermissionGuard] Denied ${request.method} ${request.url}. Required: ${requiredScope}`,
+ // Mirror standard PermissionGuard's try/catch — without it, network or
+ // auth-service errors surface as 500s, potentially leaking internals.
+ try {
+ const allowed = await this.checkPermission(request, permissions);
+ if (!allowed) {
+ this.logger.warn(
+ `[CommentsPermissionGuard] Denied ${request.method} ${request.url}. Required: ${requiredScope}`,
+ );
+ throw new ForbiddenException('Access denied');
+ }
+ return true;
+ } catch (error) {
+ if (error instanceof ForbiddenException) throw error;
+ this.logger.error(
+ `[CommentsPermissionGuard] Error checking permissions for ${request.method} ${request.url}:`,
+ error,
);
- throw new ForbiddenException('Access denied');
+ throw new ForbiddenException('Unable to verify permissions');
}
- return true;
}
private resolveEntityResource(
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
index c1fb3e2c05..24af1346aa 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
@@ -726,7 +726,10 @@ export function CloudTestsSection({
{regularGroups.map((group) => {
const isGroupExpanded = expandedGroups.has(group.serviceId);
- const hasFailures = group.findings.length > 0;
+ // "All passed" should render for groups with only passing checks
+ // — `group.findings` is the merged failed+passed set, so we'd
+ // mis-read empty-of-failures groups as having failures otherwise.
+ const hasFailures = group.failed > 0;
return (
@@ -843,7 +846,10 @@ export function CloudTestsSection({
{baselineGroups.map((group) => {
const isGroupExpanded = expandedGroups.has(group.serviceId);
- const hasFailures = group.findings.length > 0;
+ // "All passed" should render for groups with only passing checks
+ // — `group.findings` is the merged failed+passed set, so we'd
+ // mis-read empty-of-failures groups as having failures otherwise.
+ const hasFailures = group.failed > 0;
return (
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.tsx
index 2cc2846d22..a556a25e2e 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/HistoryTab.tsx
@@ -52,6 +52,13 @@ interface HistoryPayload {
resolutions: ResolutionRow[];
exceptions: ExceptionRow[];
regressions: RegressionRow[];
+ // Server may cap rows at 200/100; this signals the UI to show
+ // "showing N of M" when the response is partial.
+ truncated?: {
+ resolutions: boolean;
+ exceptions: boolean;
+ regressions: boolean;
+ };
}
const RESOLUTION_METHOD_LABEL: Record<
@@ -135,13 +142,21 @@ export function HistoryTab({ connectionId }: HistoryTabProps) {
);
}
+ const truncated = payload.truncated;
+ const sectionCount = (total: number, shown: number, isTruncated: boolean) =>
+ isTruncated ? `${shown} of ${total}` : `${total}`;
+
return (
{payload.resolutions.length > 0 && (
{payload.resolutions.map((row) => (
@@ -152,7 +167,11 @@ export function HistoryTab({ connectionId }: HistoryTabProps) {
{payload.exceptions.length > 0 && (
{payload.exceptions.map((row) => (
@@ -168,7 +187,11 @@ export function HistoryTab({ connectionId }: HistoryTabProps) {
{payload.regressions.length > 0 && (
{payload.regressions.map((row) => (
@@ -213,7 +236,7 @@ function Section({
}: {
title: string;
subtitle: string;
- count: number;
+ count: number | string;
children: React.ReactNode;
}) {
return (
diff --git a/packages/db/prisma/migrations/20260515214156_finding_regression_resolution_fk/migration.sql b/packages/db/prisma/migrations/20260515214156_finding_regression_resolution_fk/migration.sql
new file mode 100644
index 0000000000..bdb8e35c68
--- /dev/null
+++ b/packages/db/prisma/migrations/20260515214156_finding_regression_resolution_fk/migration.sql
@@ -0,0 +1,2 @@
+-- AddForeignKey
+ALTER TABLE "FindingRegression" ADD CONSTRAINT "FindingRegression_previousResolutionId_fkey" FOREIGN KEY ("previousResolutionId") REFERENCES "FindingResolution"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema/finding-history.prisma b/packages/db/prisma/schema/finding-history.prisma
index d92927ed75..a33d7acf53 100644
--- a/packages/db/prisma/schema/finding-history.prisma
+++ b/packages/db/prisma/schema/finding-history.prisma
@@ -79,6 +79,9 @@ model FindingResolution {
/// How long the finding was open before being resolved.
daysOpen Int?
+ /// Back-relation: regressions that reference this as the prior fix.
+ regressions FindingRegression[]
+
@@index([organizationId])
@@index([connectionId])
@@index([resolvedAt])
@@ -112,7 +115,10 @@ model FindingRegression {
previouslyResolvedAt DateTime
/// Optional FK to the FindingResolution that introduced the prior fix.
+ /// `onDelete: SetNull` so deleting a resolution leaves the regression
+ /// intact but clears the dangling reference.
previousResolutionId String?
+ previousResolution FindingResolution? @relation(fields: [previousResolutionId], references: [id], onDelete: SetNull)
regressedAt DateTime
detectedInRunId String
From 6988e37e7ad65ca6b662782cc4a7631a40995c9e Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Fri, 15 May 2026 18:15:38 -0400
Subject: [PATCH 13/15] fix(background-check): persist exemption reason +
justification on member
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"Confirm exemption" was failing for every customer with a "Failed to
confirm exemption" toast. Root cause:
apps/api/src/main.ts sets `forbidNonWhitelisted: true` on the global
ValidationPipe. The V1 frontend sends `backgroundCheckExempt` together
with `backgroundCheckExemptReason` + `backgroundCheckExemptJustification`,
but the latter two were never declared on UpdatePeopleDto — so the
PATCH /v1/people/:id request was rejected with 400 before the service
ever ran.
This fix:
1. Adds nullable columns `backgroundCheckExemptReason` (varchar) and
`backgroundCheckExemptJustification` (text) to the Member model
(named prisma migrate dev migration — not hand-written SQL).
2. Whitelists both fields on UpdatePeopleDto as optional strings with
sensible length caps (100 / 2000 chars).
3. Persists both fields on member.update; clears them to null when
backgroundCheckExempt is set to false so a future re-exemption starts
from a clean state. Audit log retains the prior values from the
original exempt-true request via AuditLogInterceptor.
4. Adds 4 unit tests in member-queries.spec.ts covering: persist on
exempt=true, clear on exempt=false, clear overrides incoming stale
reason on exempt=false, untouched when patch omits exempt.
Pre-existing typecheck failures in unrelated specs and a pre-existing
people.service.spec runtime error were verified to also occur on the
baseline (origin/main) before this change.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/api/src/people/dto/update-people.dto.ts | 23 +++++
.../src/people/utils/member-queries.spec.ts | 90 +++++++++++++++++++
apps/api/src/people/utils/member-queries.ts | 10 +++
.../migration.sql | 3 +
packages/db/prisma/schema/auth.prisma | 2 +
5 files changed, 128 insertions(+)
create mode 100644 apps/api/src/people/utils/member-queries.spec.ts
create mode 100644 packages/db/prisma/migrations/20260515221108_add_background_check_exemption_reason_justification/migration.sql
diff --git a/apps/api/src/people/dto/update-people.dto.ts b/apps/api/src/people/dto/update-people.dto.ts
index 951118b401..8987919ea8 100644
--- a/apps/api/src/people/dto/update-people.dto.ts
+++ b/apps/api/src/people/dto/update-people.dto.ts
@@ -5,6 +5,7 @@ import {
IsString,
IsEmail,
IsDateString,
+ MaxLength,
} from 'class-validator';
import { CreatePeopleDto } from './create-people.dto';
@@ -54,4 +55,26 @@ export class UpdatePeopleDto extends PartialType(CreatePeopleDto) {
@IsOptional()
@IsBoolean()
backgroundCheckExempt?: boolean;
+
+ @ApiProperty({
+ description:
+ 'Reason code for the exemption (e.g. "contractor_with_vendor_check", "other"). Persisted alongside backgroundCheckExempt and cleared when the member becomes non-exempt.',
+ example: 'other',
+ required: false,
+ })
+ @IsOptional()
+ @IsString()
+ @MaxLength(100)
+ backgroundCheckExemptReason?: string;
+
+ @ApiProperty({
+ description:
+ 'Free-text justification for the exemption, attached to the audit log. Cleared when the member becomes non-exempt.',
+ example: 'Contractor with existing background check on file from staffing agency.',
+ required: false,
+ })
+ @IsOptional()
+ @IsString()
+ @MaxLength(2000)
+ backgroundCheckExemptJustification?: string;
}
diff --git a/apps/api/src/people/utils/member-queries.spec.ts b/apps/api/src/people/utils/member-queries.spec.ts
new file mode 100644
index 0000000000..90f53ba7a3
--- /dev/null
+++ b/apps/api/src/people/utils/member-queries.spec.ts
@@ -0,0 +1,90 @@
+import { MemberQueries } from './member-queries';
+
+jest.mock('@db', () => ({
+ db: {
+ member: {
+ update: jest.fn(),
+ },
+ user: {
+ update: jest.fn(),
+ },
+ },
+}));
+
+import { db } from '@db';
+
+const mockedDb = db as jest.Mocked;
+
+describe('MemberQueries.updateMember — background-check exemption fields', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (mockedDb.member.update as jest.Mock).mockResolvedValue({ id: 'mem_1' });
+ });
+
+ it('persists reason and justification when backgroundCheckExempt is true', async () => {
+ await MemberQueries.updateMember('mem_1', 'org_1', {
+ backgroundCheckExempt: true,
+ backgroundCheckExemptReason: 'other',
+ backgroundCheckExemptJustification: 'Founder',
+ });
+
+ expect(mockedDb.member.update).toHaveBeenCalledTimes(1);
+ expect(mockedDb.member.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 'mem_1', organizationId: 'org_1' },
+ data: expect.objectContaining({
+ backgroundCheckExempt: true,
+ backgroundCheckExemptReason: 'other',
+ backgroundCheckExemptJustification: 'Founder',
+ }),
+ }),
+ );
+ });
+
+ it('clears reason and justification when backgroundCheckExempt is set to false', async () => {
+ await MemberQueries.updateMember('mem_1', 'org_1', {
+ backgroundCheckExempt: false,
+ });
+
+ expect(mockedDb.member.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ backgroundCheckExempt: false,
+ backgroundCheckExemptReason: null,
+ backgroundCheckExemptJustification: null,
+ }),
+ }),
+ );
+ });
+
+ it('overrides incoming reason/justification when un-exempting', async () => {
+ // Defensive: if a client sends contradictory data, false wins —
+ // an un-exempt request must not retain stale reason text.
+ await MemberQueries.updateMember('mem_1', 'org_1', {
+ backgroundCheckExempt: false,
+ backgroundCheckExemptReason: 'stale_reason',
+ backgroundCheckExemptJustification: 'stale text',
+ });
+
+ expect(mockedDb.member.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ backgroundCheckExempt: false,
+ backgroundCheckExemptReason: null,
+ backgroundCheckExemptJustification: null,
+ }),
+ }),
+ );
+ });
+
+ it('does not touch reason or justification when the patch omits backgroundCheckExempt', async () => {
+ await MemberQueries.updateMember('mem_1', 'org_1', {
+ jobTitle: 'Engineer',
+ });
+
+ expect(mockedDb.member.update).toHaveBeenCalledTimes(1);
+ const call = (mockedDb.member.update as jest.Mock).mock.calls[0][0];
+ expect(call.data).not.toHaveProperty('backgroundCheckExemptReason');
+ expect(call.data).not.toHaveProperty('backgroundCheckExemptJustification');
+ });
+});
diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts
index 05ced2524c..da7ecc92fa 100644
--- a/apps/api/src/people/utils/member-queries.ts
+++ b/apps/api/src/people/utils/member-queries.ts
@@ -21,6 +21,8 @@ export class MemberQueries {
isActive: true,
deactivated: true,
backgroundCheckExempt: true,
+ backgroundCheckExemptReason: true,
+ backgroundCheckExemptJustification: true,
fleetDmLabelId: true,
user: {
select: {
@@ -128,6 +130,14 @@ export class MemberQueries {
updatePayload.fleetDmLabelId = null;
}
+ // Un-exempting clears reason + justification so a future re-exemption
+ // starts from a clean state. The audit log retains the prior values
+ // from the original exempt-true request.
+ if (updatePayload.backgroundCheckExempt === false) {
+ updatePayload.backgroundCheckExemptReason = null;
+ updatePayload.backgroundCheckExemptJustification = null;
+ }
+
const hasUserUpdates = name !== undefined || email !== undefined;
const hasMemberUpdates = Object.keys(updatePayload).length > 0;
diff --git a/packages/db/prisma/migrations/20260515221108_add_background_check_exemption_reason_justification/migration.sql b/packages/db/prisma/migrations/20260515221108_add_background_check_exemption_reason_justification/migration.sql
new file mode 100644
index 0000000000..a1f9e91e38
--- /dev/null
+++ b/packages/db/prisma/migrations/20260515221108_add_background_check_exemption_reason_justification/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "Member" ADD COLUMN "backgroundCheckExemptJustification" TEXT,
+ADD COLUMN "backgroundCheckExemptReason" TEXT;
diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma
index 801083b868..956015bd93 100644
--- a/packages/db/prisma/schema/auth.prisma
+++ b/packages/db/prisma/schema/auth.prisma
@@ -119,6 +119,8 @@ model Member {
isActive Boolean @default(true)
deactivated Boolean @default(false)
backgroundCheckExempt Boolean @default(false)
+ backgroundCheckExemptReason String?
+ backgroundCheckExemptJustification String? @db.Text
externalUserId String?
externalUserSource String?
employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[]
From ada894dbbfbbadf5fb57d96a804d03ab695a302d Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Fri, 15 May 2026 18:28:50 -0400
Subject: [PATCH 14/15] fix(app): add new exemption fields to createMockMember
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Prisma schema change in this branch added two non-optional (nullable)
columns to Member — backgroundCheckExemptReason + backgroundCheckExemptJustification.
The strict-typed test mock createMockMember returns Member, so the mock
had to include both keys or the spread of overrides would surface them
as undefined and fail the Vercel build:
Type 'undefined' is not assignable to type 'string | null'.
Initializing both to null in the default mock matches the DB default.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/app/src/test-utils/mocks/auth.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/apps/app/src/test-utils/mocks/auth.ts b/apps/app/src/test-utils/mocks/auth.ts
index 135b192afc..0a3a89c5d4 100644
--- a/apps/app/src/test-utils/mocks/auth.ts
+++ b/apps/app/src/test-utils/mocks/auth.ts
@@ -83,6 +83,8 @@ export const createMockMember = (overrides?: Partial): Member => ({
externalUserId: null,
externalUserSource: null,
backgroundCheckExempt: false,
+ backgroundCheckExemptReason: null,
+ backgroundCheckExemptJustification: null,
...overrides,
});
From 33042e79918196f8c4e585844649622329e6f041 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Fri, 15 May 2026 18:49:39 -0400
Subject: [PATCH 15/15] fix(cloud-tests): address cubic findings on production
deploy PR
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four real issues caught by cubic on the main→release deploy review.
The two Jest-hoisting findings (exception/reconciliation specs) were
skipped — that rule is babel-jest only, project uses ts-jest, all
tests pass in CI.
P1 — "Fix All" could target passing findings
Service groups store the merged failed+passed set, and
`canFixFinding` returns a key for any finding with a `findingKey`
regardless of status. The batch dialog was happily including
already-passing checks in the remediation target list. Now filter
by `status === 'failed'` before consulting canFixFinding.
P2 — exception-expiry accepted timezone-less timestamps
ISO 8601 regex made the timezone offset optional, so
`2026-08-13T23:59:59` passed validation but `new Date()` parsed
it in server-local time — same input, different expiry on
UTC vs Pacific hosts. Made the offset required; updated the
spec to assert both acceptance (with offset) and rejection
(without offset).
P2 — dead `!prior.passed === false` in reconciliation
The line evaluated identically to the very next `if (prior.passed)
continue`. Removed; behavior unchanged, clarity restored.
P2 — ISO control-number regex was case-sensitive
`/\bA\.\d+\.\d+(\.\d+)?\b/` had no /i flag, so lowercase
variants like "a.5.1.2" would slip past the forbidden-content
guard. Added /i and a regression test.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../ai-description.prompt.spec.ts | 17 +++++++++++++++++
.../cloud-security/ai-description.prompt.ts | 5 +++--
.../exception-expiry.utils.spec.ts | 18 +++++++++++++++++-
.../cloud-security/exception-expiry.utils.ts | 8 +++++---
.../cloud-security/reconciliation.service.ts | 3 +--
.../components/CloudTestsSection.tsx | 5 +++++
6 files changed, 48 insertions(+), 8 deletions(-)
diff --git a/apps/api/src/cloud-security/ai-description.prompt.spec.ts b/apps/api/src/cloud-security/ai-description.prompt.spec.ts
index c5872ec485..9684acdac8 100644
--- a/apps/api/src/cloud-security/ai-description.prompt.spec.ts
+++ b/apps/api/src/cloud-security/ai-description.prompt.spec.ts
@@ -69,6 +69,23 @@ describe('ai-description.prompt', () => {
).toMatchObject({ field: 'whyItMatters' });
});
+ it('flags ISO 27001 control numbers in lowercase variants (a.5.1)', () => {
+ // The regex must be case-insensitive — auditors won't accept the
+ // model getting around the gate by lowercasing a citation.
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ whyItMatters: 'Maps to a.9.4.3.',
+ }),
+ ).toMatchObject({ field: 'whyItMatters' });
+ expect(
+ findForbiddenContent({
+ ...baseline,
+ description: 'Control a.5.1.2 enforces this.',
+ }),
+ ).toMatchObject({ field: 'description' });
+ });
+
it('flags HIPAA / NIST framework citations', () => {
expect(
findForbiddenContent({
diff --git a/apps/api/src/cloud-security/ai-description.prompt.ts b/apps/api/src/cloud-security/ai-description.prompt.ts
index d07d424081..0e5424d75c 100644
--- a/apps/api/src/cloud-security/ai-description.prompt.ts
+++ b/apps/api/src/cloud-security/ai-description.prompt.ts
@@ -104,9 +104,10 @@ const FORBIDDEN_PATTERNS: readonly RegExp[] = [
// prefixes — catches "CIS 1.8", "PCI 8.2.3", "NIST AC-2",
// "HIPAA 164.312" even without the full framework name re-mentioned.
/\b(CIS|PCI|NIST|HIPAA|HITRUST|FedRAMP) ?[A-Z]*[- ]?\d+(\.\d+){0,3}\b/i,
- // SOC 2 / ISO control-number formats
+ // SOC 2 / ISO control-number formats — case-insensitive so lowercase
+ // variants (e.g. "a.5.1.2") are blocked too.
/\bCC\d+\.\d+\b/i,
- /\bA\.\d+\.\d+(\.\d+)?\b/,
+ /\bA\.\d+\.\d+(\.\d+)?\b/i,
// URLs
/https?:\/\//i,
/www\./i,
diff --git a/apps/api/src/cloud-security/exception-expiry.utils.spec.ts b/apps/api/src/cloud-security/exception-expiry.utils.spec.ts
index 0de0204322..5d3bdfaa04 100644
--- a/apps/api/src/cloud-security/exception-expiry.utils.spec.ts
+++ b/apps/api/src/cloud-security/exception-expiry.utils.spec.ts
@@ -75,9 +75,25 @@ describe('parseExceptionExpiry', () => {
);
});
- it('accepts strict ISO 8601 timestamps via the fallback', () => {
+ it('accepts strict ISO 8601 timestamps with an explicit timezone offset', () => {
expect(parseExceptionExpiry('2026-08-13T23:59:59Z')).not.toBeNull();
expect(parseExceptionExpiry('2026-08-13T23:59:59.999Z')).not.toBeNull();
expect(parseExceptionExpiry('2026-08-13T23:59:59+02:00')).not.toBeNull();
+ expect(parseExceptionExpiry('2026-08-13T23:59:59-07:00')).not.toBeNull();
+ });
+
+ it('rejects timestamps without an explicit timezone offset', () => {
+ // No `Z`, no `+02:00` etc. — `new Date()` would parse these in server
+ // local time, giving inconsistent expiries across environments. Force
+ // the caller to commit to a timezone explicitly.
+ expect(() => parseExceptionExpiry('2026-08-13T23:59:59')).toThrow(
+ BadRequestException,
+ );
+ expect(() => parseExceptionExpiry('2026-08-13T23:59')).toThrow(
+ BadRequestException,
+ );
+ expect(() =>
+ parseExceptionExpiry('2026-08-13T23:59:59.999'),
+ ).toThrow(BadRequestException);
});
});
diff --git a/apps/api/src/cloud-security/exception-expiry.utils.ts b/apps/api/src/cloud-security/exception-expiry.utils.ts
index d3d9334dbb..52e8945923 100644
--- a/apps/api/src/cloud-security/exception-expiry.utils.ts
+++ b/apps/api/src/cloud-security/exception-expiry.utils.ts
@@ -45,12 +45,14 @@ export function parseExceptionExpiry(
// Reject anything that isn't strict ISO 8601 — `new Date()` happily parses
// locale-specific strings like "January 1, 2026" and "2026/08/13", which
- // would silently bypass the documented contract.
+ // would silently bypass the documented contract. The timezone offset is
+ // REQUIRED — without it, `new Date("2026-08-13T23:59:59")` is parsed in
+ // server-local time, giving different expiries on UTC vs Pacific hosts.
const ISO_8601 =
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/;
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
if (!ISO_8601.test(input)) {
throw new BadRequestException(
- 'expiresAt must be a valid ISO date or YYYY-MM-DD calendar date.',
+ 'expiresAt must be a YYYY-MM-DD calendar date or an ISO 8601 timestamp with an explicit timezone offset (e.g. "2026-08-13T23:59:59Z").',
);
}
const parsed = new Date(input);
diff --git a/apps/api/src/cloud-security/reconciliation.service.ts b/apps/api/src/cloud-security/reconciliation.service.ts
index 342ef86532..70c0e8370c 100644
--- a/apps/api/src/cloud-security/reconciliation.service.ts
+++ b/apps/api/src/cloud-security/reconciliation.service.ts
@@ -124,8 +124,7 @@ export class CloudReconciliationService {
// Resolutions: prior failed → current absent or passed.
for (const [key, prior] of priorMap.entries()) {
- if (!prior.passed === false) continue; // only interested in prior failures
- if (prior.passed) continue;
+ if (prior.passed) continue; // only interested in prior failures
const current = currentMap.get(key);
if (current && !current.passed) continue; // still failing
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
index 24af1346aa..294edc8c14 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx
@@ -455,7 +455,12 @@ export function CloudTestsSection({
if (!batchServiceId) return null;
const group = serviceGroups.find((g) => g.serviceId === batchServiceId);
if (!group) return null;
+ // `group.findings` is the merged failed+passed set — restrict the batch
+ // to failing findings only. `canFixFinding` doesn't gate on status, so
+ // without this filter a passing check could be targeted by "Fix All".
const fixable = group.findings.filter((f) => {
+ const isFailed = f.status === 'failed' || f.status === 'FAILED';
+ if (!isFailed) return false;
const match = canFixFinding(f);
return match?.key && match.enabled;
});