import { all, first } from "../db"; import { CAPS } from "../limits"; import { loadRepoById } from "../repos"; import { detectorName } from "../security/detectors"; import { notifyQuietly, quiet } from "./deliver"; import { repoAdmins } from "./recipients"; /** Notification helpers for security events. Details carry detector names and paths only, never secret values. */ const MAX_PACKAGES_LISTED = 10; async function repoPath(repoId: string): Promise { const repo = await loadRepoById(repoId); return repo ? `/${repo.owner_name}/${repo.name}` : null; } async function notifySecretAlertsUnsafe( repoId: string, findings: { detector: string; path: string | null; line: number | null }[], ): Promise { if (findings.length === 0) return; const base = await repoPath(repoId); if (!base) return; const detectors = [...new Set(findings.map((finding) => detectorName(finding.detector)))]; const title = findings.length === 1 ? `${detectors[0]} secret detected in ${base.slice(1)}` : `${findings.length} secrets detected in ${base.slice(1)}`; await notifyQuietly({ category: "security_alert", repoId, accountId: null, access: "security", recipients: await repoAdmins(repoId, true), title, details: [ `Detectors: ${detectors.join(", ")}.`, ...findings .slice(0, 3) .filter((finding) => finding.path) .map((finding) => `Found in ${finding.path}${finding.line ? `:${finding.line}` : ""}.`), "Revoke the credential at its provider, then resolve the alert.", ], url: `${base}/security/secret-scanning`, }); } async function notifyPushProtectionBypassUnsafe( repoId: string, blockId: string, bypasserId: string, reason: string, detector: string, ): Promise { const base = await repoPath(repoId); if (!base) return; const actor = await first<{ name: string }>("SELECT name FROM accounts WHERE id = ?", bypasserId); const admins = (await repoAdmins(repoId, true)).filter((id) => id !== bypasserId); await notifyQuietly({ category: "security_alert", repoId, accountId: null, access: "security", recipients: admins, title: `Push protection bypassed in ${base.slice(1)}`, details: [ `${actor?.name ?? "Someone"} pushed a ${detectorName(detector)} secret and bypassed push protection.`, `Reason given: ${reason.replace(/_/g, " ")}.`, ], url: `${base}/security/secret-scanning/unblock/${blockId}`, }); } async function notifyCodeScanningAlertsUnsafe( repoId: string, ref: string, count: number, tools: string[], ): Promise { if (count === 0) return; const base = await repoPath(repoId); if (!base) return; await notifyQuietly({ category: "security_alert", repoId, accountId: null, access: "security", recipients: await repoAdmins(repoId, true), title: `${count} new code scanning ${count === 1 ? "alert" : "alerts"} in ${base.slice(1)}`, details: [`Tools: ${tools.join(", ")}.`, `Ref: ${ref}.`], url: `${base}/security/code-scanning`, }); } async function notifyDependencyAlertsUnsafe(repoId: string, packages: string[]): Promise { if (packages.length === 0) return; const base = await repoPath(repoId); if (!base) return; await notifyQuietly({ category: "security_alert", repoId, accountId: null, access: "security", recipients: await repoAdmins(repoId, true), title: `${packages.length} vulnerable ${packages.length === 1 ? "dependency" : "dependencies"} in ${base.slice(1)}`, details: [ `Affected: ${packages.slice(0, MAX_PACKAGES_LISTED).join(", ")}${packages.length > MAX_PACKAGES_LISTED ? ", …" : ""}.`, ], url: `${base}/security/dependencies`, }); } /** Advisory updates go to admins and security managers plus the reporter, re-checked against the advisory itself. */ async function notifyAdvisoryUpdateUnsafe( repoId: string, ghsaId: string, title: string, change: string, reporterId: string | null, actorId: string | null, ): Promise { const base = await repoPath(repoId); if (!base) return; const url = `${base}/security/advisories/${ghsaId}`; const admins = (await repoAdmins(repoId, true)).filter((id) => id !== actorId); const subject = `Advisory ${ghsaId} ${change}: ${title}`; await notifyQuietly({ category: "vulnerability_report", repoId, accountId: null, access: "security", recipients: admins, title: subject, details: [`The advisory was ${change}.`], url, }); const collaborators = await all<{ user_id: string }>( `SELECT c.user_id FROM advisory_collaborators c JOIN security_advisories a ON a.id = c.advisory_id WHERE a.repo_id = ? AND a.ghsa_id = ? LIMIT ?`, repoId, ghsaId, CAPS.notificationRecipients, ); const outside = [reporterId, ...collaborators.map((row) => row.user_id)].filter( (id): id is string => Boolean(id) && id !== actorId && !admins.includes(id ?? ""), ); if (outside.length > 0) { await notifyQuietly({ category: "vulnerability_report", repoId, accountId: null, access: "advisory", subject: ghsaId, recipients: outside, title: subject, details: [`An advisory you reported or collaborate on was ${change}.`], url, }); } } export const notifySecretAlerts = quiet("notifySecretAlerts", notifySecretAlertsUnsafe); export const notifyPushProtectionBypass = quiet("notifyPushProtectionBypass", notifyPushProtectionBypassUnsafe); export const notifyCodeScanningAlerts = quiet("notifyCodeScanningAlerts", notifyCodeScanningAlertsUnsafe); export const notifyDependencyAlerts = quiet("notifyDependencyAlerts", notifyDependencyAlertsUnsafe); export const notifyAdvisoryUpdate = quiet("notifyAdvisoryUpdate", notifyAdvisoryUpdateUnsafe);