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
24 changes: 24 additions & 0 deletions apps/api/src/cloud-security/finding-exceptions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,30 @@ describe('ActiveExceptionSet', () => {
expect(set.has('c2', 'aws-s3-public-access', 'bucket-1')).toBe(false);
expect(set.size).toBe(1);
});

it('exposes excepted resourceIds grouped by (connectionId, checkId)', () => {
const set = new ActiveExceptionSet([
ActiveExceptionSet.key('c1', 'check-a', 'r1'),
ActiveExceptionSet.key('c1', 'check-a', 'r2'),
ActiveExceptionSet.key('c1', 'check-b', 'r3'),
]);
expect(new Set(set.exceptedResourceIds('c1', 'check-a'))).toEqual(
new Set(['r1', 'r2']),
);
expect(set.exceptedResourceIds('c1', 'check-b')).toEqual(['r3']);
// Nothing excepted for this pair → empty (callers skip the count query).
expect(set.exceptedResourceIds('c1', 'check-c')).toEqual([]);
expect(set.exceptedResourceIds('c2', 'check-a')).toEqual([]);
});

it('reconstructs resourceIds that themselves contain the "::" delimiter', () => {
const resourceId = 'arn:aws:s3:::my::weird::bucket';
const set = new ActiveExceptionSet([
ActiveExceptionSet.key('c1', 'check-a', resourceId),
]);
expect(set.has('c1', 'check-a', resourceId)).toBe(true);
expect(set.exceptedResourceIds('c1', 'check-a')).toEqual([resourceId]);
});
});

