From 9113711a8aaaa46266a249cac3f9aa7c4efffc17 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 29 Jul 2026 16:12:56 +0900 Subject: [PATCH] =?UTF-8?q?CSP=20=ED=97=88=EC=9A=A9=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 4 + docs/deploy.md | 8 +- docs/history.md | 4 + docs/map.md | 2 + docs/spec.md | 4 +- docs/todo.md | 4 +- docs/update.md | 6 ++ lib/csp-policy-suggestions.js | 87 ++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- pages/admin/index.vue | 24 +++++- server/repositories/csp-report-repository.js | 17 ++-- tests/csp-policy-suggestions.test.js | 66 +++++++++++++++ 13 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 lib/csp-policy-suggestions.js create mode 100644 tests/csp-policy-suggestions.test.js diff --git a/docs/changelog.md b/docs/changelog.md index 7afa8bb..4b5d96f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # 업데이트 요약 +## v1.12.10 + +- CSP 관찰 표에서 강제 정책 허용 후보를 함께 볼 수 있게 했다. + ## v1.12.9 - 관리자 대시보드에서 CSP Report-Only 위반 집계를 확인할 수 있게 했다. diff --git a/docs/deploy.md b/docs/deploy.md index b266a15..2164205 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,7 +1,7 @@ # 배포 가이드 -> v1.12.9는 Google 로그인/계정 연동, 통계 콘텐츠 세션 원장, CSP 관찰 표를 포함한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`, `072_analytics_content_sessions.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다. -> 로컬 기준 v1.12.9에서 `npm run lint`, `npm test`, `npm run build` 검증을 통과했다. +> v1.12.10은 Google 로그인/계정 연동, 통계 콘텐츠 세션 원장, CSP 관찰 표와 허용 후보 표시를 포함한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`, `072_analytics_content_sessions.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다. +> 로컬 기준 v1.12.10에서 `npm run lint`, `npm test`, `npm run build` 검증을 통과했다. ## 빌드 유형 @@ -14,7 +14,7 @@ > `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다. -## v1.12.9 배포 전 검증 +## v1.12.10 배포 전 검증 Playwright API 테스트도 브라우저 바이너리는 필요하지 않지만 `verify:ci`에는 Chromium E2E가 포함된다. 최초 1회 의존성과 Chromium을 설치하고 Docker daemon이 실행 중인지 확인한다. @@ -54,7 +54,7 @@ sh scripts/migrate-production-db.sh status v1.11.0은 `base-uri`, `object-src`, `frame-ancestors`만 강제한다. 광고 코드, 사용자 사이트 코드, 외부 이미지·미디어·iframe에 영향을 줄 수 있는 출처 정책은 계속 Report-Only이므로 기존 광고와 사용자 코드 실행을 차단하지 않는다. 전체 HTML 문서 `/pages/**`는 강제 CSP에서 제외하고 Report-Only 보고만 유지한다. -배포 후 관리자 대시보드의 `CSP 관찰` 표 또는 `csp_violation_aggregates`의 `policy_version='compat-v1'`, `effective_directive`, `blocked_origin`, `document_path`, `user_agent_family`, `violation_count`, `last_seen_at`을 관찰한다. URL 쿼리·fragment·차단 자산 경로와 User-Agent 원문은 저장되지 않는다. 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다. +배포 후 관리자 대시보드의 `CSP 관찰` 표 또는 `csp_violation_aggregates`의 `policy_version='compat-v1'`, `effective_directive`, `blocked_origin`, `document_path`, `user_agent_family`, `violation_count`, `last_seen_at`을 관찰한다. URL 쿼리·fragment·차단 자산 경로와 User-Agent 원문은 저장되지 않는다. `허용 후보`는 지시어별 외부 origin을 빠르게 묶어 보여주는 보조 정보일 뿐이므로, 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다. --- diff --git a/docs/history.md b/docs/history.md index 52a5f80..a5d1b17 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-07-29 v1.12.10 — CSP 허용 후보는 자동 적용이 아니라 검토 보조로 둔다 + +Report-Only 위반 집계에서 반복되는 외부 origin은 강제 CSP로 넘어갈 때 허용 목록 후보가 될 수 있지만, 모든 위반이 곧바로 정상 출처라는 뜻은 아니다. 따라서 관리자 대시보드는 `script-src-elem` 같은 세부 지시어를 실제 정책 지시어로 묶고 `http(s)` origin만 허용 후보로 표시하되, 정책 문자열을 자동 생성하거나 적용하지 않는다. 운영자는 후보를 보고 광고·사용자 코드·외부 미디어의 정상 동작 여부를 확인한 뒤 별도 배포에서 강제 정책을 단계적으로 넓힌다. + ## 2026-07-29 v1.12.9 — CSP 강제 전환 전 관찰 경로를 관리자 화면에 둔다 CSP 호환 정책은 광고 코드와 사용자가 작성한 사이트 코드, 외부 이미지·iframe 출처에 직접 영향을 줄 수 있으므로 운영 위반 보고를 충분히 본 뒤에만 강제 정책으로 확대해야 한다. 기존에는 `csp_violation_aggregates`를 DB에서 직접 확인해야 해서 반복 관찰 비용이 컸다. 따라서 관리자 대시보드에 최근 30일 위반 상위 항목을 표시해 정상 출처와 반복 오탐을 운영 화면에서 먼저 분류하고, 실제 차단 정책은 별도 배포에서 단계적으로 강화한다. diff --git a/docs/map.md b/docs/map.md index 7700c72..99417ff 100644 --- a/docs/map.md +++ b/docs/map.md @@ -58,6 +58,7 @@ | lib/content-in-article-ad.js | 공개 Markdown 본문 인아티클 광고 삽입 후보·목표 위치 계산 | | lib/content-gallery-images.js | 공개 Markdown 본문 단일 이미지·갤러리 라이트박스 목록 생성과 갤러리 행 분할 계산 | | lib/content-image-urls.js | 공개 Markdown 본문 이미지 표시 URL·라이트박스 URL·srcset·갤러리 sizes 계산 | +| lib/csp-policy-suggestions.js | CSP Report-Only 위반 행에서 강제 정책 허용 후보 지시어·origin 계산 | | lib/admin-social-settings-section.js | 관리자 설정 SNS 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper | | lib/admin-post-settings-section.js | 관리자 설정 POST 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper | | lib/admin-spam-settings-section.js | 관리자 설정 스팸 필터 필드 목록·기본 스냅샷·정규화·textarea 변환·스냅샷 복사·변경 비교 helper | @@ -189,6 +190,7 @@ | tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 | | tests/multipart-file-upload.test.js | multipart 스트리밍·제한 단위 회귀 테스트 | | tests/csp-report.test.js | CSP URL 최소화·payload 형식 단위 회귀 테스트 | +| tests/csp-policy-suggestions.test.js | CSP 허용 후보 지시어·origin 계산 단위 테스트 | | tests/media-catalog.test.js | 미디어 URL·역할·참조·LIKE 이스케이프 단위 회귀 테스트 | | tests/sharp-image-processing.test.js | Sharp 기반 게시물 카드·본문·페이지 커버 WebP 파생 이미지 생성 스모크 테스트 | | tests/markdown-table.test.js | Markdown 표 행·정렬·블록 파싱 단위 회귀 테스트 | diff --git a/docs/spec.md b/docs/spec.md index b474fb2..1f29a5e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -68,7 +68,7 @@ - 호환 정책은 모든 경로에서 `Content-Security-Policy-Report-Only`로 유지한다. 사용자 사이트 코드·광고·외부 미디어를 차단하지 않은 채 script/style/img/font/media/frame/connect/worker/manifest 위반을 `/api/csp-report`로 보고하며 `Reporting-Endpoints`와 `Report-To`도 같은 origin 수집 주소를 가리킨다. - `POST /api/csp-report`는 `application/csp-report`, `application/reports+json`, `application/json`, `application/*+json`만 허용하고 요청 본문은 32KB, Reporting API 배열은 최대 20건으로 제한한다. IP 원문을 보관하지 않는 해시 키 기준으로 분당 60회까지만 받는다. - CSP 위반은 정책 버전, 쿼리·fragment를 제거한 문서 경로, 지시어, 경로·쿼리를 제거한 차단 origin, 브라우저 계열만 `csp_violation_aggregates`에 누적한다. 원문 URL과 User-Agent는 저장하지 않으며 저장 실패가 일반 응답을 실패시키지 않는다. -- 관리자 대시보드는 CSP Report-Only 위반 집계가 1건 이상 있을 때만 `CSP 관찰` 표를 표시한다. 표는 최근 30일 기준 상위 위반의 지시어, 차단 출처, 문서 경로, 브라우저 계열, 횟수, 최근 관찰 시각을 보여준다. +- 관리자 대시보드는 CSP Report-Only 위반 집계가 1건 이상 있을 때만 `CSP 관찰` 표를 표시한다. 표는 최근 30일 기준 상위 위반의 지시어, 차단 출처, 문서 경로, 브라우저 계열, 횟수, 최근 관찰 시각을 보여준다. `script-src-elem`, `style-src-attr`, `child-src` 같은 세부 지시어는 각각 `script-src`, `style-src`, `frame-src` 후보로 묶고, `http(s)` origin만 허용 후보로 별도 표시한다. ### 홈 Featured (인덱스) @@ -684,7 +684,7 @@ components/content/ - `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타, IP, 열린 페이지 수와 열린 페이지 목록 포함) - `GET /admin/api/analytics/traffic?days=30` - 기간 내 유입원·디바이스·유입 키워드 통계 - `GET /admin/api/analytics/range-detail?startDay=YYYY-MM-DD&endDay=YYYY-MM-DD&limit=5` - 통계 추이 막대 상세(요약·유입·디바이스·인기 게시물·인기 페이지). 인기 콘텐츠는 조회·읽음/방문자·최근 30일 조회·작성일·스크롤 도달 지표를 포함한다. 최대 31일 범위로 제한한다. -- `GET /admin/api/security/csp-violations?days=30&limit=8` - CSP Report-Only 위반 집계 관찰 요약. `days`는 1~90일, `limit`은 1~20개로 제한하며 전체 누적 횟수, 최근 기간 횟수, 마지막 관찰 시각, 상위 위반 목록을 반환한다. +- `GET /admin/api/security/csp-violations?days=30&limit=8` - CSP Report-Only 위반 집계 관찰 요약. `days`는 1~90일, `limit`은 1~20개로 제한하며 전체 누적 횟수, 최근 기간 횟수, 마지막 관찰 시각, 상위 위반 목록, 지시어별 외부 origin 허용 후보를 반환한다. - `GET /admin/api/analytics/posts/:id?period=daily&days=14` - 게시물별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 게시물별 유입·디바이스·키워드) - `GET /admin/api/analytics/pages/:id?period=daily&days=14` - 페이지별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 체류·스크롤 구간) - `GET /admin/api/posts` - 글 목록. 기본은 본문 없는 배열 응답이며, `paged=true&page=1&pageSize=50&q=검색어&status=published&tag=slug&featured=featured&sort=newest|oldest` 쿼리를 보내면 서버 필터링된 `{ items, pagination, counts }` 페이지 응답을 반환한다. diff --git a/docs/todo.md b/docs/todo.md index 2fd6af2..823a0cd 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -7,8 +7,8 @@ ## 프론트엔드 개발 - CSP Report-Only 운영 보고 관찰 후 강제 정책 추가 강화 검토. - - 광고·사용자 사이트 코드·외부 미디어의 정상 출처를 관리자 대시보드 `CSP 관찰` 표와 `csp_violation_aggregates`에서 충분히 확인. - - 정상 출처 허용 목록과 반복 오탐을 정리한 뒤 script/style/img/frame/connect 지시어를 단계별 강제 전환. + - 광고·사용자 사이트 코드·외부 미디어의 정상 출처를 관리자 대시보드 `CSP 관찰` 표의 허용 후보와 `csp_violation_aggregates`에서 충분히 확인. + - 후보 origin 중 정상 출처와 반복 오탐을 정리한 뒤 script/style/img/frame/connect 지시어를 단계별 강제 전환. ## 콘텐츠 스타일 구현 diff --git a/docs/update.md b/docs/update.md index 22cb0ec..1628d9e 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.12.10 + +- CSP 관찰 요약에 지시어별 외부 origin 허용 후보 계산 추가. +- 관리자 대시보드 `CSP 관찰` 표에 허용 후보 표시 추가. +- CSP 허용 후보 계산 단위 테스트 추가. + ## v1.12.9 - 관리자 대시보드에 CSP Report-Only 위반 관찰 표 추가. diff --git a/lib/csp-policy-suggestions.js b/lib/csp-policy-suggestions.js new file mode 100644 index 0000000..e1cf120 --- /dev/null +++ b/lib/csp-policy-suggestions.js @@ -0,0 +1,87 @@ +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) +} diff --git a/package-lock.json b/package-lock.json index 817620a..87bad0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.12.9", + "version": "1.12.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.12.9", + "version": "1.12.10", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 90d4124..fd951b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.12.9", + "version": "1.12.10", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index 96f1dee..7cc67a1 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -106,7 +106,8 @@ const { totalRows: 0, recentViolations: 0, latestSeenAt: null, - topViolations: [] + topViolations: [], + allowlistCandidates: [] }) }) @@ -203,6 +204,7 @@ const chartPagination = computed(() => { const adsenseCards = computed(() => adsenseSummary.value.periods || []) const adsenseBalance = computed(() => adsenseSummary.value.balance || createEmptyAdsenseBalance()) const cspViolations = computed(() => cspViolationSummary.value?.topViolations || []) +const cspAllowlistCandidates = computed(() => cspViolationSummary.value?.allowlistCandidates || []) const hasCspViolations = computed(() => Number(cspViolationSummary.value?.totalViolations || 0) > 0) /** @@ -799,6 +801,26 @@ watch(trendRows, () => { +
+

