diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59391b3b..bf9c2da3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,14 +17,83 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact checked-out revision + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - name: Use preinstalled Temurin JDK 21 # Uses the runner image's bundled JDK instead of actions/setup-java to # keep every workflow dependency hash-pinned (Scorecard Pinned-Dependencies). run: | echo "JAVA_HOME=$JAVA_HOME_21_X64" >> "$GITHUB_ENV" echo "$JAVA_HOME_21_X64/bin" >> "$GITHUB_PATH" - - name: Run tests - run: mvn -B --no-transfer-progress test + - name: Run tests and coverage acceptance gates + shell: bash + run: | + if ! mvn -B --no-transfer-progress verify; then + if [[ -f target/site/jacoco/jacoco.csv ]]; then + echo "::group::JaCoCo CSV diagnostics" + cat target/site/jacoco/jacoco.csv + echo "::endgroup::" + fi + if [[ -f target/site/jacoco/jacoco.xml ]]; then + echo "::group::JaCoCo uncovered line diagnostics" + python3 - <<'PY' + import xml.etree.ElementTree as ET + from pathlib import Path + + report = Path("target/site/jacoco/jacoco.xml") + root = ET.parse(report).getroot() + gaps = [] + for package in root.findall("package"): + package_name = package.get("name", "") + for source_file in package.findall("sourcefile"): + source_name = source_file.get("name", "") + source_path = f"{package_name}/{source_name}" if package_name else source_name + for line in source_file.findall("line"): + missed_instructions = int(line.get("mi", "0")) + missed_branches = int(line.get("mb", "0")) + if missed_instructions or missed_branches: + gaps.append( + ( + source_path, + int(line.get("nr", "0")), + missed_instructions, + missed_branches, + ) + ) + + for source_path, line_number, missed_instructions, missed_branches in gaps: + print( + f"{source_path}:{line_number}: " + f"missed_instructions={missed_instructions} " + f"missed_branches={missed_branches}" + ) + PY + echo "::endgroup::" + fi + exit 1 + fi + + merge-compatibility: + name: Maven merge compatibility + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Verify merge revision + env: + EXPECTED_SHA: ${{ github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Use preinstalled Temurin JDK 21 + run: | + echo "JAVA_HOME=$JAVA_HOME_21_X64" >> "$GITHUB_ENV" + echo "$JAVA_HOME_21_X64/bin" >> "$GITHUB_PATH" + - name: Verify merged result + run: mvn -B --no-transfer-progress verify script-checks: name: Buyer-readiness script tests @@ -33,6 +102,11 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact checked-out revision + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index c2178971..ea6ae6eb 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -45,6 +45,14 @@ jobs: - TenantClaimsFuzzTest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Verify exact checked-out revision + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - name: Set up JDK 21 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 diff --git a/.github/workflows/one-shot-netty-sbom-finalize.yml b/.github/workflows/one-shot-netty-sbom-finalize.yml new file mode 100644 index 00000000..881633d5 --- /dev/null +++ b/.github/workflows/one-shot-netty-sbom-finalize.yml @@ -0,0 +1,173 @@ +name: One-shot Netty SBOM finalize + +on: + pull_request: + branches: + - main + paths: + - .github/workflows/one-shot-netty-sbom-finalize.yml + +permissions: + contents: read + +concurrency: + group: one-shot-netty-sbom-finalize-artifact-v2-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + cancel-in-progress: false + +jobs: + finalize-evidence: + if: >- + github.repository == 'ContextualWisdomLab/clearfolio' + && github.actor == 'seonghobae' + && github.event.pull_request.head.repo.full_name == github.repository + && github.event.pull_request.head.ref == 'fix/pii-logging-16240128950440010639' + name: Ingest verified evidence and self-remove + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: read + contents: write + steps: + - name: Checkout exact pull request head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + + - name: Verify immutable trigger and live branch identity + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + live_sha="$(git ls-remote origin "refs/heads/${HEAD_BRANCH}" | awk '{print $1}')" + test "$live_sha" = "$EXPECTED_SHA" + + - name: Ingest the immutable successful read-only artifact + env: + ARTIFACT_ID: "8929593015" + EXPECTED_ARCHIVE_SHA256: 07a0325e08157f00dda28c58ed4e41af51863cccb2ceea2c4e378ead77dc337f + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + archive="$RUNNER_TEMP/netty-sbom-refresh.zip" + extracted="$RUNNER_TEMP/netty-sbom-refresh" + curl --fail --silent --show-error --location \ + --proto '=https' --tlsv1.2 \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -o "$archive" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" + printf '%s %s\n' "$EXPECTED_ARCHIVE_SHA256" "$archive" | sha256sum -c - + mkdir -p "$extracted" + unzip -q "$archive" -d "$extracted" + test "$(find "$extracted" -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 3 + test -s "$extracted/generation-manifest.json" + test -s "$extracted/sbom-cyclonedx.json" + test -s "$extracted/third-party-attribution.md" + cp "$extracted/sbom-cyclonedx.json" \ + docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json + cp "$extracted/third-party-attribution.md" \ + docs/legal/2026-07-03-third-party-attribution.md + + - name: Use preinstalled Temurin JDK 21 + run: | + echo "JAVA_HOME=$JAVA_HOME_21_X64" >> "$GITHUB_ENV" + echo "$JAVA_HOME_21_X64/bin" >> "$GITHUB_PATH" + + - name: Verify complete generated graph and attribution + env: + EXPECTED_SBOM_SHA256: e138a9263edb40c613d5f159acba8fa89ee848a7cef4b6619e095c48451b095c + EXPECTED_ATTRIBUTION_SHA256: e19a3767a545bd059e50003882d8ff2f8a3ff4d3b8fd28d3f305eead61261da9 + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + + sbom_path = Path( + "docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json" + ) + attribution_path = Path( + "docs/legal/2026-07-03-third-party-attribution.md" + ) + sbom_bytes = sbom_path.read_bytes() + attribution_bytes = attribution_path.read_bytes() + sbom = json.loads(sbom_bytes) + components = sbom.get("components", []) + if len(components) != 61: + raise SystemExit(f"expected 61 components, observed {len(components)}") + netty = [item for item in components if item.get("group") == "io.netty"] + if len(netty) != 17: + raise SystemExit(f"expected 17 Netty components, observed {len(netty)}") + if {str(item.get("version", "")) for item in netty} != {"4.1.136.Final"}: + raise SystemExit("Netty versions were not coherent") + component_refs = {str(item.get("bom-ref", "")) for item in netty} + dependency_refs = set() + for dependency in sbom.get("dependencies", []): + ref = str(dependency.get("ref", "")) + if "io.netty/" in ref: + dependency_refs.add(ref) + dependency_refs.update( + str(item) + for item in dependency.get("dependsOn", []) + if "io.netty/" in str(item) + ) + if dependency_refs != component_refs: + raise SystemExit("Netty dependency references do not match component refs") + for item in netty: + expected = "@4.1.136.Final" + if expected not in str(item.get("bom-ref", "")): + raise SystemExit("Netty bom-ref version mismatch") + if expected not in str(item.get("purl", "")): + raise SystemExit("Netty purl version mismatch") + if b"4.1.135.Final" in sbom_bytes or b"4.1.135.Final" in attribution_bytes: + raise SystemExit("stale Netty evidence remains") + if hashlib.sha256(sbom_bytes).hexdigest() != os.environ["EXPECTED_SBOM_SHA256"]: + raise SystemExit("SBOM bytes differ from the successful read-only artifact") + if hashlib.sha256(attribution_bytes).hexdigest() != os.environ["EXPECTED_ATTRIBUTION_SHA256"]: + raise SystemExit("attribution bytes differ from the successful read-only artifact") + PY + python3 scripts/test_render_third_party_attribution.py + python -m pytest -q scripts + mvn -B --no-transfer-progress verify + mvn -B --no-transfer-progress dependency:tree -Dincludes=io.netty + + - name: Commit only generated evidence and helper removal + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PUSH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + live_sha="$(git ls-remote origin "refs/heads/${HEAD_BRANCH}" | awk '{print $1}')" + test "$live_sha" = "$EXPECTED_SHA" + rm .github/workflows/one-shot-netty-sbom-finalize.yml + git add \ + docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json \ + docs/legal/2026-07-03-third-party-attribution.md \ + .github/workflows/one-shot-netty-sbom-finalize.yml + git diff --cached --check + git diff --cached --quiet && { + echo "::error::Expected generated evidence and helper cleanup changes." + exit 1 + } + changed_paths="$(git diff --cached --name-only)" + expected_paths="$(printf '%s\n' \ + .github/workflows/one-shot-netty-sbom-finalize.yml \ + docs/legal/2026-07-03-third-party-attribution.md \ + docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json)" + test "$changed_paths" = "$expected_paths" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'docs(evidence): ingest verified Netty 4.1.136 evidence' + auth_header="$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')" + echo "::add-mask::$auth_header" + git -c http.https://github.com/.extraheader="AUTHORIZATION: basic $auth_header" \ + push origin "HEAD:${HEAD_BRANCH}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 373a661a..167e0bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,27 +1,38 @@ -## [Unreleased] -### Added -- **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다. - -### Changed -- PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. - # Changelog ## [Unreleased] -### 추가된 기능 (Added) +### Added + +- **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다. - **관리자용 단건 작업 삭제 및 재시도 API 추가** - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. - - **비동기 버튼 로딩 피드백 및 상태 복원 개선** - - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..." 이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. + - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..."이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. +### Changed + +- PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. +- CI가 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하며, 합성 merge revision은 별도 호환성 작업에서 검증하도록 분리했습니다. +- Maven `verify` 단계에서 JaCoCo production line 및 branch missed count가 각각 0인지 강제하고, 실패 시 누락 위치 진단을 출력하도록 했습니다. +- Jazzer fuzzing도 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하도록 강화했습니다. + +### Security + +- 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. +- 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. +- 경로·쿼리 파라미터 타입 변환 실패 응답에서 사용자가 제출한 거부 값을 고정된 `[redacted]` 표식으로 대체해 오류 응답을 통한 개인정보·비밀값 반사를 차단했습니다. 값이 실제로 없었던 경우에만 `null` 진단을 유지합니다. + +### Fixed + +- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정했습니다. ## [0.1.0] - 2026-06-25 ### 추가된 기능 (Added) + - **비동기 버튼 로딩 상태 UX 개선 (Async Button Loading States)** - 문서 제출(`submitDocument`), 데모 데이터 로드(`loadDemoData`), 실패 작업 재시도(`retryActiveJob`) 등 비동기 요청을 수행하는 버튼들에 대해 처리 중 명시적인 로딩 상태(Loading, Submitting, Retrying 등)를 추가했습니다. - 사용자의 중복 클릭을 방지하기 위해 작업 중에는 버튼이 비활성화되도록 수정했습니다. @@ -37,9 +48,11 @@ - 관련 `AdminJobListResponse` DTO 모델과 이를 처리하는 Repository 및 Service 계층의 `findAll`/`getAllJobs` 메서드를 추가했습니다. ### 테스트 커버리지 (Tests) + - 신규 구현된 Repository, Service, Controller 계층에 대한 유닛 테스트(Unit Tests)를 작성하여 JaCoCo 기준 라인 및 브랜치 커버리지 100%를 달성했습니다. ### 보안 (Security) + - **의존성 취약점 일괄 정리 (trivy-fs / osv-scan 대응)**: Spring Boot 부모 POM을 `3.5.0`에서 `3.5.16`으로 올려 Spring Framework, Netty, Reactor Netty, logback 관련 다수의 HIGH/MEDIUM 권고를 해소했습니다. - Jackson 계열을 `jackson-bom` import로 `2.22.1`에 고정하여 jackson-databind case-insensitive deserialization bypass 권고(GHSA-5jmj-h7xm-6q6v / CVE-2026-54515)를 제거했습니다. - Apache Tika 표준 파서를 통해 유입되던 전이 의존성을 `dependencyManagement`로 고정했습니다: junrar `7.6.0`(경로 순회 RCE/파일 쓰기), commons-io `2.20.0`(XmlStreamReader DoS), commons-lang3 `3.18.0`, BouncyCastle `bcprov-jdk18on 1.84` 및 `bcpkix-jdk18on 1.84`(CRITICAL/Medium). 전체 347개 테스트 통과를 확인했습니다. @@ -48,6 +61,3 @@ - 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다. - logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다. - 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다. - -### Fixed -- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 diff --git a/docs/diagrams/submit-flow.md b/docs/diagrams/submit-flow.md index 7b51e3cd..bccde0f3 100644 --- a/docs/diagrams/submit-flow.md +++ b/docs/diagrams/submit-flow.md @@ -64,7 +64,7 @@ sequenceDiagram V->>P: getBlockedExtensions() alt Override headers valid V-->>V: validate override=true + token + approver - V-->>V: emit audit-safe log(extension, approver, tokenFingerprint) + V-->>V: emit audit-safe log(extension, approverFingerprint, tokenFingerprint) V-->>Svc: validation ok else Override missing/invalid V-->>Svc: UnsupportedDocumentFormatException or IllegalArgumentException @@ -94,6 +94,8 @@ sequenceDiagram end ``` +`approverFingerprint` is the versioned, domain-separated keyed audit pseudonym. The raw approver identifier is accepted only as validation input and is never emitted by the audit-safe log. + ## Exception paths covered - Missing or empty file diff --git a/docs/diagrams/submit-policy-adapter-flow.md b/docs/diagrams/submit-policy-adapter-flow.md index bc65dfe7..18cca561 100644 --- a/docs/diagrams/submit-policy-adapter-flow.md +++ b/docs/diagrams/submit-policy-adapter-flow.md @@ -29,7 +29,7 @@ sequenceDiagram EH-->>C: 400 UNSUPPORTED_FORMAT else extension blocked and override=true alt token/approver valid - Val-->>Val: audit-safe log(extension, approverId, tokenFingerprint) + Val-->>Val: audit-safe log(extension, approverFingerprint, tokenFingerprint) Val-->>Svc: validation ok Svc->>Repo: findOrStoreByContentHash(job) Svc->>W: enqueue(jobId) when created @@ -60,6 +60,8 @@ sequenceDiagram end ``` +`approverFingerprint` is the versioned, domain-separated keyed audit pseudonym; the raw approver identifier is never written to the audit-safe log. + ## Deterministic adapter baseline - `pdf -> PDF_JS` diff --git a/docs/prd/clearfolio-viewer-unified-document-preview-prd.md b/docs/prd/clearfolio-viewer-unified-document-preview-prd.md index d9fe8f4d..b4b8bc76 100644 --- a/docs/prd/clearfolio-viewer-unified-document-preview-prd.md +++ b/docs/prd/clearfolio-viewer-unified-document-preview-prd.md @@ -1,7 +1,7 @@ # PRD: Clearfolio Viewer Unified Document Preview (Internal) Date: 2026-02-23 -Last updated: 2026-02-23 +Last updated: 2026-08-05 Owner: Product Manager Sources: `docs/architecture.md`, `docs/trd-integrated-document-viewer-platform.md`, `docs/prd-integrated-document-viewer-platform.md`, `docs/engineering/acceptance-criteria.md`, `docs/workflow/one-day-delivery-plan.md`, `docs/diagrams/*`, `AGENTS.md` @@ -196,7 +196,7 @@ Minimum claims/scopes (MVP intent): - preview session creation - viewer access (success/fail) - blocked-format attempts - - exception lane approvals (including approver id, token fingerprint, and rationale id if available) + - exception lane approvals (including `approverFingerprint`, token fingerprint, and rationale id if available); the raw approver identifier is never logged - operator-triggered retries ### 10.4 Browser security headers / CSP @@ -306,4 +306,4 @@ Minimum one-day deliverables: - Risk: Office formats (`docx`/`pptx`/`xlsx`) preview quality depends on converter availability; failures could impact perceived “unified” promise if not clearly messaged. - Risk: Gateway-induced header/proxy limitations can constrain token propagation; mitigation is short-lived viewer session tokens and minimized header set. - Risk: Strict no-warnings/no-deprecations gates can slow dependency upgrades; mitigate with explicit upgrade windows and pre-merge checks. -- Risk: Exception lane governance (who can approve, how approvals are issued) can expand scope; mitigate by treating policy token issuance as external and logging only fingerprint + approver id. +- Risk: Exception lane governance (who can approve, how approvals are issued) can expand scope; mitigate by treating policy token issuance as external and logging only the token fingerprint and `approverFingerprint`; the raw approver identifier remains validation input only and is never logged. diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md new file mode 100644 index 00000000..6f39f223 --- /dev/null +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -0,0 +1,116 @@ +# Audit identifier pseudonymization + +## Decision + +Clearfolio must not write raw approver identifiers or approval tokens to application logs. Policy-override audit events use a domain-separated keyed HMAC for the approver identifier and a non-reversible token fingerprint for the already high-entropy approval signature. Authentication-token handling is outside this policy-override logging contract and remains governed by the repository-wide logging and authorization controls. + +The approver field is named `approverFingerprint`, not `approverId`, so downstream log consumers cannot mistake pseudonymous data for the source identifier. Pseudonymized values remain personal data when they can be related back to a person using separately held information; they are not treated as anonymized data. + +## Cryptographic contract + +### Policy override key + +A configured `conversion.policy-override-secret` authorizes blocked-document policy exceptions and must contain at least 32 UTF-8 bytes. Blank or absent configuration keeps policy override disabled. A nonblank value below the minimum fails application startup before any conversion endpoint can accept traffic. The startup gate measures encoded bytes rather than Java character count, does not log the supplied value, and remains independent of the audit-key separation check. + +Deployments must generate this key from a cryptographically secure random source and must not use a password, person or tenant identifier, repository token, or other human-memorable value. The minimum-length gate prevents a weak configured secret from reducing the effective security of the HMAC approval token even when the HMAC algorithm itself is correctly implemented (National Institute of Standards and Technology, 2008; Turan & Brandão, 2024). + +### Approver identifier + +The approver fingerprint is calculated as follows: + +```text +HMAC-SHA-256( + dedicated_audit_key, + UTF-8("clearfolio:audit-approver:v1\n" + exact_approver_identifier) +) +``` + +The first 128 bits are encoded as lowercase hexadecimal and prefixed by the non-sensitive key version: + +```text +:<32 lowercase hexadecimal characters> +``` + +The implementation preserves the exact Java string bytes supplied after the policy override has passed its existing identity validation. It does not lowercase, Unicode-normalize, or trim inside the pseudonymizer because those transformations would silently alter identity semantics. Null input produces `absent:`. An empty Java string is not absent: it is processed as a zero-length identifier through the same domain-separated HMAC and produces a normal versioned fingerprint. A missing dedicated key produces `unavailable:` and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. + +A configured audit pseudonym secret must contain at least 32 UTF-8 bytes and must be generated from a cryptographically secure random source. The byte-length gate prevents accidentally deploying a short human-memorable secret whose effective strength would bound the HMAC protection. Blank or absent configuration retains the explicit non-correlatable `unavailable` behavior; a nonblank weak key fails application startup. FIPS 198-1 remains the current final NIST HMAC standard while NIST SP 800-224 remains an initial public draft; NIST expects the final SP to be published concurrently with withdrawal of FIPS 198-1 (National Institute of Standards and Technology, 2008, 2025; Turan & Brandão, 2024). + +Only an absent key-version property defaults to `v1`. Explicit blank, oversized, or unsafe key-version values fail application startup so one version label can never identify multiple key generations accidentally. The accepted format is one to 32 Java UTF-16 code units matching the implementation-equivalent expression `^[\p{L}\p{Nd}._-]{1,32}$`: each character must satisfy Java `Character.isLetterOrDigit` or be `.`, `_`, or `-`. The value is retained as a Java Unicode string and written by the configured log encoding; deployments use UTF-8 log output. Control characters, separators, whitespace, slashes, and other punctuation are rejected. + +### Approval token + +The approval token is a policy-override HMAC signature and is therefore already a high-entropy authentication value. The audit-only token fingerprint is calculated independently as follows: + +```text +SHA-256(UTF-8(exact_approval_token)) +``` + +The first eight digest bytes are encoded as 16 lowercase hexadecimal characters and written as `tokenFingerprint`. The fingerprint is unkeyed and has no domain prefix because it is used only as a short diagnostic correlation value for an already high-entropy signature; it must never be accepted as an authentication credential or used to validate a policy override. Null, empty, and blank approval tokens are rejected by request validation before fingerprinting, so the audit fingerprint function has no absent or empty sentinel contract. + +## Runtime secret loading + +Runtime key material is supplied through Spring Boot's config-tree property source rather than direct secret-bearing environment variables. The default mount is `/run/secrets/clearfolio/`; `CLEARFOLIO_SECRET_CONFIG_DIR` may select another bootstrap directory but must not contain a secret value. + +The secret store or orchestrator mounts files with these exact names: + +```text +conversion.policy-override-secret +conversion.audit-pseudonym-secret +conversion.audit-pseudonym-key-version +``` + +Spring reads each file's contents as the corresponding property. The deployment must restrict file ownership and mode, prevent inclusion in container images and support bundles, and avoid logging the imported values. If the optional config tree is absent, the application retains safe disabled defaults. Production policy must require the needed values before enabling policy override operations. + +## Key ownership and rotation + +- `conversion.policy-override-secret` is an authorization key owned by the security function. It must contain at least 32 UTF-8 bytes, be generated from a cryptographically secure random source, and be rotated through the deployment secret manager. +- `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. +- The configured audit value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier. +- The application startup guard rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's startup guard. +- `conversion.audit-pseudonym-key-version` is a non-secret identifier such as `2026-08` but is mounted with the same versioned configuration bundle to keep key and label rotation atomic. +- Rotation changes both the secret and version. During an investigation that spans a rotation boundary, operators must treat fingerprints from different versions as intentionally unlinkable unless an approved, separately controlled re-identification process exists. +- Retired keys must not remain in application configuration. Any escrow or incident-response copy must be access-controlled, time-bounded, and audited. + +## Retention and access + +Audit log retention must be limited to the shortest period required by the documented security, contractual, and regulatory purpose. Read access is restricted by least privilege. Export, search, re-identification, and deletion workflows must be auditable. Logs and pseudonym keys must never be stored in the same access domain. + +## Incident response + +If the audit pseudonym key is suspected to be exposed: + +1. Rotate the key and version immediately. +2. Preserve affected log ranges under incident hold without broadening access. +3. Determine whether dictionary attacks against likely identifiers were feasible. +4. Treat exposed pseudonymized records as potentially exposed personal data. +5. Follow the applicable breach-assessment and notification process. +6. Verify that no raw identifiers, approval tokens, or key material were written to logs. + +## Verification requirements + +Automated tests must prove: + +- determinism within one key version and domain; +- separation across keys, versions, and domains; +- startup rejection of configured policy-override and audit keys shorter than 32 UTF-8 bytes; +- acceptance of multibyte policy keys based on encoded byte length rather than character count; +- rejection of invalid explicit key versions; +- startup rejection when policy and audit purposes reuse the same nonblank key; +- distinct absent, empty, and unavailable approver behavior; +- rejection of null, empty, or blank approval tokens before token fingerprinting; +- safe handling of Unicode and control characters; +- no raw approver identifier or approval token in captured policy-override audit output; +- stable failure behavior if the HMAC provider is unavailable; +- 100% JaCoCo line and branch coverage for the `com.clearfolio.viewer.*` production package. + +## References + +European Parliament and Council of the European Union. (2016). *Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation)*. *Official Journal of the European Union, L 119*, 1–88. + +National Institute of Standards and Technology. (2008). *The keyed-hash message authentication code (HMAC)* (FIPS PUB 198-1). U.S. Department of Commerce. https://doi.org/10.6028/NIST.FIPS.198-1 + +National Institute of Standards and Technology. (2025, June 23). *Proposed withdrawal of FIPS 198-1, HMAC*. Computer Security Resource Center. https://csrc.nist.gov/News/2025/proposed-withdrawal-of-fips-198-1-hmac + +OWASP Foundation. (n.d.). *Logging cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 4, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html + +Turan, M. S., & Brandão, L. T. A. N. (2024). *Keyed-hash message authentication code (HMAC): Specification of HMAC and recommendations for message authentication* (NIST SP 800-224 Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-224.ipd diff --git a/pom.xml b/pom.xml index 51e22d13..0710a5a0 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ ${java.version} UTF-8 + 0.8.15 3.0.8 6.1.200