Skip to content

Commit ac40d5b

Browse files
authored
Merge pull request #2655 from trycompai/main
[comp] Production Deploy
2 parents 8ae2e34 + 780928b commit ac40d5b

2 files changed

Lines changed: 186 additions & 9 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import {
2+
DescribeFlowLogsCommand,
3+
DescribeInstancesCommand,
4+
DescribeVpcsCommand,
5+
GetEbsEncryptionByDefaultCommand,
6+
DescribeSecurityGroupsCommand,
7+
} from '@aws-sdk/client-ec2';
8+
import { Ec2VpcAdapter } from './ec2-vpc.adapter';
9+
10+
type SendHandler = (command: unknown) => unknown;
11+
12+
function buildClient(handler: SendHandler) {
13+
return {
14+
send: jest.fn((command: unknown) => Promise.resolve(handler(command))),
15+
} as unknown as Parameters<Ec2VpcAdapter['scan']>[0]['credentials'] extends infer _
16+
? import('@aws-sdk/client-ec2').EC2Client
17+
: never;
18+
}
19+
20+
const noopInstancesResponse = { Reservations: [] };
21+
const noopSgResponse = { SecurityGroups: [] };
22+
const encryptedByDefaultResponse = { EbsEncryptionByDefault: true };
23+
24+
describe('Ec2VpcAdapter — checkVpcFlowLogs', () => {
25+
const adapter = new Ec2VpcAdapter();
26+
27+
// Call the private method through scan() with mocked client wiring.
28+
// scan() constructs its own EC2Client; we override EC2Client by spying on send().
29+
// To keep this test focused on flow-log logic, we mock the EC2Client module
30+
// and assert behavior via the returned findings.
31+
32+
function runScanWithFlowLogs(args: {
33+
vpcs: Array<{ VpcId: string; IsDefault?: boolean; Tags?: Array<{ Key: string; Value: string }> }>;
34+
flowLogPages: Array<{ FlowLogs: Array<{ ResourceId: string }>; NextToken?: string }>;
35+
hasRunningInstances?: boolean;
36+
}) {
37+
let flowLogPageIndex = 0;
38+
const handler: SendHandler = (command) => {
39+
if (command instanceof DescribeVpcsCommand) {
40+
return { Vpcs: args.vpcs };
41+
}
42+
if (command instanceof DescribeFlowLogsCommand) {
43+
const page = args.flowLogPages[flowLogPageIndex] ?? { FlowLogs: [] };
44+
flowLogPageIndex += 1;
45+
return page;
46+
}
47+
if (command instanceof DescribeInstancesCommand) {
48+
return args.hasRunningInstances
49+
? { Reservations: [{ Instances: [{ InstanceId: 'i-abc' }] }] }
50+
: noopInstancesResponse;
51+
}
52+
if (command instanceof GetEbsEncryptionByDefaultCommand) {
53+
return encryptedByDefaultResponse;
54+
}
55+
if (command instanceof DescribeSecurityGroupsCommand) {
56+
return noopSgResponse;
57+
}
58+
return {};
59+
};
60+
61+
const client = buildClient(handler);
62+
// Access the private method for focused testing.
63+
const fn = (
64+
adapter as unknown as {
65+
checkVpcFlowLogs: (
66+
c: unknown,
67+
region: string,
68+
accountId?: string,
69+
) => Promise<ReturnType<typeof adapter.scan> extends Promise<infer U> ? U : never>;
70+
}
71+
).checkVpcFlowLogs;
72+
return fn.call(adapter, client, 'us-east-1', '123456789012');
73+
}
74+
75+
it('passes a VPC when a VPC-scope flow log exists for it', async () => {
76+
const findings = await runScanWithFlowLogs({
77+
vpcs: [{ VpcId: 'vpc-abc123', IsDefault: false }],
78+
flowLogPages: [{ FlowLogs: [{ ResourceId: 'vpc-abc123' }] }],
79+
});
80+
81+
expect(findings).toHaveLength(1);
82+
expect(findings[0].id).toBe('vpc-flow-logs-vpc-abc123');
83+
expect(findings[0].passed).toBe(true);
84+
});
85+
86+
it('fails a VPC that only has subnet-scope flow logs', async () => {
87+
const findings = await runScanWithFlowLogs({
88+
vpcs: [{ VpcId: 'vpc-abc123', IsDefault: false }],
89+
flowLogPages: [
90+
{
91+
FlowLogs: [
92+
{ ResourceId: 'subnet-111' },
93+
{ ResourceId: 'subnet-222' },
94+
],
95+
},
96+
],
97+
});
98+
99+
expect(findings).toHaveLength(1);
100+
expect(findings[0].id).toBe('vpc-no-flow-logs-vpc-abc123');
101+
expect(findings[0].passed).toBe(false);
102+
});
103+
104+
it('fails a VPC that only has ENI-scope flow logs', async () => {
105+
const findings = await runScanWithFlowLogs({
106+
vpcs: [{ VpcId: 'vpc-abc123', IsDefault: false }],
107+
flowLogPages: [{ FlowLogs: [{ ResourceId: 'eni-deadbeef' }] }],
108+
});
109+
110+
expect(findings).toHaveLength(1);
111+
expect(findings[0].id).toBe('vpc-no-flow-logs-vpc-abc123');
112+
expect(findings[0].passed).toBe(false);
113+
});
114+
115+
it('fails a VPC when no flow logs exist at all', async () => {
116+
const findings = await runScanWithFlowLogs({
117+
vpcs: [{ VpcId: 'vpc-abc123', IsDefault: false }],
118+
flowLogPages: [{ FlowLogs: [] }],
119+
});
120+
121+
expect(findings).toHaveLength(1);
122+
expect(findings[0].id).toBe('vpc-no-flow-logs-vpc-abc123');
123+
expect(findings[0].passed).toBe(false);
124+
});
125+
126+
it('paginates DescribeFlowLogs and recognizes a VPC-scope flow log on a later page', async () => {
127+
const findings = await runScanWithFlowLogs({
128+
vpcs: [{ VpcId: 'vpc-abc123', IsDefault: false }],
129+
flowLogPages: [
130+
{
131+
FlowLogs: [{ ResourceId: 'subnet-1' }, { ResourceId: 'eni-1' }],
132+
NextToken: 'page-2',
133+
},
134+
{ FlowLogs: [{ ResourceId: 'vpc-abc123' }] },
135+
],
136+
});
137+
138+
expect(findings).toHaveLength(1);
139+
expect(findings[0].id).toBe('vpc-flow-logs-vpc-abc123');
140+
expect(findings[0].passed).toBe(true);
141+
});
142+
143+
it('handles multiple VPCs with mixed scopes correctly', async () => {
144+
const findings = await runScanWithFlowLogs({
145+
vpcs: [
146+
{ VpcId: 'vpc-aaa', IsDefault: false },
147+
{ VpcId: 'vpc-bbb', IsDefault: false },
148+
],
149+
flowLogPages: [
150+
{
151+
FlowLogs: [
152+
{ ResourceId: 'vpc-aaa' }, // vpc-aaa: VPC-scope → pass
153+
{ ResourceId: 'subnet-xyz' }, // subnet for vpc-bbb doesn't count
154+
],
155+
},
156+
],
157+
});
158+
159+
expect(findings).toHaveLength(2);
160+
const byId = Object.fromEntries(findings.map((f) => [f.resourceId, f]));
161+
expect(byId['vpc-aaa'].passed).toBe(true);
162+
expect(byId['vpc-bbb'].passed).toBe(false);
163+
});
164+
});

