Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Small cloudbuster fixes #1279

Merged
merged 8 commits into from
Oct 8, 2024
4 changes: 2 additions & 2 deletions packages/cloudbuster/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ export interface Config extends PrismaConfig {
/**
* Anghammarad's topic ARN
*/
anghammaradSnsTopic?: string;
anghammaradSnsTopic: string;
}

export async function getConfig(): Promise<Config> {
const stage = getEnvOrThrow('STAGE');
const anghammaradSnsTopic = process.env['ANGHAMMARAD_SNS_ARN'];
const anghammaradSnsTopic: string = getEnvOrThrow('ANGHAMMARAD_SNS_ARN');

const isDev = stage === 'DEV';

Expand Down
62 changes: 60 additions & 2 deletions packages/cloudbuster/src/digests.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,64 @@
import type { cloudbuster_fsbp_vulnerabilities } from '@prisma/client';
import type { SecurityHubSeverity } from 'common/types';
import { createDigestsFromFindings } from './digests';
import { createDigestForAccount, createDigestsFromFindings } from './digests';

describe('createDigestForAccount', () => {
NovemberTang marked this conversation as resolved.
Show resolved Hide resolved
it('should return nothing if no vulnerabilities are passed to it', () => {
const actual = createDigestForAccount([]);
expect(actual).toBeUndefined();
});

const testVuln: cloudbuster_fsbp_vulnerabilities = {
aws_account_id: '123456789012',
aws_account_name: 'test-account',
aws_region: 'eu-west-1',
arn: 'arn:aws:service:eu-west-1:123456789012',
control_id: 'S.1',
first_observed_at: new Date(),
remediation: 'https://example.com',
severity: 'CRITICAL',
repo: null,
stack: null,
stage: null,
app: null,
title: 'test-title',
within_sla: false,
};

it('should return a digest with the correct fields', () => {
const actual = createDigestForAccount([testVuln]);
expect(actual).toEqual({
accountId: '123456789012',
accountName: 'test-account',
actions: [
{
cta: 'View all findings on Grafana',
url: 'https://metrics.gutools.co.uk/d/ddi3x35x70jy8d?var-account_name=test-account',
},
],
subject: 'Security Hub findings for AWS account test-account',
message: `The following vulnerabilities have been found in your account in the last 60 days:
**[CRITICAL] test-title**
Affected resource: arn:aws:service:eu-west-1:123456789012
Remediation: [Documentation](https://example.com)`,
});
});
it('should return nothing if the first observed date is older than the cut-off', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a nice candidate for property-based testing. No need to do it here, but if you're interested to look at doing that together at any point we can drop fast-check into the project and try it out?

const vuln = { ...testVuln, first_observed_at: new Date(0) };
const actual = createDigestForAccount([vuln]);
expect(actual).toBeUndefined();
});
it('should correctly encode the account name in the CTA URL', () => {
const vuln = { ...testVuln, aws_account_name: 'test account' };
const actual = createDigestForAccount([vuln]);
expect(actual?.actions[0]?.url).toContain('test%20account');
});
it('should not return a digest if the account name is null', () => {
const vuln = { ...testVuln, aws_account_name: null };
const actual = createDigestForAccount([vuln]);
expect(actual).toBeUndefined();
});
});

