Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/api/src/cloud-security/check-definition.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@ export function normalizeCheckId(
return findingKey;
}

/**
* The stable check key used to key exceptions and suppress findings across
* scans. Prefer the stamped `findingKey` (normalized to the bare check id);
* fall back to the check run's own `checkId` for older rows stored before
* findingKey stamping. The auto-run sentinel `'all'` is not a real check, so
* it yields null (the finding can't be marked as an exception).
*
* Shared by the exception resolver and the findings query so a finding marked
* as an exception is also the one suppressed from the list.
*/
export function resolveCheckKey(params: {
findingKey: string | null;
resourceId: string | null;
runCheckId: string | null;
}): string | null {
const { findingKey, resourceId, runCheckId } = params;
if (findingKey) return normalizeCheckId(findingKey, resourceId);
if (runCheckId && runCheckId !== 'all') return runCheckId;
return null;
}

export interface SourceHashInput {
provider: string;
serviceName: string | null;
Expand Down
12 changes: 8 additions & 4 deletions apps/api/src/cloud-security/cloud-security-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { resolveCheckKey } from './check-definition.utils';
import type {
CloudFinding,
CloudProvider,
Expand Down Expand Up @@ -364,9 +364,13 @@ export class CloudSecurityQueryService {
resourceId: result.resourceId ?? null,
resourceType: result.resourceType ?? null,
checkId: checkRun?.checkId ?? null,
checkKey: findingKey
? normalizeCheckId(findingKey, result.resourceId)
: null,
// Same fallback as the exception resolver so a finding marked as an
// exception is also the one suppressed from this list.
checkKey: resolveCheckKey({
findingKey,
resourceId: result.resourceId,
runCheckId: checkRun?.checkId ?? null,
}),
evidence: sanitizeEvidence(result.evidence ?? null),
projectDisplayName: (() => {
if (projectDisplayNameFromEvidence) {
Expand Down
73 changes: 72 additions & 1 deletion apps/api/src/cloud-security/exception.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ describe('CloudExceptionService.markAsException', () => {
dbMock.integrationCheckResult.findFirst.mockResolvedValueOnce({
resourceId: null,
evidence: null,
checkRun: { connectionId: 'icn_aws' },
checkRun: { connectionId: 'icn_aws', checkId: 'all' },
});
await expect(
buildService().markAsException({
Expand All @@ -195,6 +195,77 @@ describe('CloudExceptionService.markAsException', () => {
}),
).rejects.toThrow(BadRequestException);
});

it('falls back to the run checkId for older rows that have no findingKey', async () => {
// AWS integration-platform finding stored before findingKey stamping: the
// evidence has no findingKey, but a task-scoped run carries the real check
// id, which IS the normalized check id used to key the exception.
dbMock.integrationCheckResult.findFirst.mockResolvedValueOnce({
resourceId: 'primer-production-reports-bucket',
evidence: { bucket: 'primer-production-reports-bucket' },
checkRun: { connectionId: 'icn_aws', checkId: 'aws-s3-public-access' },
});
dbMock.findingException.upsert.mockResolvedValueOnce({ id: 'fex_fb' });

const result = await buildService().markAsException({
findingId: 'icx_old',
organizationId: 'org_1',
userId: 'usr_1',
reason: 'Bucket intentionally public for marketing assets — reviewed.',
});

expect(result.id).toBe('fex_fb');
expect(dbMock.findingException.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
organizationId_connectionId_checkId_resourceId: {
organizationId: 'org_1',
connectionId: 'icn_aws',
checkId: 'aws-s3-public-access',
resourceId: 'primer-production-reports-bucket',
},
},
}),
);
});

it("rejects older rows whose run checkId is the 'all' auto-run sentinel", async () => {
dbMock.integrationCheckResult.findFirst.mockResolvedValueOnce({
resourceId: 'some-bucket',
evidence: { bucket: 'some-bucket' },
checkRun: { connectionId: 'icn_aws', checkId: 'all' },
});
await expect(
buildService().markAsException({
findingId: 'icx_all',
organizationId: 'org_1',
userId: 'usr_1',
reason: 'A perfectly long, well-documented reason here for tests.',
}),
).rejects.toThrow(BadRequestException);
});

it('stamped findingKey (new rows) normalizes to the bare check id', async () => {
// Mirrors what AWS emitOutcomes now writes: findingKey = `${checkId}-${resourceId}`.
withFinding({
findingKey: 'aws-s3-public-access-primer-production-reports-bucket',
resourceId: 'primer-production-reports-bucket',
connectionId: 'icn_aws',
});
dbMock.findingException.upsert.mockResolvedValueOnce({ id: 'fex_new2' });

await buildService().markAsException({
findingId: 'icx_new',
organizationId: 'org_1',
userId: 'usr_1',
reason: 'Bucket intentionally public for marketing assets — reviewed.',
});

const call = dbMock.findingException.upsert.mock.calls[0][0];
expect(
call.where.organizationId_connectionId_checkId_resourceId.checkId,
).toBe('aws-s3-public-access');
});
});

describe('CloudExceptionService.revokeException', () => {
Expand Down
15 changes: 11 additions & 4 deletions apps/api/src/cloud-security/exception.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '@nestjs/common';
import { db } from '@db';
import { logCloudSecurityActivity } from './cloud-security-audit';
import { normalizeCheckId } from './check-definition.utils';
import { resolveCheckKey } from './check-definition.utils';

/** Minimum chars for an exception reason — meant to discourage low-effort
* reasons like "ok" or "test". Auditors rely on this field as the
Expand Down Expand Up @@ -198,7 +198,7 @@ export class CloudExceptionService {
select: {
resourceId: true,
evidence: true,
checkRun: { select: { connectionId: true } },
checkRun: { select: { connectionId: true, checkId: true } },
},
});

Expand All @@ -213,15 +213,22 @@ export class CloudExceptionService {
evidence && typeof evidence.findingKey === 'string'
? evidence.findingKey
: null;
if (!rawFindingKey || !result.resourceId) {

const resolvedCheckId = resolveCheckKey({
findingKey: rawFindingKey,
resourceId: result.resourceId,
runCheckId: result.checkRun.checkId,
});

if (!resolvedCheckId || !result.resourceId) {
throw new BadRequestException(
'This finding cannot be marked as an exception — it lacks a stable check/resource identity.',
);
}

return {
connectionId: result.checkRun.connectionId,
checkId: normalizeCheckId(rawFindingKey, result.resourceId),
checkId: resolvedCheckId,
resourceId: result.resourceId,
};
}
Expand Down
Loading
Loading