import { getPostgresClient } from './postgres-client.js' import { createCspAllowlistCandidates } from '../../lib/csp-policy-suggestions.js' /** * CSP 위반을 원본 없이 정규화된 차원별로 집계한다. * @param {Array<{ policyVersion: string, documentPath: string, effectiveDirective: string, blockedOrigin: string, userAgentFamily: string }>} reports - CSP 집계 행 * @returns {Promise} */ export const recordCspReports = async (reports) => { const sql = getPostgresClient() if (!sql || reports.length === 0) { return } await sql.begin(async (transaction) => { for (const report of reports) { await transaction` INSERT INTO csp_violation_aggregates ( policy_version, document_path, effective_directive, blocked_origin, user_agent_family, violation_count, first_seen_at, last_seen_at ) VALUES ( ${report.policyVersion}, ${report.documentPath}, ${report.effectiveDirective}, ${report.blockedOrigin}, ${report.userAgentFamily}, 1, now(), now() ) ON CONFLICT ( policy_version, document_path, effective_directive, blocked_origin, user_agent_family ) DO UPDATE SET violation_count = csp_violation_aggregates.violation_count + 1, last_seen_at = now() ` } }) } /** * CSP 위반 집계 행을 관리자 표시용 값으로 변환한다. * @param {Object} row - DB 집계 행 * @returns {Object} 관리자 표시용 집계 행 */ const normalizeCspViolationRow = (row) => { return { policyVersion: row.policy_version || '', documentPath: row.document_path || '/', effectiveDirective: row.effective_directive || 'unknown', blockedOrigin: row.blocked_origin || 'unknown', userAgentFamily: row.user_agent_family || 'Other', violationCount: Number(row.violation_count || 0), firstSeenAt: row.first_seen_at || null, lastSeenAt: row.last_seen_at || null } } /** * 관리자 CSP 관찰용 최근 위반 집계 요약을 조회한다. * @param {{ days?: number, limit?: number }} [options] - 조회 옵션 * @returns {Promise<{ days: number, totalViolations: number, totalRows: number, recentViolations: number, latestSeenAt: string | null, topViolations: Array, allowlistCandidates: Array }>} CSP 위반 요약 */ export const getCspViolationSummary = async (options = {}) => { const sql = getPostgresClient() const days = Math.min(Math.max(Number(options.days || 30), 1), 90) const limit = Math.min(Math.max(Number(options.limit || 8), 1), 20) if (!sql) { return { days, totalViolations: 0, totalRows: 0, recentViolations: 0, latestSeenAt: null, topViolations: [], allowlistCandidates: [] } } const [summary] = await sql` SELECT COALESCE(SUM(violation_count), 0)::bigint AS total_violations, COUNT(*)::bigint AS total_rows, COALESCE(SUM(violation_count) FILTER (WHERE last_seen_at >= now() - (${days}::int * interval '1 day')), 0)::bigint AS recent_violations, MAX(last_seen_at) AS latest_seen_at FROM csp_violation_aggregates ` const recentRows = await sql` SELECT policy_version, document_path, effective_directive, blocked_origin, user_agent_family, violation_count, first_seen_at, last_seen_at FROM csp_violation_aggregates WHERE last_seen_at >= now() - (${days}::int * interval '1 day') ORDER BY violation_count DESC, last_seen_at DESC LIMIT 200 ` const topViolations = recentRows.slice(0, limit).map(normalizeCspViolationRow) return { days, totalViolations: Number(summary?.total_violations || 0), totalRows: Number(summary?.total_rows || 0), recentViolations: Number(summary?.recent_violations || 0), latestSeenAt: summary?.latest_seen_at || null, topViolations, allowlistCandidates: createCspAllowlistCandidates( recentRows.map(normalizeCspViolationRow), { limit: 6 } ) } }