import { MAX_IN_LIST, chunks, placeholders } from "../db"; import { repoDb } from "../scoped-db"; import { accountsById } from "../people"; import { randomId } from "../crypto"; import { forbidden } from "../errors"; import { detectorName } from "./detectors"; import type { RedactedFinding } from "./detectors"; import { nextSecurityNumber } from "./settings"; import { notifySecretAlerts } from "../notify/security"; const MAX_ALERTS_PER_SCAN = 100; const ALERT_PAGE = 50; export type SecretResolution = "revoked" | "false_positive" | "used_in_tests" | "wont_fix"; export type LocatedFinding = RedactedFinding & { path: string | null; commitSha: string | null }; export type SecretAlert = { number: number; detector: string; detectorName: string; preview: string; path: string | null; line: number | null; commitSha: string | null; state: "open" | "resolved"; resolution: string | null; resolvedBy: string | null; resolvedAt: number | null; createdAt: number; }; type AlertRow = { number: number; detector: string; preview: string; path: string | null; line: number | null; commit_sha: string | null; state: "open" | "resolved"; resolution: string | null; resolver: string | null; resolved_at: number | null; created_at: number; }; function toAlert(row: AlertRow): SecretAlert { return { number: row.number, detector: row.detector, detectorName: detectorName(row.detector), preview: row.preview, path: row.path, line: row.line, commitSha: row.commit_sha, state: row.state, resolution: row.resolution, resolvedBy: row.resolver, resolvedAt: row.resolved_at, createdAt: row.created_at, }; } /** Records one alert per distinct secret fingerprint; repeats of a known secret are ignored. */ export async function recordSecretAlerts(repoId: string, findings: LocatedFinding[]): Promise { const unique = [...new Map(findings.map((finding) => [finding.fingerprint, finding])).values()].slice( 0, MAX_ALERTS_PER_SCAN, ); const known = new Set(); for (const part of chunks(unique.map((finding) => finding.fingerprint))) { const rows = await repoDb(repoId).all<{ fingerprint: string }>( `SELECT fingerprint FROM secret_alerts WHERE repo_id = ? AND fingerprint IN (${placeholders(part.length)})`, repoId, ...part, ); for (const row of rows) known.add(row.fingerprint); } let created = 0; const fresh: LocatedFinding[] = []; const now = Date.now(); for (const finding of unique) { if (known.has(finding.fingerprint)) continue; const number = await nextSecurityNumber(repoId, "secret"); const result = await repoDb(repoId).run( `INSERT OR IGNORE INTO secret_alerts (id, repo_id, number, detector, fingerprint, preview, path, line, commit_sha, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, randomId(), repoId, number, finding.detector, finding.fingerprint, finding.preview, finding.path, finding.line, finding.commitSha, now, now, ); created += result.meta.changes; if (result.meta.changes > 0) fresh.push(finding); } await notifySecretAlerts(repoId, fresh); return created; } const ALERT_SELECT = `SELECT s.number, s.detector, s.preview, s.path, s.line, s.commit_sha, s.state, s.resolution, s.resolved_by, s.resolved_at, s.created_at FROM secret_alerts s`; export async function listSecretAlerts(repoId: string, state: "open" | "resolved", page: number) { const stored = await repoDb(repoId).all & { resolved_by: string | null }>( `${ALERT_SELECT} WHERE s.repo_id = ? AND s.state = ? ORDER BY s.number DESC LIMIT ? OFFSET ?`, repoId, state, ALERT_PAGE + 1, (page - 1) * ALERT_PAGE, ); const resolvers = await accountsById(stored.map((row) => row.resolved_by)); const rows: AlertRow[] = stored.map(({ resolved_by: resolvedBy, ...row }) => ({ ...row, resolver: resolvedBy ? (resolvers.get(resolvedBy)?.name ?? null) : null, })); return { alerts: rows.slice(0, ALERT_PAGE).map(toAlert), hasMore: rows.length > ALERT_PAGE }; } export async function secretAlertCounts(repoId: string): Promise<{ open: number; resolved: number }> { const row = await repoDb(repoId).first<{ open: number; resolved: number }>( `SELECT COALESCE(SUM(state = 'open'), 0) AS open, COALESCE(SUM(state = 'resolved'), 0) AS resolved FROM secret_alerts WHERE repo_id = ?`, repoId, ); return row ?? { open: 0, resolved: 0 }; } /** Resolutions that allowlist the fingerprint repo-wide; they mirror the admin-only push-protection bypass reasons. */ export const ADMIN_ONLY_RESOLUTIONS: ReadonlySet = new Set(["false_positive", "used_in_tests", "wont_fix"]); export function requiresAdminResolution(resolution: SecretResolution | null): boolean { return resolution !== null && ADMIN_ONLY_RESOLUTIONS.has(resolution); } /** * `isAdmin` must reflect both the user's repo permission and the token's admin area: a write collaborator * could otherwise bypass push protection with `fix_later` and then resolve the alert as a false positive (AZ-A4). */ export async function setSecretAlertState( repoId: string, number: number, resolution: SecretResolution | null, userId: string, isAdmin: boolean, ): Promise { if (requiresAdminResolution(resolution) && !isAdmin) { throw forbidden("Only repository admins can mark a secret as a false positive, test data or won't fix"); } const now = Date.now(); const result = await repoDb(repoId).run( `UPDATE secret_alerts SET state = ?, resolution = ?, resolved_by = ?, resolved_at = ?, updated_at = ? WHERE repo_id = ? AND number = ?`, resolution ? "resolved" : "open", resolution, resolution ? userId : null, resolution ? now : null, now, repoId, number, ); return result.meta.changes > 0; } const FIX_LATER_WINDOW_MS = 60 * 60 * 1000; /** * Fingerprints a pusher may push again: alerts closed as harmless, or a "fix later" bypass of this * pusher's own block within the last hour (a bypass is never a permanent, repo-wide allowance). */ export async function allowedFingerprints( repoId: string, fingerprints: string[], pusherId: string | null, ): Promise> { const allowed = new Set(); const since = Date.now() - FIX_LATER_WINDOW_MS; for (const part of chunks([...new Set(fingerprints)], Math.floor(MAX_IN_LIST / 2) - 2)) { const rows = await repoDb(repoId).all<{ fingerprint: string }>( `SELECT fingerprint FROM secret_alerts WHERE repo_id = ? AND resolution IN ('false_positive', 'used_in_tests', 'wont_fix') AND fingerprint IN (${placeholders(part.length)}) UNION SELECT fingerprint FROM secret_push_blocks WHERE repo_id = ? AND user_id = ? AND bypassed_at > ? AND fingerprint IN (${placeholders(part.length)})`, repoId, ...part, repoId, pusherId, since, ...part, ); for (const row of rows) allowed.add(row.fingerprint); } return allowed; }