Files
sori.studio/lib/csp-policy-suggestions.js
2026-07-29 16:12:56 +09:00

88 lines
2.9 KiB
JavaScript

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)
}