describe('loadActiveExceptionSet', () => {
Expand Down
34 changes: 34 additions & 0 deletions apps/api/src/cloud-security/finding-exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,31 @@ import { db } from '@db';
*/
export class ActiveExceptionSet {
private readonly keys: Set<string>;
/**
* Excepted resourceIds grouped by `connectionId::checkId`. Lets a caller
* count a run's excepted failures with a targeted query instead of loading
* every result row into memory.
*/
private readonly resourceIdsByConnCheck: Map<string, Set<string>>;

constructor(keys: Iterable<string>) {
this.keys = new Set(keys);
this.resourceIdsByConnCheck = new Map();
for (const key of this.keys) {
// key = `${connectionId}::${checkId}::${resourceId}`. A resourceId can
// itself contain "::", so take the first two segments and rejoin the
// rest — reconstructing exactly the resourceId used to build the key.
const parts = key.split('::');
if (parts.length < 3) continue;
const groupKey = `${parts[0]}::${parts[1]}`;
const resourceId = parts.slice(2).join('::');
let ids = this.resourceIdsByConnCheck.get(groupKey);
if (!ids) {
ids = new Set();
this.resourceIdsByConnCheck.set(groupKey, ids);
}
ids.add(resourceId);
}
}

/** Canonical key. The only place this format is defined. */
Expand All @@ -41,6 +63,18 @@ export class ActiveExceptionSet {
ActiveExceptionSet.key(connectionId, checkId, resourceId),
);
}

/**
* The excepted resourceIds for a (connection, check) pair. Empty when nothing
* is excepted for that pair — callers use this to skip the count query
* entirely (the common case: no exceptions).
*/
exceptedResourceIds(connectionId: string, checkId: string): string[] {
const ids = this.resourceIdsByConnCheck.get(
`${connectionId}::${checkId}`,
);
return ids ? Array.from(ids) : [];
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ describe('TaskIntegrationsController', () => {
complete: jest.fn(),
addResults: jest.fn(),
findLatestPerConnectionAndCheckByTask: jest.fn(),
countExceptedFailures: jest.fn(),
};
const mockCredentialVaultService = { getDecryptedCredentials: jest.fn() };
const mockOAuthCredentialsService = {
Expand Down Expand Up @@ -229,6 +230,9 @@ describe('TaskIntegrationsController', () => {
);
mockCheckRunRepository.complete.mockResolvedValue({});
mockCheckRunRepository.addResults.mockResolvedValue({});
// Default: nothing excepted (no count query needed). Exception tests
// override this to the exact excepted-failure count for the run.
mockCheckRunRepository.countExceptedFailures.mockResolvedValue(0);
mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue(
VALID_CREDS,
);
Expand Down Expand Up @@ -564,13 +568,23 @@ describe('TaskIntegrationsController', () => {
resourceId: 'reports-bucket',
},
]);
// The excepted-failure count is now computed via a targeted query (the
// full result set is no longer loaded). reports-bucket is the one
// excepted failing result for this run.
mockCheckRunRepository.countExceptedFailures.mockResolvedValue(1);

const { runs } = await controller.getTaskCheckRuns('task_1', 'org_1');

expect(runs[0].failedCount).toBe(0);
expect(runs[0].exceptedCount).toBe(1);
expect(runs[0].status).toBe('success');
expect(runs[0].results[0].excepted).toBe(true);
// 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(
'icr_1',
['reports-bucket'],
);
});

it('keeps an execution-error run as failed (no findings, not excepted)', async () => {
Expand Down Expand Up @@ -621,5 +635,108 @@ describe('TaskIntegrationsController', () => {
mockCheckRunRepository.findLatestPerConnectionAndCheckByTask,
).not.toHaveBeenCalled();
});

it('bounds a run with a huge result set + logs so the payload stays small (CS-588)', async () => {
// Defense-in-depth response cap: even if a run somehow carries a large
// result/log set, the serialized response is bounded (results per
// category, evidence size, log count) while the run's summary counts stay
// accurate. The PRIMARY fix — never LOADING all result rows from the DB —
// lives in CheckRunRepository.findLatestPerConnectionAndCheckByTask and is
// covered in check-run.repository.spec.ts.
const HUGE = 5000;
const results = [
// First finding carries an oversized evidence blob.
{
id: 'icx_finding_0',
passed: false,
resourceType: 'firebase-user',
resourceId: 'user_0',
title: 'finding 0',
description: 'd',
severity: 'high',
remediation: 'fix',
evidence: { blob: 'x'.repeat(30_000) },
collectedAt: new Date(),
},
...Array.from({ length: HUGE - 1 }, (_, i) => ({
id: `icx_finding_${i + 1}`,
passed: false,
resourceType: 'firebase-user',
resourceId: `user_f_${i + 1}`,
title: 'finding',
description: 'd',
severity: 'high',
remediation: 'fix',
evidence: { ok: true },
collectedAt: new Date(),
})),
...Array.from({ length: HUGE }, (_, i) => ({
id: `icx_pass_${i}`,
passed: true,
resourceType: 'firebase-user',
resourceId: `user_p_${i}`,
title: 'passing',
description: 'd',
evidence: { ok: true },
collectedAt: new Date(),
})),
];
const logs = Array.from({ length: HUGE }, (_, i) => ({
level: 'info',
message: `log ${i}`,
timestamp: new Date().toISOString(),
}));

mockCheckRunRepository.findLatestPerConnectionAndCheckByTask.mockResolvedValue(
[
{
id: 'icr_huge',
checkId: 'firebase-employee-access',
checkName: 'Employee Access',
status: 'failed',
startedAt: new Date(),
completedAt: new Date(),
durationMs: 10,
totalChecked: HUGE * 2,
passedCount: HUGE,
failedCount: HUGE,
errorMessage: null,
logs,
connectionId: 'conn_1',
createdAt: new Date(),
results,
connection: {
id: 'conn_1',
metadata: { connectionName: 'Firebase' },
provider: { slug: 'firebase', name: 'Firebase' },
},
},
],
);

const { runs } = await controller.getTaskCheckRuns('task_1', 'org_1');

// Result detail is bounded (a few findings + a few passing), NOT 10000.
expect(runs[0].results.length).toBeLessThanOrEqual(15);
expect(runs[0].results.length).toBeLessThan(results.length);
// Logs are bounded too.
expect(Array.isArray(runs[0].logs)).toBe(true);
if (Array.isArray(runs[0].logs)) {
expect(runs[0].logs.length).toBeLessThanOrEqual(100);
}
// Summary counts remain authoritative (computed from the full set).
expect(runs[0].passedCount).toBe(HUGE);
expect(runs[0].failedCount).toBe(HUGE);
expect(runs[0].exceptedCount).toBe(0);
// The oversized evidence blob is replaced with a compact placeholder.
const shippedFinding = runs[0].results.find(
(r) => r.id === 'icx_finding_0',
);
expect(shippedFinding).toBeDefined();
expect(shippedFinding?.evidence).toMatchObject({ truncated: true });
// Normal small evidence is left intact.
const shippedPass = runs[0].results.find((r) => r.passed);
expect(shippedPass?.evidence).toEqual({ ok: true });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ import {
countEffectiveFailures,
decideTaskStatus,
} from '../utils/task-check-evaluation';
import {
capEvidence,
capLogs,
capResultsForList,
} from '../utils/run-history-limits';
import { db } from '@db';
import type { IntegrationConnection, Prisma } from '@db';

Expand Down Expand Up @@ -768,11 +773,31 @@ export class TaskIntegrationsController {
// untouched in the DB; this only affects the response.
const exceptions = await loadActiveExceptionSet(organizationId);

return {
runs: runs.map((run) => {
const mappedRuns = await Promise.all(
runs.map(async (run) => {
const provider = getProviderSummary(run.connection);

const results = run.results.map((r) => ({
// `run.results` is a BOUNDED, findings-first sample — the repo caps how
// many rows it loads per run (a check can produce tens of thousands, so
// loading them all hangs/OOMs the request). The effective failure count
// is therefore computed EXACTLY via a targeted count query over the
// full set, NOT by filtering this sample. The query is skipped when
// this (connection, check) has no exceptions — the common case.
const exceptedResourceIds = exceptions.exceptedResourceIds(
run.connectionId,
run.checkId,
);
const exceptedCount =
await this.checkRunRepository.countExceptedFailures(
run.id,
exceptedResourceIds,
);

// 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,
Expand All @@ -787,8 +812,11 @@ export class TaskIntegrationsController {
!r.passed &&
exceptions.has(run.connectionId, run.checkId, r.resourceId),
}));
const results = capResultsForList(sample).map((r) => ({
...r,
evidence: capEvidence(r.evidence),
}));

const exceptedCount = results.filter((r) => r.excepted).length;
const effectiveFailed = Math.max(0, run.failedCount - exceptedCount);
// Only downgrade failed → success when the failures were actually
// EXCEPTED. A failed run with no findings (e.g. an execution error,
Expand All @@ -812,7 +840,7 @@ export class TaskIntegrationsController {
failedCount: effectiveFailed,
exceptedCount,
errorMessage: run.errorMessage,
logs: run.logs,
logs: capLogs(run.logs),
connectionId: run.connectionId,
connectionLabel: getConnectionLabel(run.connection),
provider: {
Expand All @@ -823,6 +851,8 @@ export class TaskIntegrationsController {
createdAt: run.createdAt,
};
}),
};
);

return { runs: mappedRuns };
}
}
Loading
Loading