Skip to content

Commit 0489b28

Browse files
authored
Merge pull request #3109 from trycompai/main
[comp] Production Deploy
2 parents 72ef093 + 83c24cd commit 0489b28

4 files changed

Lines changed: 302 additions & 19 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// AzureSecurityService scans Defender + a suite of ARM service adapters, all of
2+
// which go through the global `fetch`. These tests mock `fetch` to exercise the
3+
// all-units-failed guard: a scan where every unit errors must THROW (so it is
4+
// not stored as a fresh empty "success" run that hides the prior good results),
5+
// while a genuinely-clean subscription (everything succeeds, 0 findings) must
6+
// return [] without throwing.
7+
jest.mock('@db', () => ({ db: {} }));
8+
9+
import { AzureSecurityService } from './azure-security.service';
10+
11+
function azureOkEmpty(): { ok: true; json: () => Promise<unknown> } {
12+
return { ok: true, json: async () => ({ value: [] }) };
13+
}
14+
15+
function azureError(
16+
status: number,
17+
text = 'error',
18+
): { ok: false; status: number; text: () => Promise<string> } {
19+
return { ok: false, status, text: async () => text };
20+
}
21+
22+
describe('AzureSecurityService.scanSecurityFindings — all-units-failed guard', () => {
23+
let service: AzureSecurityService;
24+
let fetchMock: jest.Mock;
25+
const originalFetch = global.fetch;
26+
27+
const creds = { access_token: 'tok' };
28+
const vars = { subscription_id: 'sub-1' };
29+
30+
beforeEach(() => {
31+
fetchMock = jest.fn();
32+
// @ts-expect-error replacing global fetch with a mock for these tests
33+
global.fetch = fetchMock;
34+
service = new AzureSecurityService();
35+
});
36+
37+
afterAll(() => {
38+
global.fetch = originalFetch;
39+
});
40+
41+
// The bug: a total (non-403) failure used to return [] silently, which got
42+
// stored as a fresh "success" run with 0 findings and hid the prior results.
43+
it('throws when the whole scan errors out and produces no findings, instead of returning []', async () => {
44+
fetchMock.mockResolvedValue(azureError(500, 'internal server error'));
45+
46+
await expect(service.scanSecurityFindings(creds, vars)).rejects.toThrow();
47+
});
48+
49+
it('does NOT throw on a healthy subscription — adapters emit passing findings', async () => {
50+
fetchMock.mockResolvedValue(azureOkEmpty());
51+
52+
const findings = await service.scanSecurityFindings(creds, vars);
53+
expect(findings.length).toBeGreaterThan(0);
54+
});
55+
56+
it('does NOT throw when results are produced even though one adapter fails', async () => {
57+
fetchMock.mockImplementation(async (url: string) => {
58+
// Storage adapter fails; everything else succeeds (and emits findings).
59+
if (url.includes('Microsoft.Storage')) return azureError(500, 'boom');
60+
return azureOkEmpty();
61+
});
62+
63+
const findings = await service.scanSecurityFindings(creds, vars);
64+
expect(findings.length).toBeGreaterThan(0);
65+
});
66+
67+
it('still enforces required inputs (missing subscription throws AZURE_SUB_MISSING)', async () => {
68+
await expect(service.scanSecurityFindings(creds, {})).rejects.toThrow(
69+
/AZURE_SUB_MISSING/,
70+
);
71+
});
72+
});

