From b5dd11735e5fa3a0804a48246904e49861d47569 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 16 Jul 2026 16:03:31 -0400 Subject: [PATCH 1/3] feat(app): mark evidence check resources out of scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers failing an evidence item because of an intentional finding (e.g. an S3 bucket that must stay public for a static website redirect) had no way to accept it — the task stayed failed with no route to resolve it. The exception mechanism already existed end to end: FindingException rows keyed (connection, check, resource) are honored by both task run paths and the task run display. What was missing was a way to create/revoke them from the evidence item view. - app: failing check results on a task now offer "Mark out of scope" (captures a documented reason via the shared MarkExceptionModal, moved to components/integrations with copy overrides); excepted results show the reason and offer "Move back in scope" (revoke) - app: after a scope change the check re-runs automatically so the task status updates immediately - api: task check runs response now carries exceptionId + exceptionReason on excepted results (ActiveExceptionSet optionally carries metadata) - actions gated by integration:update, matching the endpoints --- .../cloud-security/finding-exceptions.spec.ts | 29 ++- .../src/cloud-security/finding-exceptions.ts | 55 ++++- .../task-integrations.controller.spec.ts | 7 + .../task-integrations.controller.ts | 42 ++-- .../components/CloudTestsSection.tsx | 2 +- .../components/TaskIntegrationChecks.tsx | 125 ++++++++++++ .../components/check-run-history.test.tsx | 193 ++++++++++++++++++ .../[taskId]/components/check-run-history.tsx | 133 ++++++++++-- .../[taskId]/hooks/useIntegrationChecks.ts | 26 +++ .../integrations}/MarkExceptionModal.test.tsx | 36 +++- .../integrations}/MarkExceptionModal.tsx | 32 ++- 11 files changed, 624 insertions(+), 56 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.test.tsx rename apps/app/src/{app/(app)/[orgId]/cloud-tests/components => components/integrations}/MarkExceptionModal.test.tsx (76%) rename apps/app/src/{app/(app)/[orgId]/cloud-tests/components => components/integrations}/MarkExceptionModal.tsx (85%) diff --git a/apps/api/src/cloud-security/finding-exceptions.spec.ts b/apps/api/src/cloud-security/finding-exceptions.spec.ts index cd0f9f4eb7..69585571dd 100644 --- a/apps/api/src/cloud-security/finding-exceptions.spec.ts +++ b/apps/api/src/cloud-security/finding-exceptions.spec.ts @@ -44,23 +44,50 @@ describe('ActiveExceptionSet', () => { expect(set.has('c1', 'check-a', resourceId)).toBe(true); expect(set.exceptedResourceIds('c1', 'check-a')).toEqual([resourceId]); }); + + it('exposes exception metadata via infoFor when built with it', () => { + const key = ActiveExceptionSet.key('c1', 'check-a', 'r1'); + const set = new ActiveExceptionSet( + [key], + new Map([[key, { id: 'fex_1', reason: 'Intentional public bucket' }]]), + ); + expect(set.infoFor('c1', 'check-a', 'r1')).toEqual({ + id: 'fex_1', + reason: 'Intentional public bucket', + }); + expect(set.infoFor('c1', 'check-a', 'r2')).toBeNull(); + }); + + it('infoFor returns null on a set built from bare keys (has() still true)', () => { + const set = new ActiveExceptionSet([ + ActiveExceptionSet.key('c1', 'check-a', 'r1'), + ]); + expect(set.has('c1', 'check-a', 'r1')).toBe(true); + expect(set.infoFor('c1', 'check-a', 'r1')).toBeNull(); + }); }); describe('loadActiveExceptionSet', () => { beforeEach(() => jest.clearAllMocks()); - it('builds the set from active exceptions', async () => { + it('builds the set from active exceptions, carrying id + reason', async () => { findMany.mockResolvedValue([ { + id: 'fex_1', connectionId: 'c1', checkId: 'aws-s3-public-access', resourceId: 'bucket-1', + reason: 'Bucket only hosts a static website redirect.', }, ] as never); const set = await loadActiveExceptionSet('org_1'); expect(set.has('c1', 'aws-s3-public-access', 'bucket-1')).toBe(true); + expect(set.infoFor('c1', 'aws-s3-public-access', 'bucket-1')).toEqual({ + id: 'fex_1', + reason: 'Bucket only hosts a static website redirect.', + }); // Query only active exceptions (not revoked, not expired). expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/api/src/cloud-security/finding-exceptions.ts b/apps/api/src/cloud-security/finding-exceptions.ts index f18ea9c31a..013bae3d53 100644 --- a/apps/api/src/cloud-security/finding-exceptions.ts +++ b/apps/api/src/cloud-security/finding-exceptions.ts @@ -1,5 +1,15 @@ import { db } from '@db'; +/** + * Metadata about one active exception. Display surfaces use it to show the + * documented reason next to a suppressed finding and to offer revoke (which + * needs the row id) without a second query. + */ +export interface ActiveExceptionInfo { + id: string; + reason: string; +} + /** * The set of findings — keyed by (connectionId, checkId, resourceId) — that * currently have an ACTIVE exception (not revoked, not expired) for an org. @@ -24,9 +34,16 @@ export class ActiveExceptionSet { * every result row into memory. */ private readonly resourceIdsByConnCheck: Map>; + /** Exception metadata per canonical key. Optional — evaluation-only callers + * build the set from bare keys and never ask for it. */ + private readonly infoByKey: ReadonlyMap; - constructor(keys: Iterable) { + constructor( + keys: Iterable, + infoByKey?: ReadonlyMap, + ) { this.keys = new Set(keys); + this.infoByKey = infoByKey ?? new Map(); this.resourceIdsByConnCheck = new Map(); for (const key of this.keys) { // key = `${connectionId}::${checkId}::${resourceId}`. A resourceId can @@ -75,6 +92,23 @@ export class ActiveExceptionSet { ); return ids ? Array.from(ids) : []; } + + /** + * Metadata (row id + reason) of the active exception covering this finding, + * or null when none. Null also for sets built without metadata (bare keys) — + * `has()` stays the authority on whether a finding is excepted. + */ + infoFor( + connectionId: string, + checkId: string, + resourceId: string, + ): ActiveExceptionInfo | null { + return ( + this.infoByKey.get( + ActiveExceptionSet.key(connectionId, checkId, resourceId), + ) ?? null + ); + } } /** @@ -94,13 +128,22 @@ export async function loadActiveExceptionSet( revokedAt: null, OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], }, - select: { connectionId: true, checkId: true, resourceId: true }, + select: { + id: true, + connectionId: true, + checkId: true, + resourceId: true, + reason: true, + }, }); - return new ActiveExceptionSet( - active.map((e) => + const infoByKey = new Map(); + for (const e of active) { + infoByKey.set( ActiveExceptionSet.key(e.connectionId, e.checkId, e.resourceId), - ), - ); + { id: e.id, reason: e.reason }, + ); + } + return new ActiveExceptionSet(infoByKey.keys(), infoByKey); } catch { return new ActiveExceptionSet([]); } diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts index 540aa9085a..089f881575 100644 --- a/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts @@ -811,9 +811,11 @@ describe('TaskIntegrationsController', () => { ); mockFindingExceptionFindMany.mockResolvedValue([ { + id: 'fex_1', connectionId: 'conn_1', checkId: 'aws-s3-public-access', resourceId: 'reports-bucket', + reason: 'Bucket intentionally public: static website redirect only.', }, ]); // The excepted-failure count is now computed via a targeted query (the @@ -827,6 +829,11 @@ describe('TaskIntegrationsController', () => { expect(runs[0].exceptedCount).toBe(1); expect(runs[0].status).toBe('success'); expect(runs[0].results[0].excepted).toBe(true); + // Excepted rows carry the exception's id (for revoke) and its reason. + expect(runs[0].results[0].exceptionId).toBe('fex_1'); + expect(runs[0].results[0].exceptionReason).toBe( + 'Bucket intentionally public: static website redirect only.', + ); // Exact count is computed via the targeted query, scoped to this run's // excepted resourceIds (not by loading + filtering every result). expect(mockCheckRunRepository.countExceptedFailures).toHaveBeenCalledWith( diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts index b41a12db51..9e57913819 100644 --- a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts @@ -857,23 +857,33 @@ export class TaskIntegrationsController { // Tag each sampled result with whether it's excepted (for display); // authoritative totals come from the run's summary columns + - // exceptedCount above. Cap evidence so one oversized blob can't bloat - // the payload that the browser must parse + render. - const sample = run.results.map((r) => ({ - id: r.id, - passed: r.passed, - resourceType: r.resourceType, - resourceId: r.resourceId, - title: r.title, - description: r.description, - severity: r.severity, - remediation: r.remediation, - evidence: r.evidence, - collectedAt: r.collectedAt, - excepted: + // exceptedCount above. Excepted rows also carry the exception's id + // (so the UI can offer revoke) and its documented reason. Cap evidence + // so one oversized blob can't bloat the payload that the browser must + // parse + render. + const sample = run.results.map((r) => { + const excepted = !r.passed && - exceptions.has(run.connectionId, run.checkId, r.resourceId), - })); + exceptions.has(run.connectionId, run.checkId, r.resourceId); + const exceptionInfo = excepted + ? exceptions.infoFor(run.connectionId, run.checkId, r.resourceId) + : null; + return { + id: r.id, + passed: r.passed, + resourceType: r.resourceType, + resourceId: r.resourceId, + title: r.title, + description: r.description, + severity: r.severity, + remediation: r.remediation, + evidence: r.evidence, + collectedAt: r.collectedAt, + excepted, + exceptionId: exceptionInfo?.id, + exceptionReason: exceptionInfo?.reason, + }; + }); const results = capResultsForList(sample).map((r) => ({ ...r, evidence: capEvidence(r.evidence), 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 1d1b4cc9d8..a7e41da2af 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 @@ -49,7 +49,7 @@ import { CheckGroupBlock } from './CheckGroupBlock'; import { buildCheckGroups } from './check-groups'; import { filterFindingsByConnection } from './finding-filters'; import { EvidenceJsonViewer } from './EvidenceJsonViewer'; -import { MarkExceptionModal } from './MarkExceptionModal'; +import { MarkExceptionModal } from '@/components/integrations/MarkExceptionModal'; import { RemediationSection } from './RemediationSection'; interface RemediationCapabilities { diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx index 21539b9968..773781e074 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx @@ -2,7 +2,9 @@ import { ConnectIntegrationDialog } from '@/components/integrations/ConnectIntegrationDialog'; import { ManageIntegrationDialog } from '@/components/integrations/ManageIntegrationDialog'; +import { MarkExceptionModal } from '@/components/integrations/MarkExceptionModal'; import { SchedulePicker } from '@/components/schedule-picker'; +import { usePermissions } from '@/hooks/use-permissions'; import { downloadAutomationPDF } from '@/lib/evidence-download'; import { cn } from '@/lib/utils'; import { useActiveOrganization } from '@/utils/auth-client'; @@ -46,6 +48,7 @@ import { toast } from 'sonner'; import type { StoredCheckRun, TaskIntegrationCheck } from '../hooks/useIntegrationChecks'; import { useIntegrationChecks } from '../hooks/useIntegrationChecks'; import { summarizeLatestPerAccount } from './check-run-grouping'; +import type { RunExceptionActions, RunFindingActionTarget } from './check-run-history'; import { AccountRunGroups } from './check-run-history'; interface TaskIntegrationChecksProps { @@ -83,10 +86,14 @@ export function TaskIntegrationChecks({ isLoading: loading, error: hookError, mutateChecks, + mutateRuns, runCheck, + revokeException, disconnectCheckFromTask, reconnectCheckToTask, } = useIntegrationChecks({ taskId, orgId }); + const { hasPermission } = usePermissions(); + const canManageExceptions = hasPermission('integration', 'update'); const [runningCheck, setRunningCheck] = useState(null); const [togglingCheck, setTogglingCheck] = useState(null); @@ -99,6 +106,13 @@ export function TaskIntegrationChecks({ integrationName: string; } | null>(null); const [disconnectError, setDisconnectError] = useState(null); + // Failing resource being marked out of scope (drives the reason modal). + const [outOfScopeTarget, setOutOfScopeTarget] = useState(null); + // Excepted resource being moved back in scope (drives the confirm dialog). + const [revokeTarget, setRevokeTarget] = useState< + (RunFindingActionTarget & { exceptionId: string }) | null + >(null); + const [revoking, setRevoking] = useState(false); // Sync hook-level error into local state useEffect(() => { @@ -174,6 +188,59 @@ export function TaskIntegrationChecks({ [runCheck, onTaskUpdated], ); + /** + * After a scope change (mark / revoke), re-run the affected check so the + * task's status is recomputed with the new exception set — the run paths + * already honor exceptions. Skipped when a run of the same check is in + * flight: exceptions are loaded at aggregation time (after the provider + * calls), so the in-flight run already reflects the change. + */ + const rerunAfterScopeChange = useCallback( + (target: { connectionId: string; checkId: string }) => { + if (runningCheck !== null) return; + void handleRunCheck(target.connectionId, target.checkId); + }, + [runningCheck, handleRunCheck], + ); + + const handleMarkedOutOfScope = useCallback(() => { + const target = outOfScopeTarget; + setOutOfScopeTarget(null); + void mutateRuns(); + if (target) rerunAfterScopeChange(target); + }, [outOfScopeTarget, mutateRuns, rerunAfterScopeChange]); + + const handleConfirmRevoke = useCallback(async () => { + if (!revokeTarget) return; + setRevoking(true); + try { + await revokeException(revokeTarget.exceptionId); + toast.success( + `"${revokeTarget.resourceId}" is back in scope — re-running the check to update this evidence item.`, + ); + const target = revokeTarget; + setRevokeTarget(null); + rerunAfterScopeChange(target); + } catch (err) { + console.error('Failed to revoke exception:', err); + toast.error( + err instanceof Error ? err.message : 'Failed to move the resource back in scope', + ); + } finally { + setRevoking(false); + } + }, [revokeTarget, revokeException, rerunAfterScopeChange]); + + // Stable action handles passed down to the run history rows. + const exceptionActions = useMemo( + () => ({ + canManage: canManageExceptions, + onMarkOutOfScope: setOutOfScopeTarget, + onRevoke: setRevokeTarget, + }), + [canManageExceptions], + ); + const handleConfirmDisconnect = useCallback(async () => { if (!disconnectTarget) return; const { connectionId, checkId, checkName, integrationName } = disconnectTarget; @@ -710,6 +777,7 @@ export function TaskIntegrationChecks({ @@ -925,6 +993,63 @@ export function TaskIntegrationChecks({ + {/* Mark a failing resource out of scope — captures the documented reason + (recorded as a finding exception, shared with Cloud Tests). */} + { + if (!open) setOutOfScopeTarget(null); + }} + findingId={outOfScopeTarget?.findingId ?? null} + findingTitle={outOfScopeTarget?.title ?? ''} + resourceLabel={outOfScopeTarget?.resourceId ?? null} + title="Mark this resource as out of scope?" + description="The resource stays visible on this evidence item but no longer fails it. The exception and your reason are recorded in the audit trail for auditors." + confirmLabel="Mark out of scope" + expiryHint="Leave empty for never. If set, the resource comes back in scope after this date." + successToast="Marked out of scope — re-running the check to update this evidence item." + onMarked={handleMarkedOutOfScope} + /> + + {/* Confirm moving an excepted resource back in scope */} + { + // Keep the dialog owned by the in-flight revoke request. + if (!open && !revoking) { + setRevokeTarget(null); + } + }} + > + + + Move this resource back in scope? + + {revokeTarget ? ( + <> + The exception on {revokeTarget.resourceId} will be removed and + the resource will count against this evidence item again on the next check run. + + ) : null} + + + + Cancel + { + // Keep the dialog open (with its "Removing…" state) until the + // async revoke settles — Radix would otherwise auto-close. + e.preventDefault(); + void handleConfirmRevoke(); + }} + disabled={revoking} + > + {revoking ? 'Removing...' : 'Move back in scope'} + + + + + {/* Configure Integration Dialog - opens after OAuth success or when clicking Configure */} {configureConnection && ( ({ + Badge: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('@trycompai/ui/button', () => ({ + Button: ({ + children, + disabled, + onClick, + title, + }: { + children: React.ReactNode; + disabled?: boolean; + onClick?: () => void; + title?: string; + }) => ( + + ), +})); + +// Evidence details aren't under test — keep jsdom out of its JSON viewer. +vi.mock('./EvidenceJsonView', () => ({ + EvidenceJsonView: () =>
, +})); + +import type { StoredCheckRun } from '../hooks/useIntegrationChecks'; +import type { RunExceptionActions } from './check-run-history'; +import { CheckRunItem } from './check-run-history'; + +function buildRun(): StoredCheckRun { + const now = new Date().toISOString(); + return { + id: 'icr_1', + checkId: 'aws-s3-bucket-public-access', + checkName: 'S3 - public access blocked', + status: 'failed', + startedAt: now, + completedAt: now, + durationMs: 10, + totalChecked: 3, + passedCount: 1, + failedCount: 1, + exceptedCount: 1, + connectionId: 'conn_1', + connectionLabel: 'AWS 111111111111', + provider: { slug: 'aws', name: 'AWS' }, + results: [ + { + id: 'res_fail', + passed: false, + resourceType: 'aws-s3-bucket', + resourceId: 'redirect-bucket', + title: 'Public access not fully blocked: redirect-bucket', + collectedAt: now, + }, + { + id: 'res_excepted', + passed: false, + resourceType: 'aws-s3-bucket', + resourceId: 'assets-bucket', + title: 'Public access not fully blocked: assets-bucket', + collectedAt: now, + excepted: true, + exceptionId: 'fex_1', + exceptionReason: 'Bucket only hosts a static website redirect.', + }, + { + id: 'res_pass', + passed: true, + resourceType: 'aws-s3-bucket', + resourceId: 'private-bucket', + title: 'Public access blocked: private-bucket', + collectedAt: now, + }, + ], + createdAt: now, + }; +} + +function buildActions(canManage = true): RunExceptionActions { + return { + canManage, + onMarkOutOfScope: vi.fn(), + onRevoke: vi.fn(), + }; +} + +describe('CheckRunItem scope actions', () => { + it('offers "Mark out of scope" on failing rows and reports the full target', () => { + const actions = buildActions(); + render( + , + ); + + const markButton = screen.getByRole('button', { name: 'Mark out of scope' }); + fireEvent.click(markButton); + + expect(actions.onMarkOutOfScope).toHaveBeenCalledWith({ + findingId: 'res_fail', + title: 'Public access not fully blocked: redirect-bucket', + resourceId: 'redirect-bucket', + connectionId: 'conn_1', + checkId: 'aws-s3-bucket-public-access', + }); + }); + + it('shows the excepted row with its reason and offers revoke with the exception id', () => { + const actions = buildActions(); + render( + , + ); + + expect(screen.getByText('Out of scope')).toBeInTheDocument(); + expect( + screen.getByText('Bucket only hosts a static website redirect.'), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Move back in scope' })); + + expect(actions.onRevoke).toHaveBeenCalledWith({ + findingId: 'res_excepted', + title: 'Public access not fully blocked: assets-bucket', + resourceId: 'assets-bucket', + connectionId: 'conn_1', + checkId: 'aws-s3-bucket-public-access', + exceptionId: 'fex_1', + }); + }); + + it('hides both actions without the integration:update permission', () => { + render( + , + ); + + expect( + screen.queryByRole('button', { name: 'Mark out of scope' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Move back in scope' }), + ).not.toBeInTheDocument(); + // Read-only users still see the excepted state + reason. + expect(screen.getByText('Out of scope')).toBeInTheDocument(); + }); + + it('hides both actions on non-latest runs (stale resources)', () => { + render( + , + ); + + expect( + screen.queryByRole('button', { name: 'Mark out of scope' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Move back in scope' }), + ).not.toBeInTheDocument(); + }); + + it('renders no actions at all when none are provided (other callers unaffected)', () => { + render(); + + expect( + screen.queryByRole('button', { name: 'Mark out of scope' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Move back in scope' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx index 42901c60fe..92eb8f541e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'; import { Badge } from '@trycompai/ui/badge'; +import { Button } from '@trycompai/ui/button'; import { formatDistanceToNow } from 'date-fns'; import { ChevronDown } from 'lucide-react'; import { useMemo, useState } from 'react'; @@ -9,6 +10,28 @@ import type { StoredCheckRun } from '../hooks/useIntegrationChecks'; import { groupRunsByConnection } from './check-run-grouping'; import { EvidenceJsonView } from './EvidenceJsonView'; +/** A failing result row the user is acting on, identified the way the + * exception endpoints need it (result id to mark; run coords to re-run). */ +export interface RunFindingActionTarget { + findingId: string; + title: string; + resourceId: string; + connectionId: string; + checkId: string; +} + +/** + * Scope actions offered on the latest run's result rows: mark a failing + * resource out of scope (creates a finding exception) or move an excepted + * resource back in scope (revokes it). `canManage` reflects the caller's + * integration:update permission — without it no action is rendered. + */ +export interface RunExceptionActions { + canManage: boolean; + onMarkOutOfScope: (target: RunFindingActionTarget) => void; + onRevoke: (target: RunFindingActionTarget & { exceptionId: string }) => void; +} + /** * Run history for a check, grouped by the account (connection) it ran against. * @@ -21,14 +44,22 @@ import { EvidenceJsonView } from './EvidenceJsonView'; export function AccountRunGroups({ runs, organizationName, + exceptionActions, }: { runs: StoredCheckRun[]; organizationName: string; + exceptionActions?: RunExceptionActions; }) { const groups = useMemo(() => groupRunsByConnection(runs), [runs]); if (groups.length <= 1) { - return ; + return ( + + ); } return ( @@ -53,7 +84,12 @@ export function AccountRunGroups({ )}
- + ); })} @@ -66,10 +102,12 @@ export function GroupedCheckRuns({ runs, maxRuns = 5, organizationName, + exceptionActions, }: { runs: StoredCheckRun[]; maxRuns?: number; organizationName: string; + exceptionActions?: RunExceptionActions; }) { const [showAll, setShowAll] = useState(false); @@ -119,6 +157,7 @@ export function GroupedCheckRuns({ run={run} isLatest={isLatest} organizationName={organizationName} + exceptionActions={exceptionActions} /> ); })} @@ -143,13 +182,20 @@ export function CheckRunItem({ run, isLatest, organizationName, + exceptionActions, }: { run: StoredCheckRun; isLatest: boolean; organizationName: string; + exceptionActions?: RunExceptionActions; }) { const [expanded, setExpanded] = useState(isLatest); + // Scope actions only make sense on the LATEST run — older runs may list + // resources that no longer exist. Marking/revoking still applies to the + // (connection, check, resource) key, not to a specific run. + const showExceptionActions = isLatest && !!exceptionActions?.canManage; + const timeAgo = formatDistanceToNow(new Date(run.createdAt), { addSuffix: true }); const hasFailed = run.status === 'failed' || run.failedCount > 0; const hasError = run.status === 'failed' && run.errorMessage; @@ -250,7 +296,7 @@ export function CheckRunItem({ {finding.remediation && (

{finding.remediation}

)} -
+
{finding.resourceId} @@ -259,6 +305,25 @@ export function CheckRunItem({ {finding.severity} )} + {showExceptionActions && ( + + )}
{finding.evidence && Object.keys(finding.evidence).length > 0 && ( @@ -283,28 +348,58 @@ export function CheckRunItem({ )} - {/* Excepted - failing findings the customer marked as an exception. + {/* Excepted - failing findings the customer marked out of scope. Shown muted (not an issue) so it's clear the exception applied. */} {shownExcepted.length > 0 && (
- {shownExcepted.map((finding) => ( -
-
-

{finding.title}

- - Exception - -
-
- - {finding.resourceId} - + {shownExcepted.map((finding) => { + const { exceptionId } = finding; + return ( +
+
+

+ {finding.title} +

+ + Out of scope + +
+ {finding.exceptionReason && ( +

+ {finding.exceptionReason} +

+ )} +
+ + {finding.resourceId} + + {showExceptionActions && exceptionId && ( + + )} +
-
- ))} + ); + })} {moreExcepted > 0 && (

- +{moreExcepted} more excepted + +{moreExcepted} more out of scope

)}
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts index f0e7f9f827..4b1abd56af 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts @@ -62,6 +62,10 @@ interface StoredCheckRun { collectedAt: string; /** True when this failing result is suppressed by an active exception. */ excepted?: boolean; + /** The active exception's id — needed to revoke (move back in scope). */ + exceptionId?: string; + /** The documented reason recorded when the exception was created. */ + exceptionReason?: string; }>; createdAt: string; } @@ -145,6 +149,27 @@ export function useIntegrationChecks({ taskId, orgId }: UseIntegrationChecksOpti throw new Error('Failed to run check'); }; + /** + * Revoke an active finding exception (move the resource back in scope). + * The exception mechanism is shared with Cloud Tests, so this talks to the + * cloud-security endpoint; the check run views refresh afterwards. + */ + const revokeException = async (exceptionId: string): Promise => { + const response = await api.delete<{ success: boolean }>( + `/v1/cloud-security/exceptions/${exceptionId}?organizationId=${orgId}`, + ); + + if (response.error || !response.data?.success) { + throw new Error( + typeof response.error === 'string' + ? response.error + : 'Failed to move the resource back in scope', + ); + } + + await mutateRuns(); + }; + /** * Disconnect a single check from the current task. The integration itself * stays connected — only the (task, check) pair is affected. Applies an @@ -231,6 +256,7 @@ export function useIntegrationChecks({ taskId, orgId }: UseIntegrationChecksOpti mutateChecks, mutateRuns, runCheck, + revokeException, disconnectCheckFromTask, reconnectCheckToTask, }; diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.test.tsx b/apps/app/src/components/integrations/MarkExceptionModal.test.tsx similarity index 76% rename from apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.test.tsx rename to apps/app/src/components/integrations/MarkExceptionModal.test.tsx index 7c2419456f..30f35b8ff0 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/MarkExceptionModal.test.tsx +++ b/apps/app/src/components/integrations/MarkExceptionModal.test.tsx @@ -23,9 +23,15 @@ vi.mock('@trycompai/ui/dialog', () => { children: React.ReactNode; }) => (open ?
{children}
: null), DialogContent: Pass, - DialogDescription: Pass, + // Title/description get their own elements so text queries can match + // them individually (a fragment would merge them into one text blob). + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), DialogHeader: Pass, - DialogTitle: Pass, + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), }; }); @@ -68,6 +74,32 @@ describe('MarkExceptionModal', () => { expect(screen.getByText('IAM Account: 123456789012')).toBeInTheDocument(); }); + it('applies copy overrides for the out-of-scope surface', () => { + render( + {}} + findingId="icx_1" + findingTitle="Public access not fully blocked: redirect-bucket" + title="Mark this resource as out of scope?" + description="The resource stays visible but no longer fails this evidence item." + confirmLabel="Mark out of scope" + expiryHint="Leave empty for never." + />, + ); + expect( + screen.getByText('Mark this resource as out of scope?'), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /^Mark out of scope$/ }), + ).toBeInTheDocument(); + expect(screen.getByText('Leave empty for never.')).toBeInTheDocument(); + // Default copy is fully replaced. + expect( + screen.queryByText('Mark this finding as an exception?'), + ).not.toBeInTheDocument(); + }); + it('keeps the submit button disabled until reason reaches min length', () => { render( void; + /** Copy overrides so the same flow reads naturally on each surface — + * Cloud Tests says "exception", evidence tasks say "out of scope". + * The underlying mechanism (a FindingException) is identical. */ + title?: string; + description?: string; + confirmLabel?: string; + expiryHint?: string; + successToast?: string; } /** @@ -46,6 +54,9 @@ export interface MarkExceptionModalProps { * 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. + * + * Shared by the Cloud Tests findings view and the evidence-task check view + * (which brands the same flow "mark out of scope" via the copy props). */ export function MarkExceptionModal({ open, @@ -54,6 +65,11 @@ export function MarkExceptionModal({ findingTitle, resourceLabel, onMarked, + title = 'Mark this finding as an exception?', + description = 'Exceptions are recorded in the audit trail. Auditors will see this exception and the reason you provide.', + confirmLabel = 'Mark as exception', + expiryHint = 'Leave empty for never. If set, the finding reappears in Scan Results on the first scan after this date.', + successToast = 'Marked as exception', }: MarkExceptionModalProps) { const api = useApi(); const [reason, setReason] = useState(''); @@ -85,7 +101,7 @@ export function MarkExceptionModal({ return; } - toast.success('Marked as exception'); + toast.success(successToast); setReason(''); setReviewedBy(''); setExpiresAt(''); @@ -106,11 +122,8 @@ export function MarkExceptionModal({ - Mark this finding as an exception? - - Exceptions are recorded in the audit trail. Auditors will see this - exception and the reason you provide. - + {title} + {description}
@@ -180,10 +193,7 @@ export function MarkExceptionModal({ min={tomorrowLocalDateString()} className="w-full rounded-md border bg-background px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary/30" /> -

- Leave empty for never. If set, the finding reappears in Scan - Results on the first scan after this date. -

+

{expiryHint}

@@ -204,7 +214,7 @@ export function MarkExceptionModal({ {submitting ? ( ) : null} - Mark as exception + {confirmLabel} From 51132769d057d8a62fdc6276d5c7159e00d1d2d6 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 16 Jul 2026 16:42:26 -0400 Subject: [PATCH 2/3] fix(app): address review findings on out-of-scope actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - re-run guard now only skips when the SAME check is in flight — a run of a different check doesn't recompute this one, so it must not suppress the re-run after a scope change - findings and out-of-scope lists are expandable beyond the first three sampled rows, so every sampled resource is markable/revocable - the reason field label follows the surface copy (out-of-scope wording on evidence items) via a reasonLabel override --- .../components/TaskIntegrationChecks.tsx | 9 +++-- .../components/check-run-history.test.tsx | 32 +++++++++++++++ .../[taskId]/components/check-run-history.tsx | 40 +++++++++++++++---- .../integrations/MarkExceptionModal.test.tsx | 5 +++ .../integrations/MarkExceptionModal.tsx | 4 +- 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx index 773781e074..231cce904a 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx @@ -191,13 +191,15 @@ export function TaskIntegrationChecks({ /** * After a scope change (mark / revoke), re-run the affected check so the * task's status is recomputed with the new exception set — the run paths - * already honor exceptions. Skipped when a run of the same check is in + * already honor exceptions. Skipped only when a run of the SAME check is in * flight: exceptions are loaded at aggregation time (after the provider - * calls), so the in-flight run already reflects the change. + * calls), so that in-flight run already reflects the change. A run of a + * DIFFERENT check doesn't recompute this one, so it must not suppress the + * re-run. */ const rerunAfterScopeChange = useCallback( (target: { connectionId: string; checkId: string }) => { - if (runningCheck !== null) return; + if (runningCheck === target.checkId) return; void handleRunCheck(target.connectionId, target.checkId); }, [runningCheck, handleRunCheck], @@ -1006,6 +1008,7 @@ export function TaskIntegrationChecks({ title="Mark this resource as out of scope?" description="The resource stays visible on this evidence item but no longer fails it. The exception and your reason are recorded in the audit trail for auditors." confirmLabel="Mark out of scope" + reasonLabel="Reason this resource is out of scope (required) *" expiryHint="Leave empty for never. If set, the resource comes back in scope after this date." successToast="Marked out of scope — re-running the check to update this evidence item." onMarked={handleMarkedOutOfScope} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.test.tsx index 2b0b33cba0..c86fa675c7 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.test.tsx @@ -180,6 +180,38 @@ describe('CheckRunItem scope actions', () => { ).not.toBeInTheDocument(); }); + it('expands beyond the first three failing rows so every sampled resource is markable', () => { + const actions = buildActions(); + const run = buildRun(); + const now = new Date().toISOString(); + // 5 sampled failing rows (plus the excepted one from the base fixture). + run.results = Array.from({ length: 5 }, (_, i) => ({ + id: `res_fail_${i}`, + passed: false, + resourceType: 'aws-s3-bucket', + resourceId: `bucket-${i}`, + title: `Public access not fully blocked: bucket-${i}`, + collectedAt: now, + })); + run.failedCount = 5; + run.exceptedCount = 0; + + render( + , + ); + + // Collapsed: only the first three rows are actionable. + expect(screen.getAllByRole('button', { name: 'Mark out of scope' })).toHaveLength(3); + + fireEvent.click(screen.getByRole('button', { name: 'Show 2 more issues' })); + + // Expanded: every sampled failing row is actionable. + expect(screen.getAllByRole('button', { name: 'Mark out of scope' })).toHaveLength(5); + expect( + screen.queryByRole('button', { name: /Show .* more issues/ }), + ).not.toBeInTheDocument(); + }); + it('renders no actions at all when none are provided (other callers unaffected)', () => { render(); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx index 92eb8f541e..5b769b7a78 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx @@ -190,6 +190,8 @@ export function CheckRunItem({ exceptionActions?: RunExceptionActions; }) { const [expanded, setExpanded] = useState(isLatest); + const [showAllFindings, setShowAllFindings] = useState(false); + const [showAllExcepted, setShowAllExcepted] = useState(false); // Scope actions only make sense on the LATEST run — older runs may list // resources that no longer exist. Marking/revoking still applies to the @@ -208,14 +210,20 @@ export function CheckRunItem({ // `run.results` is capped server-side — a check can produce tens of thousands // of results (e.g. a Firebase B2C tenant) which would otherwise ship a - // multi-MB payload and OOM the browser. Show the first few from the (bounded) - // array, but derive the "+N more" counts from the run's authoritative summary - // counts so the totals are still correct. - const shownFindings = findings.slice(0, 3); - const shownExcepted = excepted.slice(0, 3); + // multi-MB payload and OOM the browser. Show the first few by default, but + // let the user expand to every SAMPLED finding/excepted row — the scope + // actions live on these rows, so capping them at three would leave later + // resources unactionable. "+N more" counts beyond that come from the run's + // authoritative summary columns (rows outside the server-side sample). + const shownFindings = showAllFindings ? findings : findings.slice(0, 3); + const shownExcepted = showAllExcepted ? excepted : excepted.slice(0, 3); const shownPassing = passing.slice(0, 3); - const moreFindings = Math.max(0, run.failedCount - shownFindings.length); - const moreExcepted = Math.max(0, (run.exceptedCount ?? 0) - shownExcepted.length); + // Sampled rows currently hidden behind the "Show more" toggles. + const collapsedFindings = findings.length - shownFindings.length; + const collapsedExcepted = excepted.length - shownExcepted.length; + // Rows beyond the server-side sample — not in the payload at all. + const moreFindings = Math.max(0, run.failedCount - findings.length); + const moreExcepted = Math.max(0, (run.exceptedCount ?? 0) - excepted.length); const morePassing = Math.max(0, run.passedCount - shownPassing.length); const statusColor = hasError ? 'text-destructive' : hasFailed ? 'text-warning' : 'text-primary'; @@ -340,6 +348,15 @@ export function CheckRunItem({ )} ))} + {collapsedFindings > 0 && ( + + )} {moreFindings > 0 && (

+{moreFindings} more issues @@ -397,6 +414,15 @@ export function CheckRunItem({ ); })} + {collapsedExcepted > 0 && ( + + )} {moreExcepted > 0 && (

+{moreExcepted} more out of scope diff --git a/apps/app/src/components/integrations/MarkExceptionModal.test.tsx b/apps/app/src/components/integrations/MarkExceptionModal.test.tsx index 30f35b8ff0..8d768367b2 100644 --- a/apps/app/src/components/integrations/MarkExceptionModal.test.tsx +++ b/apps/app/src/components/integrations/MarkExceptionModal.test.tsx @@ -84,6 +84,7 @@ describe('MarkExceptionModal', () => { title="Mark this resource as out of scope?" description="The resource stays visible but no longer fails this evidence item." confirmLabel="Mark out of scope" + reasonLabel="Reason this resource is out of scope (required) *" expiryHint="Leave empty for never." />, ); @@ -93,11 +94,15 @@ describe('MarkExceptionModal', () => { expect( screen.getByRole('button', { name: /^Mark out of scope$/ }), ).toBeInTheDocument(); + expect( + screen.getByLabelText(/Reason this resource is out of scope/i), + ).toBeInTheDocument(); expect(screen.getByText('Leave empty for never.')).toBeInTheDocument(); // Default copy is fully replaced. expect( screen.queryByText('Mark this finding as an exception?'), ).not.toBeInTheDocument(); + expect(screen.queryByText(/Reason for exception/i)).not.toBeInTheDocument(); }); it('keeps the submit button disabled until reason reaches min length', () => { diff --git a/apps/app/src/components/integrations/MarkExceptionModal.tsx b/apps/app/src/components/integrations/MarkExceptionModal.tsx index 14ac4d93e8..05cf2dec38 100644 --- a/apps/app/src/components/integrations/MarkExceptionModal.tsx +++ b/apps/app/src/components/integrations/MarkExceptionModal.tsx @@ -45,6 +45,7 @@ export interface MarkExceptionModalProps { title?: string; description?: string; confirmLabel?: string; + reasonLabel?: string; expiryHint?: string; successToast?: string; } @@ -68,6 +69,7 @@ export function MarkExceptionModal({ title = 'Mark this finding as an exception?', description = 'Exceptions are recorded in the audit trail. Auditors will see this exception and the reason you provide.', confirmLabel = 'Mark as exception', + reasonLabel = 'Reason for exception (required) *', expiryHint = 'Leave empty for never. If set, the finding reappears in Scan Results on the first scan after this date.', successToast = 'Marked as exception', }: MarkExceptionModalProps) { @@ -139,7 +141,7 @@ export function MarkExceptionModal({ htmlFor="exception-reason" className="block text-xs font-medium mb-1" > - Reason for exception (required) * + {reasonLabel}