const directiveAliases = new Map([ ['script-src-elem', 'script-src'], ['script-src-attr', 'script-src'], ['style-src-elem', 'style-src'], ['style-src-attr', 'style-src'], ['child-src', 'frame-src'] ]) const supportedSourceDirectives = new Set([ 'script-src', 'style-src', 'img-src', 'font-src', 'media-src', 'frame-src', 'connect-src', 'worker-src', 'manifest-src' ]) /** * CSP 위반 지시어를 출처 허용 목록 후보용 지시어로 보정한다. * @param {string} directive - CSP effective directive * @returns {string} 출처 지시어 */ export const normalizeCspSourceDirective = (directive) => { const value = String(directive || '').trim().toLowerCase() const normalized = directiveAliases.get(value) || value return supportedSourceDirectives.has(normalized) ? normalized : '' } /** * CSP 허용 후보로 검토할 수 있는 외부 origin인지 확인한다. * @param {string} origin - 차단 origin * @returns {boolean} 허용 후보 여부 */ export const isCspAllowlistOriginCandidate = (origin) => { return /^https?:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(String(origin || '').trim()) } /** * CSP 위반 행에서 강제 정책 허용 후보를 집계한다. * @param {Array<{ effectiveDirective?: string, blockedOrigin?: string, violationCount?: number, lastSeenAt?: string | null }>} rows - CSP 위반 행 * @param {{ limit?: number }} [options] - 후보 생성 옵션 * @returns {Array<{ sourceDirective: string, blockedOrigin: string, violationCount: number, lastSeenAt: string | null }>} 허용 후보 */ export const createCspAllowlistCandidates = (rows, options = {}) => { const limit = Math.min(Math.max(Number(options.limit || 6), 1), 20) const groups = new Map() for (const row of rows || []) { const sourceDirective = normalizeCspSourceDirective(row.effectiveDirective) const blockedOrigin = String(row.blockedOrigin || '').trim() if (!sourceDirective || !isCspAllowlistOriginCandidate(blockedOrigin)) { continue } const key = `${sourceDirective}\n${blockedOrigin}` const existing = groups.get(key) || { sourceDirective, blockedOrigin, violationCount: 0, lastSeenAt: null } const nextCount = Number(row.violationCount || 0) existing.violationCount += Number.isFinite(nextCount) ? Math.max(nextCount, 0) : 0 const existingLastSeen = existing.lastSeenAt ? new Date(existing.lastSeenAt).getTime() : 0 const rowLastSeen = row.lastSeenAt ? new Date(row.lastSeenAt).getTime() : 0 if (rowLastSeen > existingLastSeen) { existing.lastSeenAt = row.lastSeenAt } groups.set(key, existing) } return [...groups.values()] .sort((a, b) => { const countDelta = Number(b.violationCount || 0) - Number(a.violationCount || 0) if (countDelta !== 0) { return countDelta } return new Date(b.lastSeenAt || 0).getTime() - new Date(a.lastSeenAt || 0).getTime() }) .slice(0, limit) }