‎apps/api/src/cloud-security/providers/azure-security.service.ts‎

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -106,41 +106,82 @@ export class AzureSecurityService {
106106

107107
this.logger.log(`Scanning Azure subscription ${subscriptionId}`);
108108
const findings: SecurityFinding[] = [];
109+
// Track unit failures so a scan that comes back EMPTY because every unit
110+
// (Defender + each adapter) errored fails loudly instead of being stored as
111+
// a fresh "success" run with 0 findings — which would hide the previous good
112+
// results (the UI shows only the latest run). A healthy subscription still
113+
// emits passing findings, so "empty result + a failure" is the real tell.
114+
let failedUnits = 0;
115+
let firstError: Error | null = null;
109116

110117
// 1. Defender alerts + assessments (always runs)
111118
if (!enabledServices || enabledServices.includes('defender')) {
112-
const defenderFindings = await this.scanDefender(token, subscriptionId);
113-
findings.push(...defenderFindings);
119+
const defender = await this.scanDefender(token, subscriptionId);
120+
findings.push(...defender.findings);
121+
if (!defender.anySucceeded) {
122+
failedUnits++;
123+
if (defender.firstError && !firstError) firstError = defender.firstError;
124+
}
114125
}
115126

116127
// 2. Run service adapters in parallel
117-
const adapterPromises = SERVICE_ADAPTERS.filter(
128+
const activeAdapters = SERVICE_ADAPTERS.filter(
118129
(a) => !enabledServices || enabledServices.includes(a.serviceId),
119-
).map(async (adapter) => {
120-
try {
121-
return await adapter.scan({ accessToken: token, subscriptionId });
122-
} catch (error) {
123-
const msg = error instanceof Error ? error.message : String(error);
124-
this.logger.warn(`Azure ${adapter.serviceId} scan failed: ${msg}`);
125-
return [];
130+
);
131+
const adapterResults = await Promise.all(
132+
activeAdapters.map(async (adapter) => {
133+
try {
134+
const scanned = await adapter.scan({
135+
accessToken: token,
136+
subscriptionId,
137+
});
138+
return { ok: true as const, findings: scanned };
139+
} catch (error) {
140+
const err = error instanceof Error ? error : new Error(String(error));
141+
this.logger.warn(`Azure ${adapter.serviceId} scan failed: ${err.message}`);
142+
return { ok: false as const, error: err };
143+
}
144+
}),
145+
);
146+
for (const result of adapterResults) {
147+
if (result.ok) {
148+
findings.push(...result.findings);
149+
} else {
150+
failedUnits++;
151+
if (!firstError) firstError = result.error;
126152
}
127-
});
153+
}
128154

129-
const adapterResults = await Promise.all(adapterPromises);
130-
for (const result of adapterResults) {
131-
findings.push(...result);
155+
// An empty result combined with a failed unit is a degenerate/broken scan,
156+
// not a genuinely-clean subscription (a clean scan still emits passing
157+
// findings). Throw so nothing is stored and the prior good run stays
158+
// visible. Mirrors the AWS all-regions-failed and GCP all-scopes-failed
159+
// guards. A truly-empty subscription (every unit succeeded, no failures)
160+
// still returns [] normally.
161+
if (findings.length === 0 && failedUnits > 0) {
162+
throw firstError ?? new Error('All Azure service scans failed');
132163
}
133164

134165
this.logger.log(`Azure scan complete: ${findings.length} total findings`);
135166
return findings;
136167
}
137168

138-
/** Scan Defender for Cloud alerts and assessments. */
169+
/**
170+
* Scan Defender for Cloud alerts and assessments. Reports whether at least
171+
* one query succeeded so the caller can tell a genuinely-clean subscription
172+
* (anySucceeded=true, no findings) apart from a total failure.
173+
*/
139174
private async scanDefender(
140175
accessToken: string,
141176
subscriptionId: string,
142-
): Promise<SecurityFinding[]> {
177+
): Promise<{
178+
findings: SecurityFinding[];
179+
anySucceeded: boolean;
180+
firstError: Error | null;
181+
}> {
143182
const findings: SecurityFinding[] = [];
183+
let anySucceeded = false;
184+
let firstError: Error | null = null;
144185

145186
// Alerts
146187
try {
@@ -173,13 +214,17 @@ export class AzureSecurityService {
173214
createdAt: alert.properties.startTimeUtc || new Date().toISOString(),
174215
});
175216
}
217+
anySucceeded = true;
176218
} catch (error) {
177219
this.handlePermissionError(
178220
findings,
179221
error,
180222
'Security Alerts',
181223
subscriptionId,
182224
);
225+
if (!firstError) {
226+
firstError = error instanceof Error ? error : new Error(String(error));
227+
}
183228
}
184229

185230
// Assessments
@@ -227,16 +272,20 @@ export class AzureSecurityService {
227272
createdAt: new Date().toISOString(),
228273
});
229274
}
275+
anySucceeded = true;
230276
} catch (error) {
231277
this.handlePermissionError(
232278
findings,
233279
error,
234280
'Security Assessments',
235281
subscriptionId,
236282
);
283+
if (!firstError) {
284+
firstError = error instanceof Error ? error : new Error(String(error));
285+
}
237286
}
238287

239-
return findings;
288+
return { findings, anySucceeded, firstError };
240289
}
241290