+ 허용 후보 +

+
+ + {{ candidate.sourceDirective }} + {{ candidate.blockedOrigin }} + {{ formatNumber(candidate.violationCount) }}회 + +
+
+
{ /** * 관리자 CSP 관찰용 최근 위반 집계 요약을 조회한다. * @param {{ days?: number, limit?: number }} [options] - 조회 옵션 - * @returns {Promise<{ days: number, totalViolations: number, totalRows: number, recentViolations: number, latestSeenAt: string | null, topViolations: Array }>} CSP 위반 요약 + * @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() @@ -84,7 +85,8 @@ export const getCspViolationSummary = async (options = {}) => { totalRows: 0, recentViolations: 0, latestSeenAt: null, - topViolations: [] + topViolations: [], + allowlistCandidates: [] } } @@ -97,7 +99,7 @@ export const getCspViolationSummary = async (options = {}) => { FROM csp_violation_aggregates ` - const topRows = await sql` + const recentRows = await sql` SELECT policy_version, document_path, @@ -110,8 +112,9 @@ export const getCspViolationSummary = async (options = {}) => { FROM csp_violation_aggregates WHERE last_seen_at >= now() - (${days}::int * interval '1 day') ORDER BY violation_count DESC, last_seen_at DESC - LIMIT ${limit} + LIMIT 200 ` + const topViolations = recentRows.slice(0, limit).map(normalizeCspViolationRow) return { days, @@ -119,6 +122,10 @@ export const getCspViolationSummary = async (options = {}) => { totalRows: Number(summary?.total_rows || 0), recentViolations: Number(summary?.recent_violations || 0), latestSeenAt: summary?.latest_seen_at || null, - topViolations: topRows.map(normalizeCspViolationRow) + topViolations, + allowlistCandidates: createCspAllowlistCandidates( + recentRows.map(normalizeCspViolationRow), + { limit: 6 } + ) } } diff --git a/tests/csp-policy-suggestions.test.js b/tests/csp-policy-suggestions.test.js new file mode 100644 index 0000000..f103490 --- /dev/null +++ b/tests/csp-policy-suggestions.test.js @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + createCspAllowlistCandidates, + isCspAllowlistOriginCandidate, + normalizeCspSourceDirective +} from '../lib/csp-policy-suggestions.js' + +test('CSP 세부 지시어는 강제 정책 후보 지시어로 묶는다', () => { + assert.equal(normalizeCspSourceDirective('script-src-elem'), 'script-src') + assert.equal(normalizeCspSourceDirective('style-src-attr'), 'style-src') + assert.equal(normalizeCspSourceDirective('child-src'), 'frame-src') + assert.equal(normalizeCspSourceDirective('base-uri'), '') +}) + +test('CSP 허용 후보 origin은 http와 https origin만 사용한다', () => { + assert.equal(isCspAllowlistOriginCandidate('https://cdn.example.com'), true) + assert.equal(isCspAllowlistOriginCandidate('http://localhost:43080'), true) + assert.equal(isCspAllowlistOriginCandidate('inline'), false) + assert.equal(isCspAllowlistOriginCandidate('data:'), false) + assert.equal(isCspAllowlistOriginCandidate('https://cdn.example.com/path/file.js'), false) +}) + +test('CSP 허용 후보는 지시어와 origin별로 집계한다', () => { + const candidates = createCspAllowlistCandidates([ + { + effectiveDirective: 'script-src-elem', + blockedOrigin: 'https://cdn.example.com', + violationCount: 2, + lastSeenAt: '2026-07-29T01:00:00.000Z' + }, + { + effectiveDirective: 'script-src', + blockedOrigin: 'https://cdn.example.com', + violationCount: 3, + lastSeenAt: '2026-07-29T02:00:00.000Z' + }, + { + effectiveDirective: 'img-src', + blockedOrigin: 'data:', + violationCount: 8, + lastSeenAt: '2026-07-29T03:00:00.000Z' + }, + { + effectiveDirective: 'frame-src', + blockedOrigin: 'https://player.example.com', + violationCount: 1, + lastSeenAt: '2026-07-29T04:00:00.000Z' + } + ]) + + assert.deepEqual(candidates, [ + { + sourceDirective: 'script-src', + blockedOrigin: 'https://cdn.example.com', + violationCount: 5, + lastSeenAt: '2026-07-29T02:00:00.000Z' + }, + { + sourceDirective: 'frame-src', + blockedOrigin: 'https://player.example.com', + violationCount: 1, + lastSeenAt: '2026-07-29T04:00:00.000Z' + } + ]) +})