function mockFinding(
aws_account_id: string,
Expand All @@ -14,7 +72,7 @@ function mockFinding(
remediation: 'https://mock.url/mock',
severity,
within_sla: true,
first_observed_at: new Date('2020-01-01'),
first_observed_at: new Date(),
control_id: 'MOCK.1',
aws_region: 'eu-mock-1',
repo: null,
Expand Down
81 changes: 57 additions & 24 deletions packages/cloudbuster/src/digests.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { RequestedChannel } from '@guardian/anghammarad';
import type { Anghammarad, NotifyParams } from '@guardian/anghammarad';
import type { Action, Anghammarad, NotifyParams } from '@guardian/anghammarad';
import type { cloudbuster_fsbp_vulnerabilities } from '@prisma/client';
import type { SecurityHubSeverity } from 'common/src/types';
import { type Config } from './config';
Expand All @@ -24,35 +24,56 @@ export function createDigestsFromFindings(
.filter((d): d is Digest => d !== undefined);
}

function createDigestForAccount(
function createCta(aws_account_name: string): Action[] {
if (!aws_account_name) {
return [];
} else {
return [
{
cta: `View all findings on Grafana`,
url: `https://metrics.gutools.co.uk/d/ddi3x35x70jy8d?var-account_name=${encodeURIComponent(aws_account_name)}`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be nice to pass in the dashboard name as a param to make it easier to parse as a human

Copy link
Contributor Author

@NovemberTang NovemberTang Oct 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand what you mean by dashboard name? Or what would be easier to parse?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant this bit: ddi3x35x70jy8d - maybe we could have a var called fsbpCompliance or somesuch in its place (since it's already a template string) - just to make it easier to see where it goes at a glance? Not that important though!

},
];
}
}

export function createDigestForAccount(
accountFindings: cloudbuster_fsbp_vulnerabilities[],
): Digest | undefined {
if (accountFindings.length === 0 || !accountFindings[0]) {
const vulnCutOffInDays = 60;

const cutOffDate = new Date();
cutOffDate.setDate(cutOffDate.getDate() - vulnCutOffInDays);
const recentFindings = accountFindings.filter(
(f) => f.first_observed_at && f.first_observed_at > cutOffDate,
);

if (recentFindings.length === 0 || !recentFindings[0]) {
return undefined;
}

const [finding] = accountFindings;
const [finding] = recentFindings;

const { aws_account_name, aws_account_id } = finding;
if (aws_account_name) {
return {
accountId: aws_account_id,

const actions = [
{
cta: `View all ${accountFindings.length} findings on Grafana`,
url: `https://metrics.gutools.co.uk/d/ddi3x35x70jy8d?var-account_name=${aws_account_name}`,
},
];

return {
accountId: aws_account_id,
accountName: aws_account_name as string,
actions,
subject: `Security Hub findings for AWS account ${aws_account_name}`,
message: createEmailBody(accountFindings),
};
accountName: aws_account_name,
actions: createCta(aws_account_name),
NovemberTang marked this conversation as resolved.
Show resolved Hide resolved
subject: `Security Hub findings for AWS account ${aws_account_name}`,
message: createEmailBody(recentFindings, vulnCutOffInDays),
};
} else {
return undefined;
}
}

function createEmailBody(findings: cloudbuster_fsbp_vulnerabilities[]): string {
return `The following vulnerabilities have been found in your account:
function createEmailBody(
findings: cloudbuster_fsbp_vulnerabilities[],
cutOffInDays: number,
): string {
return `The following vulnerabilities have been found in your account in the last ${cutOffInDays} days:
${findings
.map(
(f) =>
Expand All @@ -68,25 +89,37 @@ export async function sendDigest(
config: Config,
digest: Digest,
): Promise<void> {
// TODO replace this with `{ AwsAccount: digest.accountId }` to send real alerts
const target = { Stack: 'testing-alerts' };

const notifyParams: NotifyParams = {
subject: digest.subject,
message: digest.message,
actions: digest.actions,
target,
target: { AwsAccount: digest.accountId },
threadKey: digest.accountId,
channel: RequestedChannel.HangoutsChat,
sourceSystem: `cloudbuster ${config.stage}`,
topicArn: config.anghammaradSnsTopic as string,
topicArn: config.anghammaradSnsTopic,
};

if (config.enableMessaging) {
const { enableMessaging, stage } = config;

if (enableMessaging && stage == 'PROD') {
console.log(
`Sending ${digest.accountId} digest to ${JSON.stringify(target, null, 4)}...`,
);
await anghammaradClient.notify(notifyParams);
} else if (enableMessaging) {
const testNotifyParams = {
...notifyParams,
target: { Stack: 'testing-alerts' },
};

console.log(
`Sending ${digest.accountId} digest to ${JSON.stringify(target, null, 4)}...`,
);

await anghammaradClient.notify(testNotifyParams);
} else {
console.log(
`Messaging disabled. Anghammarad would have sent: ${JSON.stringify(notifyParams, null, 4)}`,
Expand Down
5 changes: 5 additions & 0 deletions packages/cloudbuster/src/run-locally.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { homedir } from 'os';
import { config } from 'dotenv';
import { main } from './index';

config({ path: `../../.env` }); // Load `.env` file at the root of the repository
config({ path: `${homedir()}/.gu/service_catalogue/.env.local` });

if (require.main === module) {
void main();
}
Loading