242291
private handlePermissionError(

‎apps/api/src/cloud-security/providers/gcp-security.service.spec.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,3 +698,128 @@ describe('resolveGcpServiceId — Vertex AI grouping', () => {
698698
);
699699
});
700700
});
701+
702+
// ── SCC findings response helpers ───────────────────────────────────────────
703+
function sccPage(
704+
findings: Array<Record<string, unknown>>,
705+
nextPageToken?: string,
706+
): { ok: true; json: () => Promise<unknown> } {
707+
return {
708+
ok: true,
709+
json: async () => ({
710+
listFindingsResults: findings,
711+
...(nextPageToken ? { nextPageToken } : {}),
712+
}),
713+
};
714+
}
715+
716+
function sccError(
717+
status: number,
718+
text: string,
719+
): { ok: false; status: number; text: () => Promise<string> } {
720+
return { ok: false, status, text: async () => text };
721+
}
722+
723+
function sccFinding(project: string): Record<string, unknown> {
724+
return {
725+
finding: {
726+
name: `organizations/1/sources/-/findings/${project}-1`,
727+
category: 'PUBLIC_BUCKET_ACL',
728+
description: 'desc',
729+
severity: 'HIGH',
730+
state: 'ACTIVE',
731+
resourceName: `//storage.googleapis.com/projects/${project}/buckets/b`,
732+
eventTime: '2026-01-01T00:00:00Z',
733+
createTime: '2026-01-01T00:00:00Z',
734+
},
735+
resource: {
736+
type: 'storage.googleapis.com/Bucket',
737+
projectDisplayName: project,
738+
},
739+
};
740+
}
741+
742+
describe('GCPSecurityService.scanSecurityFindings — all-scopes-failed guard', () => {
743+
let service: GCPSecurityService;
744+
let fetchMock: jest.Mock;
745+
const originalFetch = global.fetch;
746+
747+
beforeEach(() => {
748+
fetchMock = jest.fn();
749+
// @ts-expect-error replacing global fetch with a mock for these tests
750+
global.fetch = fetchMock;
751+
service = new GCPSecurityService();
752+
});
753+
754+
afterAll(() => {
755+
global.fetch = originalFetch;
756+
});
757+
758+
// The bug: when the only scope errored, the scan used to swallow the error and
759+
// return [] — which got stored as a fresh "success" run with 0 findings,
760+
// hiding the previous good results. It must now throw so nothing is stored.
761+
it('throws (re-throwing the actionable SCC error) when the only scope errors, instead of returning []', async () => {
762+
fetchMock.mockResolvedValue(
763+
sccError(403, 'PERMISSION_DENIED: caller lacks securitycenter.findings.list'),
764+
);
765+
766+
await expect(
767+
service.scanSecurityFindings(
768+
{ access_token: 'tok' },
769+
{ project_ids: ['wasimil-prod'] },
770+
),
771+
).rejects.toThrow(/Permission denied/);
772+
});
773+
774+
// The exact case from the customer report: project-scoped SCC query on a
775+
// no-org account returns 404 NOT_FOUND (SCC never activated for the project).
776+
it('maps a 404 NOT_FOUND to the actionable SCC_NOT_ACTIVATED error', async () => {
777+
fetchMock.mockResolvedValue(
778+
sccError(404, '{"error":{"code":404,"message":"Requested entity was not found.","status":"NOT_FOUND"}}'),
779+
);
780+
781+
await expect(
782+
service.scanSecurityFindings(
783+
{ access_token: 'tok' },
784+
{ project_ids: ['wasimil-prod'] },
785+
),
786+
).rejects.toThrow(/SCC_NOT_ACTIVATED/);
787+
});
788+
789+
it('throws when EVERY configured scope errors', async () => {
790+
fetchMock.mockResolvedValue(sccError(500, 'internal error'));
791+
792+
await expect(
793+
service.scanSecurityFindings(
794+
{ access_token: 'tok' },
795+
{ project_ids: ['p1', 'p2'] },
796+
),
797+
).rejects.toThrow();
798+
});
799+
800+
it('does NOT throw when at least one scope succeeds — returns the surviving scope’s findings', async () => {
801+
fetchMock.mockImplementation(async (url: string) => {
802+
if (url.includes('projects/p1')) return sccError(403, 'PERMISSION_DENIED');
803+
return sccPage([sccFinding('p2')]);
804+
});
805+
806+
const findings = await service.scanSecurityFindings(
807+
{ access_token: 'tok' },
808+
{ project_ids: ['p1', 'p2'] },
809+
);
810+
811+
expect(findings).toHaveLength(1);
812+
expect(findings[0].resourceId).toContain('projects/p2');
813+
});
814+
815+
it('does NOT throw when a scope succeeds with zero findings (genuinely clean account)', async () => {
816+
fetchMock.mockResolvedValue(sccPage([]));
817+
818+
const findings = await service.scanSecurityFindings(
819+
{ access_token: 'tok' },
820+
{ project_ids: ['wasimil-prod'] },
821+
);
822+
823+
expect(findings).toEqual([]);
824+
});
825+
});

0 commit comments

Comments
 (0)