packages/repocop/src/remediation/vuln-digest/vuln-digest.ts (163 lines of code) (raw):
import { Anghammarad, RequestedChannel } from '@guardian/anghammarad';
import type { view_repo_ownership } from '@prisma/client';
import { daysLeftToFix } from 'common/src/functions';
import { SLAs } from 'common/src/types';
import type { RepocopVulnerability } from 'common/src/types';
import type { Config } from '../../config';
import type { EvaluationResult, Team, VulnerabilityDigest } from '../../types';
import { removeRepoOwner } from '../shared-utilities';
function getOwningRepos(
team: Team,
repoOwners: view_repo_ownership[],
results: EvaluationResult[],
) {
const reposOwnedByTeam = repoOwners.filter(
(repoOwner) => repoOwner.github_team_id === team.id,
);
const resultsOwnedByTeam = reposOwnedByTeam
.map((repo) => {
return results.find((result) => result.fullName === repo.full_repo_name);
})
.filter((result): result is EvaluationResult => result !== undefined);
return resultsOwnedByTeam;
}
function createHumanReadableVulnMessage(vuln: RepocopVulnerability): string {
const ecosystem =
vuln.ecosystem === 'maven' ? 'sbt or maven' : vuln.ecosystem;
const daysToFix = daysLeftToFix(vuln.alert_issue_date, vuln.severity);
const vulnHyperlink: string = vuln.urls[0]
? `[${vuln.package}](${vuln.urls[0]})`
: vuln.package;
const cveHyperlink = vuln.cves[0] ?? 'no CVE provided';
return String.raw`[${removeRepoOwner(vuln.full_name)}](https://github.com/${vuln.full_name}) contains a ${vuln.severity} severity vulnerability, ${cveHyperlink}, from ${vulnHyperlink}, introduced via ${ecosystem}.
There are ${daysToFix} days left to fix this vulnerability. It ${vuln.is_patchable ? 'is ' : 'might not be '}patchable.`;
}
function createTeamDashboardLinkAction(team: Team, vulnCount: number) {
return {
cta: `View all ${vulnCount} vulnerabilities on Grafana`,
url: `https://metrics.gutools.co.uk/d/fdib3p8l85jwgd?var-repo_owner=${team.slug}`,
};
}
export function createDigestForSeverity(
team: Team,
severity: 'critical' | 'high',
repoOwners: view_repo_ownership[],
results: EvaluationResult[],
cutOffInDays: number,
): VulnerabilityDigest | undefined {
const resultsForTeam: EvaluationResult[] = getOwningRepos(
team,
repoOwners,
results,
);
const vulns = resultsForTeam.flatMap((r) => r.vulnerabilities);
const cutOffDate = new Date();
cutOffDate.setDate(cutOffDate.getDate() - cutOffInDays);
const patchableFirst = (a: RepocopVulnerability, b: RepocopVulnerability) => {
if (a.is_patchable && !b.is_patchable) {
return -1;
}
if (!a.is_patchable && b.is_patchable) {
return 1;
}
return 0;
};
const vulnsSinceImplementationDate = vulns
.filter(
(v) =>
v.severity == severity && new Date(v.alert_issue_date) > cutOffDate,
)
.sort(patchableFirst);
const totalNewVulnsCount = vulnsSinceImplementationDate.length;
if (totalNewVulnsCount === 0) {
return undefined;
}
const preamble = String.raw`Found ${totalNewVulnsCount} ${severity} vulnerabilities introduced in the last ${cutOffInDays} days. Teams have ${SLAs[severity]} days to fix these.
Note: DevX only aggregates vulnerability information for runtime dependencies in repositories with a production topic.`;
const digestString = vulnsSinceImplementationDate
.map((v) => createHumanReadableVulnMessage(v))
.join('\n\n');
const message = `${preamble}\n\n${digestString}`;
const actions = [createTeamDashboardLinkAction(team, vulns.length)];
return {
teamSlug: team.slug,
subject: `Vulnerability Digest for ${team.name}`,
message,
actions,
};
}
async function sendVulnerabilityDigests(
digests: VulnerabilityDigest[],
config: Config,
) {
const anghammarad = new Anghammarad();
console.log(
`Sending ${digests.length} vulnerability digests: ${digests
.map((d) => d.teamSlug)
.join(', ')}`,
);
return Promise.all(
digests.map(
async (digest) =>
await anghammarad.notify({
subject: digest.subject,
message: digest.message,
actions: digest.actions,
target: { GithubTeamSlug: digest.teamSlug },
channel: RequestedChannel.PreferHangouts,
sourceSystem: `${config.app} ${config.stage}`,
topicArn: config.anghammaradSnsTopic,
threadKey: `vulnerability-digest-${digest.teamSlug}`,
}),
),
);
}
export async function createAndSendVulnDigestsForSeverity(
config: Config,
teams: Team[],
repoOwners: view_repo_ownership[],
results: EvaluationResult[],
severity: 'critical' | 'high',
maxVulnAgeInDays: number = 60,
) {
const digests = teams
.map((t) =>
createDigestForSeverity(
t,
severity,
repoOwners,
results,
maxVulnAgeInDays,
),
)
.filter((d): d is VulnerabilityDigest => d !== undefined);
console.log(`Logging ${severity} vulnerability digests`);
digests.forEach((digest) => console.log(JSON.stringify(digest)));
if (config.stage === 'PROD') {
await sendVulnerabilityDigests(digests, config);
}
}
export async function createAndSendVulnerabilityDigests(
config: Config,
teams: Team[],
repoOwners: view_repo_ownership[],
evaluationResults: EvaluationResult[],
) {
await createAndSendVulnDigestsForSeverity(
config,
teams,
repoOwners,
evaluationResults,
'critical',
);
const isTuesday = new Date().getDay() === 2;
if (isTuesday) {
await createAndSendVulnDigestsForSeverity(
config,
teams,
repoOwners,
evaluationResults,
'high',
);
}
}