Skip to content

Commit

Permalink
feat: AWS VCP Scanner (#275)
Browse files Browse the repository at this point in the history
Signed-off-by: Zachary Blasczyk <[email protected]>
Co-authored-by: Justin Brooks <[email protected]>
  • Loading branch information
zacharyblasczyk and jsbroks authored Dec 29, 2024
1 parent 69b49fd commit fdc4c53
Show file tree
Hide file tree
Showing 11 changed files with 4,777 additions and 17 deletions.
1 change: 1 addition & 0 deletions apps/docs/pages/integrations/aws/compute-scanner.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ creates resources for them automatically.
Currently, the AWS compute scanner supports importing the following resources:

- Amazon Elastic Kubernetes Service Clusters (EKS)
- Amazon Virtual Private Cloud (VPC)

## Managed AWS Compute Scanner

Expand Down
30 changes: 16 additions & 14 deletions apps/event-worker/src/resource-scan/aws/eks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,20 +161,22 @@ export const getEksResources = async (
const credentials = await assumeWorkspaceRole(workspaceRoleArn);
const workspaceStsClient = credentials.sts();

const resources = await _.chain(config.awsRoleArns)
.map((customerRoleArn) =>
scanEksClustersByAssumedRole(workspaceStsClient, customerRoleArn),
)
.thru((promises) => Promise.all(promises))
.value()
.then((results) => results.flat())
.then((resources) =>
resources.map((resource) => ({
...resource,
workspaceId: workspace.id,
providerId: config.resourceProviderId,
})),
);
const resources = config.importEks
? await _.chain(config.awsRoleArns)
.map((customerRoleArn) =>
scanEksClustersByAssumedRole(workspaceStsClient, customerRoleArn),
)
.thru((promises) => Promise.all(promises))
.value()
.then((results) => results.flat())
.then((resources) =>
resources.map((resource) => ({
...resource,
workspaceId: workspace.id,
providerId: config.resourceProviderId,
})),
)
: [];

const resourceTypes = _.countBy(resources, (resource) =>
[resource.kind, resource.version].join("/"),
Expand Down
10 changes: 8 additions & 2 deletions apps/event-worker/src/resource-scan/aws/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { logger } from "@ctrlplane/logger";

import { createResourceScanWorker } from "../utils.js";
import { getEksResources } from "./eks.js";
import { getVpcResources } from "./vpc.js";

const log = logger.child({ label: "resource-scan/aws" });

Expand All @@ -14,10 +15,15 @@ export const createAwsResourceScanWorker = () =>
return [];
}

const resources = await getEksResources(
const eksResources = await getEksResources(
rp.workspace,
rp.resource_provider_aws,
);

return resources;
const vpcResources = await getVpcResources(
rp.workspace,
rp.resource_provider_aws,
);

return [...eksResources, ...vpcResources];
});
207 changes: 207 additions & 0 deletions apps/event-worker/src/resource-scan/aws/vpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import type { Vpc } from "@aws-sdk/client-ec2";
import type { STSClient } from "@aws-sdk/client-sts";
import type { ResourceProviderAws, Workspace } from "@ctrlplane/db/schema";
import type { CloudVPCV1 } from "@ctrlplane/validators/resources";
import {
DescribeRegionsCommand,
DescribeSubnetsCommand,
DescribeVpcsCommand,
} from "@aws-sdk/client-ec2";
import _ from "lodash";
import { isPresent } from "ts-is-present";

import { logger } from "@ctrlplane/logger";
import { ReservedMetadataKey } from "@ctrlplane/validators/conditions";

import type { AwsCredentials } from "./aws.js";
import { omitNullUndefined } from "../../utils.js";
import { assumeRole, assumeWorkspaceRole } from "./aws.js";

const log = logger.child({ label: "resource-scan/aws/vpc" });

const convertVpcToCloudResource = (
accountId: string,
region: string,
vpc: Vpc,
subnets: {
name: string;
region: string;
cidr: string;
type: "public" | "private";
availabilityZone?: string;
}[] = [],
): CloudVPCV1 => {
const partition = region.startsWith("us-gov-") ? "aws-us-gov" : "aws";
const appUrl = `https://${
partition === "aws-us-gov"
? `console.${region}.${partition}`
: "console.aws.amazon"
}.com/vpcconsole/home?region=${region}#vpcs:search=${vpc.VpcId}`;

const name = vpc.Tags?.find((tag) => tag.Key === "Name")?.Value ?? vpc.VpcId!;

return {
name,
identifier: `aws/${accountId}/vpc/${vpc.VpcId}`,
version: "cloud/v1",
kind: "VPC",
config: {
name,
id: vpc.VpcId!,
provider: "aws",
region,
accountId: accountId,
cidr: vpc.CidrBlock,
subnets,
secondaryCidrs: vpc.CidrBlockAssociationSet?.filter(
(assoc) => assoc.CidrBlock !== vpc.CidrBlock,
).map((assoc) => ({
cidr: assoc.CidrBlock ?? "",
state: assoc.CidrBlockState?.State?.toLowerCase() ?? "",
})),
},
metadata: omitNullUndefined({
[ReservedMetadataKey.ExternalId]: vpc.VpcId,
[ReservedMetadataKey.Links]: JSON.stringify({ "AWS Console": appUrl }),
"aws/region": region,
"aws/state": vpc.State,
"aws/is-default": vpc.IsDefault,
"aws/dhcp-options-id": vpc.DhcpOptionsId,
"aws/instance-tenancy": vpc.InstanceTenancy,
...(vpc.Tags?.reduce(
(acc, tag) => ({
...acc,
[`aws/tag/${tag.Key}`]: tag.Value,
}),
{},
) ?? {}),
}),
};
};

const getAwsRegions = async (credentials: AwsCredentials) =>
credentials
.ec2()
.send(new DescribeRegionsCommand({}))
.then(({ Regions = [] }) => Regions.map((region) => region.RegionName));

const getVpcs = async (client: AwsCredentials, region: string) => {
const ec2Client = client.ec2(region);
const { Vpcs = [] } = await ec2Client.send(new DescribeVpcsCommand({}));
return Vpcs;
};

const getSubnets = async (
client: AwsCredentials,
region: string,
vpcId: string,
): Promise<
{
name: string;
region: string;
cidr: string;
type: "public" | "private";
availabilityZone: string;
}[]
> => {
const ec2Client = client.ec2(region);
const { Subnets = [] } = await ec2Client.send(
new DescribeSubnetsCommand({
Filters: [{ Name: "vpc-id", Values: [vpcId] }],
}),
);
return Subnets.map((subnet) => ({
name: subnet.SubnetId ?? "",
region,
cidr: subnet.CidrBlock ?? "",
type: subnet.MapPublicIpOnLaunch ? "public" : "private",
availabilityZone: subnet.AvailabilityZone ?? "",
}));
};

const createVpcScannerForRegion = (
client: AwsCredentials,
customerRoleArn: string,
) => {
const accountId = /arn:aws:iam::(\d+):/.exec(customerRoleArn)?.[1];
if (accountId == null) throw new Error("Missing account ID");

return async (region: string) => {
const vpcs = await getVpcs(client, region);
log.info(
`Found ${vpcs.length} VPCs for ${customerRoleArn} in region ${region}`,
);

const vpcResources = await Promise.all(
vpcs.map(async (vpc) => {
if (!vpc.VpcId) return null;
const subnets = await getSubnets(client, region, vpc.VpcId);
return convertVpcToCloudResource(accountId, region, vpc, subnets);
}),
);

return vpcResources.filter(isPresent);
};
};

const scanVpcsByAssumedRole = async (
workspaceClient: STSClient,
customerRoleArn: string,
) => {
const client = await assumeRole(workspaceClient, customerRoleArn);
const regions = await getAwsRegions(client);

log.info(
`Scanning ${regions.length} AWS regions for VPCs in account ${customerRoleArn}`,
);

const regionalVpcScanner = createVpcScannerForRegion(client, customerRoleArn);

return _.chain(regions)
.filter(isPresent)
.map(regionalVpcScanner)
.thru((promises) => Promise.all(promises))
.value()
.then((results) => results.flat());
};

export const getVpcResources = async (
workspace: Workspace,
config: ResourceProviderAws,
) => {
const { awsRoleArn: workspaceRoleArn } = workspace;
if (workspaceRoleArn == null) return [];

log.info(
`Scanning for VPCs with assumed role arns ${config.awsRoleArns.join(", ")} using role ${workspaceRoleArn}`,
{
workspaceId: workspace.id,
config,
workspaceRoleArn,
},
);

const credentials = await assumeWorkspaceRole(workspaceRoleArn);
const workspaceStsClient = credentials.sts();

const resources = config.importVpc
? await _.chain(config.awsRoleArns)
.map((customerRoleArn) =>
scanVpcsByAssumedRole(workspaceStsClient, customerRoleArn),
)
.thru((promises) => Promise.all(promises))
.value()
.then((results) => results.flat())
.then((resources) =>
resources.map((resource) => ({
...resource,
workspaceId: workspace.id,
providerId: config.resourceProviderId,
})),
)
: [];

log.info(`Found ${resources.length} VPC resources`);

return resources;
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
Expand All @@ -32,6 +33,7 @@ import {
} from "@ctrlplane/ui/form";
import { Input } from "@ctrlplane/ui/input";
import { Label } from "@ctrlplane/ui/label";
import { Switch } from "@ctrlplane/ui/switch";

import { api } from "~/trpc/react";

Expand All @@ -47,6 +49,8 @@ export const createAwsSchema = z.object({
),
}),
),
importEks: z.boolean().default(false),
importVpc: z.boolean().default(false),
});

export const AwsDialog: React.FC<{
Expand All @@ -58,6 +62,8 @@ export const AwsDialog: React.FC<{
defaultValues: {
name: "",
awsRoleArns: [{ value: "" }],
importEks: true,
importVpc: false,
},
mode: "onSubmit",
});
Expand All @@ -84,6 +90,7 @@ export const AwsDialog: React.FC<{
...data,
workspaceId: workspace.id,
config: {
...data,
awsRoleArns: data.awsRoleArns.map((a) => a.value),
},
});
Expand Down Expand Up @@ -194,6 +201,48 @@ export const AwsDialog: React.FC<{
</Button>
</div>

<FormField
control={form.control}
name="importEks"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Import EKS Clusters</FormLabel>
<FormDescription>
Enable importing of EKS clusters from your AWS accounts
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>

<FormField
control={form.control}
name="importVpc"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Import VPCs</FormLabel>
<FormDescription>
Enable importing of VPCs from your AWS accounts
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>

<DialogFooter>
<Button type="submit">Create</Button>
</DialogFooter>
Expand Down
Loading

0 comments on commit fdc4c53

Please sign in to comment.