diff --git a/packages/obligatron/src/obligations/index.ts b/packages/obligatron/src/obligations/index.ts index 917de45c7..28c7091d6 100644 --- a/packages/obligatron/src/obligations/index.ts +++ b/packages/obligatron/src/obligations/index.ts @@ -19,5 +19,5 @@ export type ObligationResult = { /** * Key-value pairs to link failing obligations to the responsible teams. */ - contacts?: Record; + contacts?: Record; }; diff --git a/packages/obligatron/src/obligations/tagging.test.ts b/packages/obligatron/src/obligations/tagging.test.ts index e6c42c974..7f991eed4 100644 --- a/packages/obligatron/src/obligations/tagging.test.ts +++ b/packages/obligatron/src/obligations/tagging.test.ts @@ -1,166 +1,136 @@ import type { PrismaClient } from '@prisma/client'; -import type { AwsResource } from './tagging'; import { evaluateTaggingObligation } from './tagging'; -const createPrismaClientWithMockedResponse = (response: AwsResource[]) => { +const createPrismaClientWithMockedResponse = (response: unknown[]) => { + const aws_securityhub_findings = { + findMany: () => Promise.resolve(response), + }; + const test = { - $queryRaw: () => Promise.resolve(response), + aws_securityhub_findings, } as unknown as PrismaClient; return test; }; describe('The tagging obligation', () => { - it('passes correct resource', async () => { + it('catches failed securityhub findings', async () => { const client = createPrismaClientWithMockedResponse([ { - account_id: '123456789012', - arn: 'arn:aws:s3:::mybucket', - service: 's3', - resource_type: 'bucket', - taggable: 'true', - tags: { - Stack: 'my-stack', - Stage: 'prod', - App: 'myapp', - 'gu:repo': 'myrepo', - }, + id: '123456789012', + title: 'failed tagging', + region: 'mars-north-1', + aws_account_id: '123456789012', + resources: [ + { + Id: 'arn:aws:s3:::mybucket', + Tags: { + Stack: 'my-stack', + Stage: 'prod', + App: 'myapp', + 'gu:repo': 'myrepo', + }, + }, + ], }, - ]); - - const results = await evaluateTaggingObligation(client); - - expect(results).toHaveLength(0); - }); - - it('catches missing Stack tags', async () => { - const client = createPrismaClientWithMockedResponse([ { - account_id: '123456789012', - arn: 'arn:aws:s3:::mybucket', - service: 's3', - resource_type: 'bucket', - taggable: 'true', - tags: { - Stage: 'prod', - App: 'myapp', - 'gu:repo': 'myrepo', - }, + id: '123456789012', + title: 'failed tagging', + region: 'mars-north-1', + aws_account_id: '123456789012', + resources: [ + { + Id: 'arn:aws:s3:::mybucket', + Tags: { + Stack: 'my-stack', + Stage: 'prod', + App: 'myapp', + }, + }, + ], }, ]); const results = await evaluateTaggingObligation(client); - expect(results).toHaveLength(1); + expect(results).toHaveLength(2); expect(results[0]).toEqual({ resource: 'arn:aws:s3:::mybucket', - reason: "Resource missing 'Stack' tag.", - contacts: { aws_account: '123456789012' }, - }); - }); - - it('catches missing Stage tags', async () => { - const client = createPrismaClientWithMockedResponse([ - { - account_id: '123456789012', - arn: 'arn:aws:s3:::mybucket', - service: 's3', - resource_type: 'bucket', - taggable: 'true', - tags: { - Stack: 'my-stack', - App: 'myapp', - 'gu:repo': 'myrepo', - }, + reason: 'failed tagging', + contacts: { + aws_account_id: '123456789012', + Stack: 'my-stack', + Stage: 'prod', + App: 'myapp', }, - ]); - - const results = await evaluateTaggingObligation(client); - - expect(results).toHaveLength(1); - expect(results[0]).toEqual({ - resource: 'arn:aws:s3:::mybucket', - reason: "Resource missing 'Stage' tag.", - contacts: { aws_account: '123456789012' }, + url: 'https://mars-north-1.console.aws.amazon.com/securityhub/home?region=mars-north-1#/findings?search=RecordState%3D%255Coperator%255C%253AEQUALS%255C%253AACTIVE%26Id%3D%255Coperator%255C%253AEQUALS%255C%253A123456789012', }); }); - it('catches missing App tags', async () => { + it('handles findings with no resources', async () => { const client = createPrismaClientWithMockedResponse([ { - account_id: '123456789012', - arn: 'arn:aws:s3:::mybucket', - service: 's3', - resource_type: 'bucket', - taggable: 'true', - tags: { - Stack: 'my-stack', - Stage: 'prod', - 'gu:repo': 'myrepo', - }, + id: '123456789012', + title: 'failed tagging', + region: 'mars-north-1', + aws_account_id: '123456789012', + resources: [], }, ]); const results = await evaluateTaggingObligation(client); - expect(results).toHaveLength(1); - expect(results[0]).toEqual({ - resource: 'arn:aws:s3:::mybucket', - reason: "Resource missing 'App' tag.", - contacts: { aws_account: '123456789012' }, - }); + expect(results).toHaveLength(0); }); - it('catches missing Repo tags', async () => { + it('handles findings with incorrect amount of resources', async () => { const client = createPrismaClientWithMockedResponse([ { - account_id: '123456789012', - arn: 'arn:aws:s3:::mybucket', - service: 's3', - resource_type: 'bucket', - taggable: 'true', - tags: { - Stack: 'my-stack', - Stage: 'prod', - App: 'myapp', - }, + id: '123456789012', + title: 'failed tagging', + region: 'mars-north-1', + aws_account_id: '123456789012', + resources: [ + { + Id: 'arn:aws:s3:::mybucket', + Tags: { + Stack: 'my-stack', + Stage: 'prod', + App: 'myapp', + 'gu:repo': 'myrepo', + }, + }, + { + Id: 'arn:aws:s3:::mybucket', + Tags: { + Stack: 'my-stack', + Stage: 'prod', + App: 'myapp', + 'gu:repo': 'myrepo', + }, + }, + ], }, ]); const results = await evaluateTaggingObligation(client); - expect(results).toHaveLength(1); - expect(results[0]).toEqual({ - resource: 'arn:aws:s3:::mybucket', - reason: "Resource missing 'gu:repo' tag.", - contacts: { aws_account: '123456789012' }, - }); + expect(results).toHaveLength(2); }); - it('catches empty tags', async () => { + it('crashes on findings with an invalid Resource schema', async () => { const client = createPrismaClientWithMockedResponse([ { - account_id: '123456789012', - arn: 'arn:aws:s3:::mybucket', - service: 's3', - resource_type: 'bucket', - taggable: 'true', - tags: { - Stack: '', - Stage: '', - App: '', - 'gu:repo': '', - }, + id: '123456789012', + title: 'failed tagging', + region: 'mars-north-1', + aws_account_id: '123456789012', + resources: [{}], }, ]); - const results = await evaluateTaggingObligation(client); - - expect(results).toHaveLength(4); - expect(results[0]).toEqual({ - resource: 'arn:aws:s3:::mybucket', - reason: "Resource missing 'Stack' tag.", - contacts: { aws_account: '123456789012' }, - }); + await expect(evaluateTaggingObligation(client)).rejects.toEqual( + new Error('Invalid resource in finding 123456789012 at index 0'), + ); }); }); diff --git a/packages/obligatron/src/obligations/tagging.ts b/packages/obligatron/src/obligations/tagging.ts index d4ee082a2..3cad9dc49 100644 --- a/packages/obligatron/src/obligations/tagging.ts +++ b/packages/obligatron/src/obligations/tagging.ts @@ -1,68 +1,103 @@ import type { PrismaClient } from '@prisma/client'; import type { ObligationResult } from '.'; -export type AwsResource = { - account_id: string; - arn: string; - service: string; - resource_type: string; - taggable: string; - tags?: Record | null; +type FindingResource = { + Id: string; + Tags: Record; }; -const REQUIRED_TAGS = ['Stack', 'Stage', 'App', 'gu:repo'] as const; +const securityHubLink = (region: string, findingId: string) => { + // Annoyingly AWS doesn't seem to use any standard encoding that I'm aware of + // meaning that we can't use encodeURI and must manually build the encoded URL. + const BACK_SLASH = '%255C'; + const SLASH = '%252F'; + const SEMI_COLON = '%253A'; -const isExemptResource = (resource: AwsResource): boolean => { - if (resource.resource_type === 'role') { - // AWS Creates roles for various services when onboarding them. - // Technically we can tag these but they wouldn't belong to a specific App, Stage, or Stack. - return resource.arn.includes('/aws-service-role/'); - } + const EQUALS = encodeURIComponent('='); // This one is actually standard encoding for = + const AMPERSANS = encodeURIComponent('&'); // This one is actually standard encoding for & + + const queryParameter = + `RecordState=\\operator\\:EQUALS\\:ACTIVE&Id=\\operator\\:EQUALS\\:${findingId}` + .replaceAll(':', SEMI_COLON) + .replaceAll('/', SLASH) + .replaceAll('\\', BACK_SLASH) + .replaceAll('=', EQUALS) + .replaceAll('&', AMPERSANS); - return false; + return `https://${region}.console.aws.amazon.com/securityhub/home?region=${region}#/findings?search=${queryParameter}`; }; -function resourceHasTag(resource: AwsResource, tag: string): boolean { - return ( - typeof resource.tags === 'object' && - resource.tags?.[tag] !== undefined && - resource.tags[tag] !== '' - ); -} +const isFindingResource = (resource: unknown): resource is FindingResource => + typeof resource === 'object' && + resource != null && + 'Id' in resource && + 'Tags' in resource; export async function evaluateTaggingObligation( db: PrismaClient, ): Promise { - const awsResources = await db.$queryRaw` - SELECT - account_id, - arn, - service, - resource_type, - bool_or(taggable) as taggable, - jsonb_aggregate(tags) as tags - FROM aws_resources_raw() - WHERE taggable = true - GROUP BY account_id, arn, service, resource_type; - `; + const findings = await db.aws_securityhub_findings.findMany({ + where: { + product_fields: { + path: ['StandardsArn'], + string_starts_with: + 'arn:aws:securityhub:::standards/aws-resource-tagging-standard', + }, + compliance: { + path: ['Status'], + equals: 'FAILED', + }, + }, + }); + + console.log({ + message: 'Received findings from security hub', + total: findings.length, + }); const results: ObligationResult[] = []; - for (const resource of awsResources) { - if (isExemptResource(resource)) { + for (const finding of findings) { + const resources = finding.resources?.valueOf(); + + if (!Array.isArray(resources)) { + console.error({ + message: `Skipping invalid SecurityHub finding, invalid 'resources' field`, + finding_id: finding.id, + }); continue; } - for (const requiredTag of REQUIRED_TAGS) { - if (resourceHasTag(resource, requiredTag)) { - continue; + // The Security Hub spec indicates that a finding can have multiple resources, + // I don't think this will happen for the Tagging rules, but lets be safe by + // handling this situation and raising a warning. + if (resources.length !== 1) { + console.warn({ + message: `Finding had more (or less) that 1 resource: ${resources.length}`, + finding_id: finding.id, + }); + } + + for (const [index, resource] of resources.entries()) { + // This in theory should not happen as long as AWS don't change their schema. + // if they do change the schema its unlikely to be just this one finding failing, + // so lets make sure that we crash the lambda and get a humans attention! + if (!isFindingResource(resource)) { + throw new Error( + `Invalid resource in finding ${finding.id} at index ${index}`, + ); } results.push({ - resource: resource.arn, - reason: `Resource missing '${requiredTag}' tag.`, + resource: resource.Id, + reason: finding.title, + url: securityHubLink(finding.region, finding.id), contacts: { - aws_account: resource.account_id, + aws_account_id: finding.aws_account_id, + // Resource might only be missing one of these tags which might help us assert ownership + Stack: resource.Tags.Stack, + Stage: resource.Tags.Stage, + App: resource.Tags.App, }, }); }