Files
sori.studio/server/repositories/csp-report-repository.js
2026-07-29 16:12:56 +09:00

132 lines
4.1 KiB
JavaScript

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<void>}
*/
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<Object>, allowlistCandidates: Array<Object> }>} 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 }
)
}
}