apps/api/src/cloud-security/providers/aws/ec2-vpc.adapter.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -190,15 +190,28 @@ export class Ec2VpcAdapter implements AwsServiceAdapter {
190190

191191
if (vpcs.length === 0) return findings;
192192

193-
const flowLogsResp = await client.send(
194-
new DescribeFlowLogsCommand({
195-
Filter: [{ Name: 'resource-type', Values: ['VPC'] }],
196-
}),
197-
);
198-
199-
const vpcsWithFlowLogs = new Set(
200-
(flowLogsResp.FlowLogs || []).map((fl) => fl.ResourceId),
201-
);
193+
// DescribeFlowLogs does not support a `resource-type` filter — the only
194+
// supported filters are documented in DescribeFlowLogsRequest (resource-id,
195+
// flow-log-id, traffic-type, etc.). We fetch all flow logs with pagination
196+
// and filter to VPC-scope by the `vpc-` ResourceId prefix client-side.
197+
// Subnet-scope (subnet-*) and ENI-scope (eni-*) flow logs are ignored
198+
// because they don't cover all VPC traffic.
199+
const vpcsWithFlowLogs = new Set<string>();
200+
let nextToken: string | undefined;
201+
do {
202+
const flowLogsResp = await client.send(
203+
new DescribeFlowLogsCommand({
204+
MaxResults: 1000,
205+
NextToken: nextToken,
206+
}),
207+
);
208+
for (const fl of flowLogsResp.FlowLogs || []) {
209+
if (fl.ResourceId && fl.ResourceId.startsWith('vpc-')) {
210+
vpcsWithFlowLogs.add(fl.ResourceId);
211+
}
212+
}
213+
nextToken = flowLogsResp.NextToken;
214+
} while (nextToken);
202215

203216
for (const vpc of vpcs) {
204217
if (!vpc.VpcId) continue;

0 commit comments

Comments
 (0)