From 5261356ac34e6545bce947ba0bcf2b1ce9f9be67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 12:59:56 +0900 Subject: [PATCH 01/85] security: rebuild audit pseudonymization on current main --- CHANGELOG.md | 6 +- docs/diagrams/submit-flow.md | 4 +- docs/diagrams/submit-policy-adapter-flow.md | 4 +- ...lio-viewer-unified-document-preview-prd.md | 6 +- .../2026-08-04-audit-pseudonymization.md | 108 +++++++++++ .../viewer/config/ConversionProperties.java | 40 ++++ .../security/AuditKeySeparationGuard.java | 49 +++++ .../viewer/security/AuditPseudonymizer.java | 140 ++++++++++++++ .../DefaultDocumentValidationService.java | 13 +- .../viewer/service/PolicyOverrideRequest.java | 12 +- src/main/resources/application.yml | 9 +- .../security/AuditKeySeparationGuardTest.java | 40 ++++ .../AuditPseudonymizerKeyStrengthTest.java | 41 ++++ .../security/AuditPseudonymizerTest.java | 182 ++++++++++++++++++ ...ultDocumentValidationServiceAuditTest.java | 170 ++++++++++++++++ .../service/PolicyOverrideRequestTest.java | 29 ++- 16 files changed, 831 insertions(+), 22 deletions(-) create mode 100644 docs/security/2026-08-04-audit-pseudonymization.md create mode 100644 src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java create mode 100644 src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 373a661a..cc5ba27b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Changed - PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. +### Security +- 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. +- 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. + # Changelog ## [Unreleased] @@ -50,4 +54,4 @@ - 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다. ### Fixed -- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 +- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 \ No newline at end of file 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..92b06e93 --- /dev/null +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -0,0 +1,108 @@ +# 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 + +### 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.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. +- The configured 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; +- rejection of configured keys shorter than 32 UTF-8 bytes; +- 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/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java b/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java index a86fc982..feb50622 100644 --- a/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java +++ b/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java @@ -21,6 +21,8 @@ public class ConversionProperties { private double retryBackoffMultiplier = 2.0; private long maxUploadSizeBytes = 5 * 1024 * 1024L; private String policyOverrideSecret = ""; + private String auditPseudonymSecret = ""; + private String auditPseudonymKeyVersion = "v1"; private long processingLeaseTimeoutMs = 60_000L; /** @@ -200,6 +202,44 @@ public void setPolicyOverrideSecret(String policyOverrideSecret) { this.policyOverrideSecret = policyOverrideSecret == null ? "" : policyOverrideSecret; } + /** + * Returns the dedicated secret used to pseudonymize audit identifiers. + * + * @return audit pseudonym secret + */ + public String getAuditPseudonymSecret() { + return auditPseudonymSecret; + } + + /** + * Sets the dedicated secret used to pseudonymize audit identifiers. + * + * @param auditPseudonymSecret audit pseudonym secret + */ + public void setAuditPseudonymSecret(String auditPseudonymSecret) { + this.auditPseudonymSecret = auditPseudonymSecret == null ? "" : auditPseudonymSecret; + } + + /** + * Returns the non-sensitive key version included in audit fingerprints. + * + * @return audit pseudonym key version + */ + public String getAuditPseudonymKeyVersion() { + return auditPseudonymKeyVersion; + } + + /** + * Sets the non-sensitive key version included in audit fingerprints. + * + * @param auditPseudonymKeyVersion audit pseudonym key version + */ + public void setAuditPseudonymKeyVersion(String auditPseudonymKeyVersion) { + this.auditPseudonymKeyVersion = auditPseudonymKeyVersion == null + ? "v1" + : auditPseudonymKeyVersion; + } + /** * Returns the processing lease timeout used by restart recovery. * diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java new file mode 100644 index 00000000..240fae06 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -0,0 +1,49 @@ +package com.clearfolio.viewer.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +import org.springframework.stereotype.Component; + +import com.clearfolio.viewer.config.ConversionProperties; + +/** + * Fails application startup when policy signing and audit pseudonymization use + * the same configured key material. + * + *

The two HMAC purposes form separate security domains. Reusing one value + * would allow a holder of the audit key to create policy-override signatures, + * so nonblank configured values must remain distinct.

+ */ +@Component +public final class AuditKeySeparationGuard { + + /** + * Validates the bound conversion security configuration during bean startup. + * + * @param properties bound conversion configuration + */ + public AuditKeySeparationGuard(ConversionProperties properties) { + requireDistinct( + properties.getPolicyOverrideSecret(), + properties.getAuditPseudonymSecret() + ); + } + + static void requireDistinct(String policySecret, String auditSecret) { + if (!isConfigured(policySecret) || !isConfigured(auditSecret)) { + return; + } + if (MessageDigest.isEqual( + policySecret.getBytes(StandardCharsets.UTF_8), + auditSecret.getBytes(StandardCharsets.UTF_8))) { + throw new IllegalStateException( + "policy override and audit pseudonym keys must be different" + ); + } + } + + private static boolean isConfigured(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java new file mode 100644 index 00000000..ed3bd31d --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java @@ -0,0 +1,140 @@ +package com.clearfolio.viewer.security; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Produces privacy-safe, domain-separated audit fingerprints for identifiers. + * + *

The pseudonymizer deliberately uses a dedicated keyed HMAC rather than an + * unkeyed digest. This prevents practical dictionary attacks against common + * low-entropy identifiers such as usernames, employee numbers, and email + * addresses. Fingerprints are stable only within the configured key version + * and domain.

+ */ +public final class AuditPseudonymizer { + + private static final String HMAC_SHA_256 = "HmacSHA256"; + private static final String DEFAULT_KEY_VERSION = "v1"; + private static final String APPROVER_DOMAIN = "clearfolio:audit-approver:v1"; + private static final int FINGERPRINT_BYTES = 16; + private static final int MIN_SECRET_BYTES = 32; + private static final int MAX_KEY_VERSION_LENGTH = 32; + private static final HexFormat HEX_FORMAT = HexFormat.of(); + + private final byte[] secretBytes; + private final String keyVersion; + private final String domain; + + /** + * Creates an approver audit pseudonymizer. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; blank disables correlation + * @param keyVersion non-sensitive key-rotation identifier + */ + public AuditPseudonymizer(String secret, String keyVersion) { + this(secret, keyVersion, APPROVER_DOMAIN); + } + + /** + * Creates a pseudonymizer with an explicit domain for isolated internal use + * and domain-separation verification. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; blank disables correlation + * @param keyVersion non-sensitive key-rotation identifier + * @param domain stable protocol-specific domain separator + */ + AuditPseudonymizer(String secret, String keyVersion, String domain) { + this.secretBytes = configuredSecretBytes(secret); + this.keyVersion = normalizeKeyVersion(keyVersion); + this.domain = requireDomain(domain); + } + + /** + * Returns a stable keyed fingerprint without exposing the supplied identifier. + * + *

Null input is represented by a fixed absent marker. If no dedicated key + * is configured, the method emits a fixed non-correlatable unavailable marker + * instead of falling back to plaintext or an unkeyed hash. Empty input remains + * distinct from absent input and is HMACed exactly as supplied.

+ * + * @param identifier exact identifier bytes represented as a Java string + * @return versioned fingerprint or a fixed safe marker + */ + public String fingerprint(String identifier) { + if (identifier == null) { + return "absent:" + keyVersion; + } + if (secretBytes == null) { + return "unavailable:" + keyVersion; + } + + String payload = domain + "\n" + identifier; + try { + Mac mac = Mac.getInstance(HMAC_SHA_256); + mac.init(new SecretKeySpec(secretBytes, HMAC_SHA_256)); + byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return keyVersion + ":" + HEX_FORMAT.formatHex(digest, 0, FINGERPRINT_BYTES); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("audit pseudonym HMAC unavailable", ex); + } + } + + private static byte[] configuredSecretBytes(String secret) { + String configuredSecret = cleanSecret(secret); + if (configuredSecret == null) { + return null; + } + + byte[] bytes = configuredSecret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < MIN_SECRET_BYTES) { + throw new IllegalArgumentException( + "audit pseudonym secret must contain at least 32 UTF-8 bytes" + ); + } + return bytes; + } + + private static String cleanSecret(String secret) { + if (secret == null || secret.isBlank()) { + return null; + } + return secret; + } + + private static String normalizeKeyVersion(String keyVersion) { + if (keyVersion == null) { + return DEFAULT_KEY_VERSION; + } + if (keyVersion.isEmpty()) { + throw new IllegalArgumentException("audit pseudonym key version must not be blank"); + } + if (keyVersion.length() > MAX_KEY_VERSION_LENGTH) { + throw new IllegalArgumentException("audit pseudonym key version is too long"); + } + for (int index = 0; index < keyVersion.length(); index++) { + char character = keyVersion.charAt(index); + boolean safe = Character.isLetterOrDigit(character) + || character == '.' + || character == '_' + || character == '-'; + if (!safe) { + throw new IllegalArgumentException("audit pseudonym key version contains unsafe characters"); + } + } + return keyVersion; + } + + private static String requireDomain(String domain) { + if (domain == null || domain.isBlank()) { + throw new IllegalArgumentException("audit pseudonym domain is required"); + } + return domain; + } +} diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java index 95e67228..43884656 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java @@ -18,6 +18,7 @@ import com.clearfolio.viewer.config.ConversionProperties; import com.clearfolio.viewer.exception.UnsupportedDocumentFormatException; +import com.clearfolio.viewer.security.AuditPseudonymizer; /** * Default document validator that enforces extension and size constraints. @@ -32,6 +33,7 @@ public class DefaultDocumentValidationService implements DocumentValidationServi private final Set blockedExtensions; private final long maxUploadSizeBytes; private final String policyOverrideSecret; + private final AuditPseudonymizer auditPseudonymizer; /** * Creates the validation service from conversion configuration values. @@ -42,6 +44,10 @@ public DefaultDocumentValidationService(ConversionProperties conversionPropertie this.blockedExtensions = conversionProperties.getBlockedExtensions(); this.maxUploadSizeBytes = conversionProperties.getMaxUploadSizeBytes(); this.policyOverrideSecret = conversionProperties.getPolicyOverrideSecret(); + this.auditPseudonymizer = new AuditPseudonymizer( + conversionProperties.getAuditPseudonymSecret(), + conversionProperties.getAuditPseudonymKeyVersion() + ); } /** @@ -113,9 +119,9 @@ public void validateOrThrow(MultipartFile file, PolicyOverrideRequest overrideRe if (blockedExtension) { LOGGER.info( - "Blocked-format override accepted extension={} approverId={} tokenFingerprint={}", + "Blocked-format override accepted extension={} approverFingerprint={} tokenFingerprint={}", sanitizeForLog(extension), - sanitizeForLog(overrideApproverIdForAudit), + auditPseudonymizer.fingerprint(overrideApproverIdForAudit), tokenFingerprint(overrideTokenForAudit) ); } @@ -202,7 +208,6 @@ private String tokenFingerprint(String approvalToken) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hashed = digest.digest(approvalToken.getBytes(StandardCharsets.UTF_8)); - // Reused HexFormat for performance return HEX_FORMAT.formatHex(hashed, 0, FINGERPRINT_TRUNCATE_BYTES); } catch (NoSuchAlgorithmException ex) { throw new IllegalStateException("SHA-256 digest unavailable", ex); @@ -213,8 +218,6 @@ private String sanitizeForLog(final String value) { if (value == null) { return ""; } - // ⚡ Bolt: Single-pass string sanitization - // Avoids multiple allocations from chained replace() calls. StringBuilder sb = null; for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); diff --git a/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java b/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java index 76790b55..0d77ef62 100644 --- a/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java +++ b/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java @@ -101,12 +101,22 @@ private static String normalizeHeader(final String value) { return sb == null ? value : sb.toString(); } + /** + * Returns a log-safe diagnostic representation. + * + *

The approval token and approver identifier are always redacted, even + * when absent, so callers cannot accidentally disclose either secret value + * or infer whether an approver identifier was supplied from this string.

+ * + * @return diagnostic text containing only the sanitized override flag and + * fixed redaction markers for sensitive headers + */ @Override public String toString() { return "PolicyOverrideRequest{" + "policyOverride='" + normalizeHeader(policyOverride) + '\'' + ", approvalToken='[redacted]'" - + ", approverId='" + normalizeHeader(approverId) + '\'' + + ", approverId='[redacted]'" + '}'; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index df4f72aa..87a025b0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,14 @@ spring: application: name: clearfolio-viewer + config: + # Runtime key material is mounted as a Spring Boot config tree rather than + # carried in process environment variables. The environment variable below + # selects only the bootstrap directory. Mount files named + # `conversion.policy-override-secret`, + # `conversion.audit-pseudonym-secret`, and + # `conversion.audit-pseudonym-key-version` in this directory. + import: "optional:configtree:${CLEARFOLIO_SECRET_CONFIG_DIR:/run/secrets/clearfolio/}" codec: max-in-memory-size: ${conversion.max-upload-size-bytes} @@ -9,7 +17,6 @@ conversion: - hwp - hwpx worker-threads: 4 - policy-override-secret: "${CONVERSION_POLICY_OVERRIDE_SECRET:}" queue-capacity: 200 max-retry-attempts: 3 retry-initial-delay-ms: 500 diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java new file mode 100644 index 00000000..9e1c4a6f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -0,0 +1,40 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.config.ConversionProperties; + +class AuditKeySeparationGuardTest { + + @Test + void rejectsIdenticalConfiguredKeysDuringStartup() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("shared-key-material"); + properties.setAuditPseudonymSecret("shared-key-material"); + + assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + } + + @Test + void acceptsDistinctConfiguredKeys() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("policy-signing-key"); + properties.setAuditPseudonymSecret("audit-pseudonym-key"); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + + @Test + void permitsDisabledSecurityPurposesWithoutComparingMissingValues() { + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(null, "audit-key")); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct("policy-key", null)); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(" ", "audit-key")); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct("policy-key", " ")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java new file mode 100644 index 00000000..5200269a --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java @@ -0,0 +1,41 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Verifies the minimum configured key-strength contract for audit pseudonyms. + */ +class AuditPseudonymizerKeyStrengthTest { + + @Test + void rejectsConfiguredSecretShorterThanThirtyTwoUtf8Bytes() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer("0123456789abcdef0123456789abcde", "v1") + ); + + assertTrue(exception.getMessage().contains("at least 32 UTF-8 bytes")); + } + + @Test + void acceptsConfiguredSecretWithThirtyTwoUtf8Bytes() { + AuditPseudonymizer pseudonymizer = assertDoesNotThrow( + () -> new AuditPseudonymizer("0123456789abcdef0123456789abcdef", "v1") + ); + + assertTrue(pseudonymizer.fingerprint("approver").startsWith("v1:")); + } + + @Test + void measuresConfiguredSecretLengthAsUtf8Bytes() { + AuditPseudonymizer pseudonymizer = assertDoesNotThrow( + () -> new AuditPseudonymizer("가나다라마바사아자차카타파하가나", "v1") + ); + + assertTrue(pseudonymizer.fingerprint("approver").startsWith("v1:")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java new file mode 100644 index 00000000..ce9b01b7 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java @@ -0,0 +1,182 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.Provider; +import java.security.Security; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class AuditPseudonymizerTest { + + private static final Object SECURITY_PROVIDERS_LOCK = new Object(); + private static final String AUDIT_KEY_ONE = "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_KEY_TWO = "fedcba9876543210fedcba9876543210"; + + @Test + void producesDeterministicVersionedFingerprintForExactIdentifierBytes() { + AuditPseudonymizer pseudonymizer = new AuditPseudonymizer(AUDIT_KEY_ONE, "2026-08"); + + String first = pseudonymizer.fingerprint("Employee-007@example.com"); + String second = pseudonymizer.fingerprint("Employee-007@example.com"); + + assertEquals(first, second); + assertTrue(first.matches("2026-08:[0-9a-f]{32}")); + assertFalse(first.contains("Employee-007")); + } + + @Test + void separatesKeysVersionsAndDomains() { + String identifier = "approver-123"; + String baseline = new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v1", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier); + + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_TWO, + "v1", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier) + ); + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v2", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier) + ); + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v1", + "clearfolio:audit-subject:v1" + ).fingerprint(identifier) + ); + } + + @Test + void distinguishesAbsentEmptyAndUnavailableValues() { + AuditPseudonymizer configured = new AuditPseudonymizer(AUDIT_KEY_ONE, "v1"); + AuditPseudonymizer unavailableWhitespace = new AuditPseudonymizer(" ", "v1"); + AuditPseudonymizer unavailableNull = new AuditPseudonymizer(null, "v1"); + + assertEquals("absent:v1", configured.fingerprint(null)); + assertTrue(configured.fingerprint("").matches("v1:[0-9a-f]{32}")); + assertNotEquals(configured.fingerprint(null), configured.fingerprint("")); + assertEquals("unavailable:v1", unavailableWhitespace.fingerprint("approver")); + assertEquals("unavailable:v1", unavailableNull.fingerprint("approver")); + assertEquals("absent:v1", unavailableWhitespace.fingerprint(null)); + } + + @Test + void preservesUnicodeAndControlCharactersOnlyInsideTheHmacInput() { + String identifier = "승인자\n\u202E@example.com"; + String fingerprint = new AuditPseudonymizer(AUDIT_KEY_ONE, "unicode-v1") + .fingerprint(identifier); + + assertTrue(fingerprint.matches("unicode-v1:[0-9a-f]{32}")); + assertFalse(fingerprint.contains("승인자")); + assertFalse(fingerprint.contains("example.com")); + assertFalse(fingerprint.contains("\n")); + assertFalse(fingerprint.contains("\u202E")); + } + + @Test + void defaultsOnlyMissingKeyVersionAndRejectsInvalidExplicitValues() { + assertTrue(new AuditPseudonymizer(AUDIT_KEY_ONE, null).fingerprint("id").startsWith("v1:")); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, " ") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, " v1") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1 ") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "bad/version") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "x".repeat(33)) + ); + assertTrue( + new AuditPseudonymizer(AUDIT_KEY_ONE, "valid._-9") + .fingerprint("id") + .startsWith("valid._-9:") + ); + } + + @Test + void rejectsMissingDomain() { + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1", null) + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1", " ") + ); + } + + @Test + void wrapsMissingHmacProviderAsStableInternalFailure() { + synchronized (SECURITY_PROVIDERS_LOCK) { + List removedProviders = hmacProviderPositions(); + for (ProviderPosition providerPosition : removedProviders) { + Security.removeProvider(providerPosition.provider().getName()); + } + try { + AuditPseudonymizer pseudonymizer = new AuditPseudonymizer(AUDIT_KEY_ONE, "v1"); + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> pseudonymizer.fingerprint("approver") + ); + assertEquals("audit pseudonym HMAC unavailable", exception.getMessage()); + } finally { + removedProviders.stream() + .sorted(Comparator.comparingInt(ProviderPosition::position)) + .forEach(providerPosition -> Security.insertProviderAt( + providerPosition.provider(), + providerPosition.position() + )); + } + } + } + + private static List hmacProviderPositions() { + Provider[] providers = Security.getProviders(); + List positions = new ArrayList<>(); + for (int index = 0; index < providers.length; index++) { + Provider provider = providers[index]; + if (provider.getService("Mac", "HmacSHA256") != null) { + positions.add(new ProviderPosition(provider, index + 1)); + } + } + return positions; + } + + private record ProviderPosition(Provider provider, int position) { + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java new file mode 100644 index 00000000..7cafcd86 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java @@ -0,0 +1,170 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import com.clearfolio.viewer.config.ConversionProperties; + +class DefaultDocumentValidationServiceAuditTest { + + private static final String AUDIT_PSEUDONYM_SECRET = + "0123456789abcdef0123456789abcdef"; + + @Test + void acceptedOverrideLogsOnlyPrivacySafeFingerprints() { + String approverId = "employee-007@example.com"; + String policySecret = "policy-signing-secret"; + String approvalToken = generateSignature(approverId, "hwp", policySecret); + ConversionProperties properties = configuredProperties( + policySecret, + AUDIT_PSEUDONYM_SECRET, + "rotation-7" + ); + DefaultDocumentValidationService service = new DefaultDocumentValidationService(properties); + CapturingAppender appender = attachAppender(); + + try { + service.validateOrThrow( + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), + PolicyOverrideRequest.of("true", approvalToken, approverId) + ); + } finally { + appender.closeAndDetach(); + } + + String auditLine = appender.singleMessage(); + assertTrue(auditLine.contains("approverFingerprint=rotation-7:")); + assertTrue(auditLine.contains("tokenFingerprint=")); + assertFalse(auditLine.contains(approverId)); + assertFalse(auditLine.contains(approvalToken)); + assertFalse(auditLine.contains("approverId=")); + } + + @Test + void missingDedicatedAuditKeyUsesNonCorrelatableSentinel() { + String approverId = "employee-008"; + String policySecret = "policy-signing-secret"; + String approvalToken = generateSignature(approverId, "hwp", policySecret); + ConversionProperties properties = configuredProperties(policySecret, "", "v9"); + DefaultDocumentValidationService service = new DefaultDocumentValidationService(properties); + CapturingAppender appender = attachAppender(); + + try { + service.validateOrThrow( + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), + PolicyOverrideRequest.of("true", approvalToken, approverId) + ); + } finally { + appender.closeAndDetach(); + } + + String auditLine = appender.singleMessage(); + assertTrue(auditLine.contains("approverFingerprint=unavailable:v9")); + assertFalse(auditLine.contains(approverId)); + assertFalse(auditLine.contains(approvalToken)); + } + + @Test + void auditConfigurationNullsUseDocumentedSafeDefaults() { + ConversionProperties properties = new ConversionProperties(); + + properties.setAuditPseudonymSecret(null); + properties.setAuditPseudonymKeyVersion(null); + + assertEquals("", properties.getAuditPseudonymSecret()); + assertEquals("v1", properties.getAuditPseudonymKeyVersion()); + } + + private static ConversionProperties configuredProperties( + String policySecret, + String auditSecret, + String keyVersion) { + ConversionProperties properties = new ConversionProperties(); + properties.setBlockedExtensions(Set.of("hwp", "hwpx")); + properties.setPolicyOverrideSecret(policySecret); + properties.setAuditPseudonymSecret(auditSecret); + properties.setAuditPseudonymKeyVersion(keyVersion); + return properties; + } + + private static String generateSignature(String approverId, String extension, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String payload = approverId.length() + ":" + approverId + extension; + return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + throw new IllegalStateException("test signature generation failed", ex); + } + } + + private static CapturingAppender attachAppender() { + Logger logger = (Logger) LogManager.getLogger(DefaultDocumentValidationService.class); + CapturingAppender appender = new CapturingAppender(logger); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.INFO); + return appender; + } + + private static final class CapturingAppender extends AbstractAppender { + + private final Logger logger; + private final List messages = new ArrayList<>(); + + private CapturingAppender(Logger logger) { + super( + "audit-test-appender", + null, + PatternLayout.newBuilder().withPattern("%m").build(), + false, + null + ); + this.logger = logger; + } + + @Override + public void append(LogEvent event) { + messages.add(event.getMessage().getFormattedMessage()); + } + + private String singleMessage() { + assertEquals(1, messages.size()); + return messages.getFirst(); + } + + private void closeAndDetach() { + logger.removeAppender(this); + stop(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java b/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java index 00a34a26..e8e7966b 100644 --- a/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java +++ b/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java @@ -4,8 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -53,30 +53,41 @@ void ofCreatesDistinctInstanceWhenOnlyApproverHeaderIsPresent() { } @Test - void toStringRedactsApprovalToken() { - PolicyOverrideRequest request = PolicyOverrideRequest.of("true", "secret-token", "approver-1"); + void toStringRedactsApprovalTokenAndApproverIdentifier() { + PolicyOverrideRequest request = PolicyOverrideRequest.of( + "true", + "secret-token", + "private-approver@example.com" + ); String rendered = request.toString(); assertTrue(rendered.contains("approvalToken='[redacted]'")); + assertTrue(rendered.contains("approverId='[redacted]'")); assertFalse(rendered.contains("secret-token")); + assertFalse(rendered.contains("private-approver@example.com")); } @Test - void toStringNormalizesControlCharactersInPrintableHeaders() { - PolicyOverrideRequest request = PolicyOverrideRequest.of("true\n", "secret-token", "approver\r\n1\t"); + void toStringNormalizesControlCharactersInPrintableOverrideFlag() { + PolicyOverrideRequest request = PolicyOverrideRequest.of( + "tr\nue\t", + "secret-token", + "sensitive-user\r\n1\t" + ); String rendered = request.toString(); - assertTrue(rendered.contains("policyOverride='true_'")); - assertTrue(rendered.contains("approverId='approver__1_'")); + assertTrue(rendered.contains("policyOverride='tr_ue_'")); + assertTrue(rendered.contains("approverId='[redacted]'")); + assertFalse(rendered.contains("sensitive-user")); } @Test - void toStringHandlesNullPrintableHeaders() { + void toStringHandlesNullPrintableHeaderWithoutRevealingIdentityState() { String rendered = PolicyOverrideRequest.none().toString(); assertTrue(rendered.contains("policyOverride='null'")); - assertTrue(rendered.contains("approverId='null'")); + assertTrue(rendered.contains("approverId='[redacted]'")); } } From 28966e640eeae2119ac6fcee04419219ef33b00c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:05:39 +0900 Subject: [PATCH 02/85] test(security): require strong policy override keys --- .../security/AuditKeySeparationGuardTest.java | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java index 9e1c4a6f..ce613765 100644 --- a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -9,11 +10,14 @@ class AuditKeySeparationGuardTest { + private static final String POLICY_KEY = "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_KEY = "fedcba9876543210fedcba9876543210"; + @Test void rejectsIdenticalConfiguredKeysDuringStartup() { ConversionProperties properties = new ConversionProperties(); - properties.setPolicyOverrideSecret("shared-key-material"); - properties.setAuditPseudonymSecret("shared-key-material"); + properties.setPolicyOverrideSecret(POLICY_KEY); + properties.setAuditPseudonymSecret(POLICY_KEY); assertThrows( IllegalStateException.class, @@ -21,11 +25,32 @@ void rejectsIdenticalConfiguredKeysDuringStartup() { ); } + @Test + void rejectsConfiguredPolicyKeyShorterThanThirtyTwoUtf8Bytes() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("short-policy-key"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + + assertTrue(exception.getMessage().contains("at least 32 UTF-8 bytes")); + } + @Test void acceptsDistinctConfiguredKeys() { ConversionProperties properties = new ConversionProperties(); - properties.setPolicyOverrideSecret("policy-signing-key"); - properties.setAuditPseudonymSecret("audit-pseudonym-key"); + properties.setPolicyOverrideSecret(POLICY_KEY); + properties.setAuditPseudonymSecret(AUDIT_KEY); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + + @Test + void acceptsConfiguredPolicyKeyMeasuredAsThirtyTwoOrMoreUtf8Bytes() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("가나다라마바사아자차카"); assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); } From 2e9c4a2f81584229acbf9dcc9ab3c36930a50092 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:06:04 +0900 Subject: [PATCH 03/85] fix(security): reject weak policy override keys --- .../security/AuditKeySeparationGuard.java | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java index 240fae06..3b45d45f 100644 --- a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -8,28 +8,45 @@ import com.clearfolio.viewer.config.ConversionProperties; /** - * Fails application startup when policy signing and audit pseudonymization use - * the same configured key material. + * Fails application startup when configured policy-signing key material is weak + * or when policy signing and audit pseudonymization reuse the same key. * - *

The two HMAC purposes form separate security domains. Reusing one value + *

The policy-override key protects an administrative authorization decision, + * so a configured value must contain at least 32 UTF-8 bytes. The policy and + * audit HMAC purposes also form separate security domains. Reusing one value * would allow a holder of the audit key to create policy-override signatures, * so nonblank configured values must remain distinct.

*/ @Component public final class AuditKeySeparationGuard { + private static final int MINIMUM_POLICY_SECRET_BYTES = 32; + /** * Validates the bound conversion security configuration during bean startup. * * @param properties bound conversion configuration */ public AuditKeySeparationGuard(ConversionProperties properties) { + requireStrongPolicySecret(properties.getPolicyOverrideSecret()); requireDistinct( properties.getPolicyOverrideSecret(), properties.getAuditPseudonymSecret() ); } + static void requireStrongPolicySecret(String policySecret) { + if (!isConfigured(policySecret)) { + return; + } + if (policySecret.getBytes(StandardCharsets.UTF_8).length + < MINIMUM_POLICY_SECRET_BYTES) { + throw new IllegalStateException( + "policy override key must contain at least 32 UTF-8 bytes" + ); + } + } + static void requireDistinct(String policySecret, String auditSecret) { if (!isConfigured(policySecret) || !isConfigured(auditSecret)) { return; From ac70dd738acea7eeb311916eda1b2fa8f3782c82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:07:17 +0900 Subject: [PATCH 04/85] docs(security): document policy override key strength --- docs/security/2026-08-04-audit-pseudonymization.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md index 92b06e93..6f39f223 100644 --- a/docs/security/2026-08-04-audit-pseudonymization.md +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -8,6 +8,12 @@ The approver field is named `approverFingerprint`, not `approverId`, so downstre ## 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: @@ -57,8 +63,9 @@ Spring reads each file's contents as the corresponding property. The deployment ## 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 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 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. @@ -85,7 +92,8 @@ Automated tests must prove: - determinism within one key version and domain; - separation across keys, versions, and domains; -- rejection of configured keys shorter than 32 UTF-8 bytes; +- 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; From 27106a53071465fc3b7439b1bfdf305aa58e373e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:37:26 +0000 Subject: [PATCH 05/85] fix: trigger CI due to strix timeout --- update_commit.sh | 1 + 1 file changed, 1 insertion(+) create mode 100755 update_commit.sh diff --git a/update_commit.sh b/update_commit.sh new file mode 100755 index 00000000..6e48d953 --- /dev/null +++ b/update_commit.sh @@ -0,0 +1 @@ +git commit --allow-empty -m "trigger CI: Retry due to Strix GitHub Models transient outage" From b3ad882680f81b2bbfeddac892ba71bb45af8ade Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:59:42 +0900 Subject: [PATCH 06/85] chore: remove stray CI trigger script --- update_commit.sh | 1 - 1 file changed, 1 deletion(-) delete mode 100755 update_commit.sh diff --git a/update_commit.sh b/update_commit.sh deleted file mode 100755 index 6e48d953..00000000 --- a/update_commit.sh +++ /dev/null @@ -1 +0,0 @@ -git commit --allow-empty -m "trigger CI: Retry due to Strix GitHub Models transient outage" From 0e7770be5de992a7d4e24f4626508682bf9d7232 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:05:47 +0900 Subject: [PATCH 07/85] test: cover disabled policy signing startup path --- .../viewer/security/AuditKeySeparationGuardTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java index ce613765..5d44c4d5 100644 --- a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -55,6 +55,13 @@ void acceptsConfiguredPolicyKeyMeasuredAsThirtyTwoOrMoreUtf8Bytes() { assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); } + @Test + void permitsMissingPolicyKeyWhenPolicySigningIsDisabled() { + ConversionProperties properties = new ConversionProperties(); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + @Test void permitsDisabledSecurityPurposesWithoutComparingMissingValues() { assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(null, "audit-key")); From a86ab46c3214c803a90f51deec3d4d8723699667 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:06:15 +0900 Subject: [PATCH 08/85] docs: consolidate unreleased changelog entries --- CHANGELOG.md | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5ba27b..a1a683cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,31 +1,34 @@ +# Changelog + ## [Unreleased] + ### 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..."이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. + - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. ### Changed + - PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. ### Security + - 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. - 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. -# Changelog - -## [Unreleased] - -### 추가된 기능 (Added) -- **관리자용 단건 작업 삭제 및 재시도 API 추가** - - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. - - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. - -- **비동기 버튼 로딩 피드백 및 상태 복원 개선** - - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..." 이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. - - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. +### Fixed +- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정했습니다. ## [0.1.0] - 2026-06-25 ### 추가된 기능 (Added) + - **비동기 버튼 로딩 상태 UX 개선 (Async Button Loading States)** - 문서 제출(`submitDocument`), 데모 데이터 로드(`loadDemoData`), 실패 작업 재시도(`retryActiveJob`) 등 비동기 요청을 수행하는 버튼들에 대해 처리 중 명시적인 로딩 상태(Loading, Submitting, Retrying 등)를 추가했습니다. - 사용자의 중복 클릭을 방지하기 위해 작업 중에는 버튼이 비활성화되도록 수정했습니다. @@ -41,9 +44,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개 테스트 통과를 확인했습니다. @@ -52,6 +57,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을 손상시키지 않고 안전하게 복원되도록 수정 \ No newline at end of file From eb343235981880e994e2e91a841cdf67e428e970 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:11:12 +0900 Subject: [PATCH 09/85] build: enforce zero missed production lines and branches --- pom.xml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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 + 4.1.136.Final M[Maven resolves
Spring Boot + netty.version] + M --> C[CycloneDX Maven Plugin 2.9.1
makeAggregateBom] + C --> B[target/bom.json
CycloneDX 1.6] + B --> V[Graph verifier
61 components / 17 Netty] + V --> A[Deterministic attribution renderer] + B --> I[Immutable Actions artifact
ID 8929593015] + A --> I + I --> D[Committed buyer data-room evidence] + D --> T[Permanent drift and dependency tests] +``` + +The exact generation command is: + +```bash +mvn -B --no-transfer-progress -DskipTests \ + org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom \ + -Dcyclonedx.skipAttach=true \ + -DoutputFormat=json \ + -DoutputName=bom +``` + +The plugin writes the canonical JSON document to `target/bom.json`. The `outputFormat` and `outputName` parameters are Maven user properties without a `cyclonedx.` prefix; only `cyclonedx.skipAttach` uses that prefix in this invocation. + +## Evidence record + +Read-only workflow run `31004040777` generated the accepted evidence from source head `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` at `2026-08-05T12:07:15Z`. + +- Artifact ID: `8929593015` +- Artifact archive SHA-256: `07a0325e08157f00dda28c58ed4e41af51863cccb2ceea2c4e378ead77dc337f` +- CycloneDX version: `1.6` +- Total components: `61` +- Netty components: `17` +- Netty version set: exactly `4.1.136.Final` +- SBOM SHA-256: `e138a9263edb40c613d5f159acba8fa89ee848a7cef4b6619e095c48451b095c` +- Attribution SHA-256: `e19a3767a545bd059e50003882d8ff2f8a3ff4d3b8fd28d3f305eead61261da9` + +The graph verifier proved that every Netty dependency reference has a corresponding current Netty component ref and that `4.1.135.Final` is absent from both generated files. The attribution renderer was rerun from the generated JSON and matched the committed Markdown byte contract. + +The committed SBOM and attribution are shareable buyer evidence. Workflow logs and the one-day artifact are transient generation provenance and must not be presented as durable data-room evidence after expiry. Reproduction therefore depends on the documented command, exact source revision, immutable plugin version, committed hashes, permanent drift tests, and fresh exact-head CI. ## Security and compatibility boundaries @@ -31,17 +80,19 @@ The override is guarded by `DependencyPolicyTest.pomPinsPatchedNettyLineForReact - Maven must resolve all applicable `io.netty` modules to `4.1.136.Final`; stale modules at `4.1.135.Final` or an older version are a release blocker. - Existing zero-missed-line and zero-missed-branch JaCoCo gates, compiler warnings-as-errors, fuzzing, SAST, dependency review, OSV, Trivy, Scorecard, Strix, and independent review remain mandatory. - A successful unit-test run does not replace dependency-tree and security-scanner evidence. -- The override must not be copied into downstream modules as separate ad hoc pins. Shared consumers should inherit the version through the root build or a versioned central BOM contract. +- The override must not be copied into downstream modules as separate ad hoc pins. Standalone builds inherit the root property; modular consumers should use a versioned BOM or equivalent explicit contract. +- The evidence record describes the dependency graph of its exact generation head. Any dependency change requires regeneration and a new evidence hash record. ## Verification For the exact pull-request head: 1. Run `mvn -B --no-transfer-progress verify`. -2. Inspect `mvn -B --no-transfer-progress dependency:tree -Dincludes=io.netty` and confirm one coherent `4.1.136.Final` line for applicable Netty modules. -3. Require successful CI, Security Scan, SAST Semgrep, fuzzing, CodeRabbit/OpenCode/Noema review, and Strix evidence for the same head. -4. Reject cancelled, skipped-required, stale-head, or manually inferred results. -5. Preserve an independent approving review that GitHub counts under protected-branch rules. +2. Run `mvn -B --no-transfer-progress dependency:tree -Dincludes=io.netty` and confirm one coherent `4.1.136.Final` line for every applicable Netty module. +3. Run `python3 scripts/test_render_third_party_attribution.py` and require the generated graph and attribution drift contract to pass. +4. Require successful CI, Security Scan, SAST Semgrep, every fuzz target, CodeRabbit, Strix, OpenCode, and Noema evidence for the same head. +5. Reject queued, cancelled, skipped-required, stale-head, local-only, or manually inferred results. +6. Preserve an independent approving review that GitHub counts under protected-branch rules. ## Removal and upgrade rule @@ -50,27 +101,31 @@ Keep this override until one of the following occurs: - the Spring Boot parent used by Clearfolio manages Netty `4.1.136.Final` or a later reviewed compatible release; or - Clearfolio moves to a different supported reactive HTTP stack through an accepted architecture decision. -Removing or increasing the override requires the same exact-head dependency-tree, compatibility, security, coverage, and review evidence. A newer version number alone is not proof of compatibility. +Removing or increasing the override requires the same exact-head dependency-tree, compatibility, security, coverage, SBOM regeneration, and review evidence. A newer version number alone is not proof of compatibility. ## Consequences ### Positive -- The complete Netty family moves to the reviewed fixed 4.1 patch line. -- The remediation is expressed through Spring Boot's documented version-property mechanism rather than fragile per-artifact pins. -- A real-project contract test prevents silent regression when dependency management is edited. -- The decision and evidence remain auditable for buyer security review and future upgrades. +- The complete Netty family moves to one reviewed fixed 4.1 patch line. +- The remediation uses Spring Boot's documented version-property mechanism rather than fragile per-artifact pins. +- Real-project and generated-evidence contracts prevent silent dependency or data-room drift. +- Exact generation provenance, hashes, and local-versus-shareable evidence boundaries remain auditable for acquisition diligence. ### Trade-offs - Clearfolio temporarily diverges from the Netty patch version selected by Spring Boot 3.5.16. - The project must retain explicit exact-head compatibility and scanner evidence until the parent line catches up. -- A future parent upgrade must reconcile this property deliberately rather than assuming it is obsolete. +- A future parent upgrade must reconcile this property deliberately and regenerate the buyer evidence. ## References +CycloneDX Project. (n.d.). *CycloneDX Maven plugin* [Source code]. GitHub. Retrieved August 5, 2026, from https://github.com/CycloneDX/cyclonedx-maven-plugin + Netty Project. (2026, July 8). *Netty 4.1.136.Final* [Software release]. GitHub. https://github.com/netty/netty/releases/tag/netty-4.1.136.Final -Spring. (2026). *Managed dependency coordinates: Spring Boot 3.5.16*. https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/coordinates.html +OWASP Foundation. (2024, April 9). *CycloneDX 1.6* [Software bill of materials specification]. https://github.com/CycloneDX/specification/releases/tag/1.6 + +Spring. (n.d.). *Managed dependency coordinates: Spring Boot 3.5.16*. Retrieved August 5, 2026, from https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/coordinates.html -Spring. (2026). *Version properties: Spring Boot 3.5.16*. https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/properties.html +Spring. (n.d.). *Version properties: Spring Boot 3.5.16*. Retrieved August 5, 2026, from https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/properties.html From 67cd42c435865b565c52697019aba927eddd6a0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:04:12 +0900 Subject: [PATCH 33/85] docs(evidence): correct CycloneDX generation contract --- .../2026-07-02-krw2b-sale-readiness/README.md | 230 +++++++++--------- 1 file changed, 110 insertions(+), 120 deletions(-) diff --git a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md index d8f2b78b..a071f400 100644 --- a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md +++ b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md @@ -1,40 +1,49 @@ # KRW 2B Sale-Readiness Evidence -Date: 2026-07-02 -Verification source head SHA before this evidence refresh: -`7df3ac8b8253cd1a445ba7faddbf99bc9a5c5fcd` +Original evidence date: 2026-07-02 +Original verification source head: `7df3ac8b8253cd1a445ba7faddbf99bc9a5c5fcd` +Latest dependency-evidence refresh: 2026-08-05 +Netty SBOM generation source head: `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` + +## Evidence Boundary + +This directory combines a historical sale-readiness snapshot with selected generated artifacts that remain under executable drift contracts. A `Pass` result describes the named artifact and its source revision; it is not automatically transferable to a later source head. + +The committed CycloneDX JSON and generated third-party attribution are shareable buyer data-room evidence. GitHub Actions logs and the one-day generation artifact are transient provenance. Any dependency change must regenerate the SBOM, attribution, hashes, and exact-head acceptance evidence before release. ## Gate Summary | Gate | Result | Evidence | | --- | --- | --- | -| Java runtime | Pass, Java 26.0.1 runtime with Java 21 release-target compile | `java-version.txt`, `compile.log` | -| Compile warnings/deprecations | Pass | `compile.log` | -| Tests + JaCoCo | Pass, 357 tests, `classes=49`, `line_missed=0`, `branch_missed=0` | `mvn-test.log`, `test-jacoco.log`, `jacoco.csv`, `jacoco-status.txt` | -| JavaDoc | Pass, `javadoc_warnings_or_errors=none` | `javadoc.log`, `javadoc-status.txt` | -| Markdown lint | Pass, 0 errors across changed docs | `markdownlint.log` | -| JS syntax | Pass | `node-check.log` | -| SAST | Pass, 0 findings | `semgrep.log`, `semgrep.json` | -| SBOM | Pass, CycloneDX 1.6, 61 components, 0 components without license metadata | `sbom-cyclonedx.log`, `sbom-cyclonedx.json`, `sbom-status.txt` | -| License review | Pass, buyer-release policy checker reports 61 allowed components, 0 review-required components, 0 unlisted violations, and passes `--require-no-review` | `docs/security/2026-07-02-license-allowlist-review.md`, `license-policy-summary.json`, `license-policy-test.log` | -| Third-party attribution | Pass, generated buyer data-room attribution contains all 61 current SBOM components and passes drift check | `docs/legal/2026-07-03-third-party-attribution.md`, `third-party-attribution-check.log` | -| Buyer data-room manifest | Pass, manifest references required buyer evidence artifacts, all local paths exist, and ready gates reference only ready artifacts | `docs/diligence/2026-07-03-buyer-data-room-manifest.json`, `buyer-dataroom-manifest-check.log` | -| Buyer readiness scorecard | Pass, generated scorecard reports 23 artifacts, 8 readiness gates, 38 percent conservative gate readiness, and ready-gate evidence integrity pass from the current data-room manifest | `docs/diligence/2026-07-03-buyer-readiness-scorecard.md`, `buyer-readiness-scorecard-summary.json` | -| Figma Slides generation payload | Pass, payload check reports 11 slides, 4 objectives, and 0 errors; actual Slides generation still requires Figma team or organization plan selection | `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json`, `figma-deck-payload-check.json` | -| Auth/tenant, signed artifacts, and KPI snapshots | Partial, runtime tenant enforcement, optional gateway HMAC tenant-claim validation, production-profile fail-closed startup without signed tenant secret, signed artifact tokens, token revocation, artifact read audit API, optional file-backed artifact-link ledger replay, optional file-backed KPI snapshot ledger replay, and tenant-scoped KPI snapshot export API implemented; OIDC/JWT and centralized durable revocation/audit/analytics persistence pending | `docs/security/2026-07-02-auth-tenant-model.md`, `docs/security/2026-07-02-signed-artifact-link-design.md`, auth/artifact/analytics tests | -| Buyer deployment integration | Pass for buyer sandbox scope; `buyer-demo` Spring profile, gateway-signed header contract, connector API table, OpenAPI connector seed, smoke path, and cutover gates are documented; buyer tenant import and production OIDC/JWT profile remain follow-up | `src/main/resources/application-buyer-demo.yml`, `docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md`, `docs/deployment/clearfolio-buyer-connector.openapi.yaml` | -| Durable job repository design, state-store, lifecycle event, and recovery sweep slice | Partial, code boundary implemented; `ConversionJobStateStore` routes worker success/failure and operator retry transitions, `ConversionJobLifecycleEvent` records process-local append-only transition evidence, and `DefaultConversionWorker` now re-enqueues due submitted jobs plus stale processing leases from available repository state, while SQL persistence remains pending for true process-restart durability | `docs/persistence/2026-07-02-durable-conversion-job-repository-plan.md`, state-store, lifecycle event, and recovery sweep tests | -| Seeded buyer-demo screenshots | Pass for local screenshot scope; seeded desktop and mobile viewports render after `Load demo story`, with no mobile horizontal overflow and uploaded FigJam screenshot nodes `25:1423` and `25:1422` | `seeded-demo-story-verification.md`, `screenshots/seeded-demo-desktop-viewport.png`, `screenshots/seeded-demo-mobile-viewport.png` | -| Buyer diligence closure map | Pass for FigJam handoff scope; added `Clearfolio KRW 2B Buyer Diligence Closure Map`, `Clearfolio Buyer Readiness Scorecard Gate Map`, and `Clearfolio Buyer Diligence Slides Storyboard` on the existing evidence board, and captured Slides generation prerequisites plus deck outline | `docs/design/2026-07-03-buyer-diligence-slides-and-closure-map.md`, `docs/design/2026-07-02-buyer-demo-kpi-figjam-handoff.md` | -| Local smoke | Pass, signed tenant claims plus file-backed artifact/KPI ledgers, KPI snapshot export API, buyer-demo KPI evidence panel, and operator recovery evidence panel | `smoke-local.txt`, `smoke-app.log`, `smoke-ui-root.txt` | -| GitHub PR state | Seeded buyer-demo story branch is refreshed on current `main`; review and queued checks are not treated as blockers | PR body and GitHub UI | +| Java runtime | Pass for original snapshot, Java 26.0.1 runtime with Java 21 release-target compile | `java-version.txt`, `compile.log` | +| Compile warnings/deprecations | Pass for original snapshot | `compile.log` | +| Tests + JaCoCo | Pass for original snapshot, 357 tests, `classes=49`, `line_missed=0`, `branch_missed=0` | `mvn-test.log`, `test-jacoco.log`, `jacoco.csv`, `jacoco-status.txt` | +| JavaDoc | Pass for original snapshot, `javadoc_warnings_or_errors=none` | `javadoc.log`, `javadoc-status.txt` | +| Markdown lint | Pass for original snapshot, 0 errors across changed docs | `markdownlint.log` | +| JS syntax | Pass for original snapshot | `node-check.log` | +| SAST | Pass for original snapshot, 0 findings | `semgrep.log`, `semgrep.json` | +| SBOM | Refreshed 2026-08-05, CycloneDX 1.6, 61 components, 17 Netty components at `4.1.136.Final`, 0 components without license metadata | `sbom-cyclonedx.json`, Netty ADR, permanent drift test | +| License review | Pass for current 61-component generated SBOM; 0 review-required and 0 unlisted violations under buyer-release policy | `docs/security/2026-07-02-license-allowlist-review.md`, `license-policy-summary.json`, `license-policy-test.log` | +| Third-party attribution | Refreshed from the same generated SBOM and protected by byte-for-byte renderer drift validation | `docs/legal/2026-07-03-third-party-attribution.md`, `scripts/test_render_third_party_attribution.py` | +| Buyer data-room manifest | Pass for original snapshot; required local paths existed and ready gates cited only ready artifacts | `docs/diligence/2026-07-03-buyer-data-room-manifest.json`, `buyer-dataroom-manifest-check.log` | +| Buyer readiness scorecard | Pass for original snapshot; 23 artifacts, 8 readiness gates, 38 percent conservative gate readiness | `docs/diligence/2026-07-03-buyer-readiness-scorecard.md`, `buyer-readiness-scorecard-summary.json` | +| Figma Slides generation payload | Pass for payload scope; 11 slides, 4 objectives, 0 errors; actual Slides generation still requires an eligible Figma plan | `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json`, `figma-deck-payload-check.json` | +| Auth/tenant, signed artifacts, and KPI snapshots | Partial; runtime tenant enforcement, signed claims, signed artifact tokens, revocation, audit, and file-backed ledgers exist, while production OIDC/JWT and centralized durable persistence remain pending | Security model, artifact, analytics, and persistence tests | +| Buyer deployment integration | Pass for buyer sandbox scope; connector seed, gateway-signed claims, smoke path, and cutover gates documented | Buyer deployment playbook, connector OpenAPI, buyer-demo profile | +| Durable job repository and recovery slice | Partial; code boundary, state store, lifecycle events, and process-local recovery exist, while SQL process-restart durability remains pending | Persistence plan and repository/state-store tests | +| Seeded buyer-demo screenshots | Pass for local screenshot scope; desktop/mobile seeded story, no mobile overflow | Seeded demo verification and screenshots | +| Buyer diligence closure map | Pass for FigJam handoff scope | Design handoff documentation | +| Local smoke | Pass for original signed-tenant, artifact-ledger, KPI-ledger, viewer, revocation, and recovery scope | `smoke-local.txt`, `smoke-app.log`, `smoke-ui-root.txt` | +| GitHub PR state | Dynamic; queued or waiting review does not stop productive work but is never counted as merge acceptance | Current exact-head PR checks and reviews | ## SAST -Command: +Command used for the original evidence snapshot: ```bash -uvx semgrep --config p/java --metrics=off --error --json --output docs/qa/evidence/2026-07-02-krw2b-sale-readiness/semgrep.json src/main/java src/test/java +uvx semgrep --config p/java --metrics=off --error --json \ + --output docs/qa/evidence/2026-07-02-krw2b-sale-readiness/semgrep.json \ + src/main/java src/test/java ``` Result: @@ -45,53 +54,77 @@ Result: - Findings: 0. - Errors: 0. -Evidence: - -- `semgrep.json` +Evidence: `semgrep.json`. -## SBOM +## SBOM Generation -Command: +### Canonical command ```bash -mvn -DskipTests org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -Dcyclonedx.skipAttach=true -Dcyclonedx.outputFormat=json -Dcyclonedx.outputName=clearfolio-viewer-sbom +mvn -B --no-transfer-progress -DskipTests \ + org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom \ + -Dcyclonedx.skipAttach=true \ + -DoutputFormat=json \ + -DoutputName=bom ``` -Result: +CycloneDX Maven Plugin 2.9.1 writes the canonical JSON output to `target/bom.json`. `outputFormat` and `outputName` are Maven user properties without a `cyclonedx.` prefix. The earlier evidence command incorrectly prefixed those two properties and is superseded by this contract. + +### Deterministic provenance + +Read-only workflow run `31004040777` generated the accepted dependency evidence at `2026-08-05T12:07:15Z`. + +| Field | Value | +| --- | --- | +| Source head | `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` | +| Generator | `org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom` | +| Artifact ID | `8929593015` | +| Artifact archive SHA-256 | `07a0325e08157f00dda28c58ed4e41af51863cccb2ceea2c4e378ead77dc337f` | +| SBOM SHA-256 | `e138a9263edb40c613d5f159acba8fa89ee848a7cef4b6619e095c48451b095c` | +| Attribution SHA-256 | `e19a3767a545bd059e50003882d8ff2f8a3ff4d3b8fd28d3f305eead61261da9` | +| CycloneDX specification | `1.6` | +| Total components | `61` | +| Netty components | `17` | +| Netty version set | exactly `4.1.136.Final` | +| Components without license metadata | `0` | + +```mermaid +flowchart LR + H[Exact source head] --> R[Maven dependency resolution] + R --> G[CycloneDX 2.9.1] + G --> B[target/bom.json] + B --> V[Component and edge verifier] + V --> A[Attribution renderer] + B --> C[Committed SBOM] + A --> D[Committed attribution] + C --> T[Permanent drift test] + D --> T +``` + +The verifier requires every Netty component version, purl, bom-ref, and dependency edge to resolve to `4.1.136.Final`. It rejects the historical `4.1.135.Final` line, an empty component list, unmatched dependency references, or attribution that cannot be reproduced from the committed JSON. + +### Current generated result - CycloneDX BOM format: 1.6. - Components: 61. - Components without license metadata: 0. - Unique license metadata entries: 3. -- Engineering license review is now documented in - `docs/security/2026-07-02-license-allowlist-review.md`. -- The unused `tika-parsers-standard-package` dependency was removed, which - eliminated Tika transitive review-required components `jhighlight`, `junrar`, - and `juniversalchardet` from the current SBOM. -- Spring Boot's default Logback starter was replaced with - `spring-boot-starter-log4j2`, and `jakarta.annotation-api` is excluded from - the current starter paths. -- The standard-library license policy checker passes buyer-release mode: - 61 allowed components, 0 review-required components, and 0 unlisted - violations with `--require-no-review`. -- The standard-library attribution renderer generates - `docs/legal/2026-07-03-third-party-attribution.md` from the same SBOM and - the drift check confirms that the data-room attribution file is current. -- The buyer data-room manifest checker confirms the sale-readiness package links - to required local evidence and current Figma/GitHub handoff URLs, and prevents - ready gates from citing partial or external artifacts as complete evidence. -- The buyer readiness scorecard generator reports 23 current data-room - artifacts, 8 readiness gates, 38 percent conservative gate readiness, and - ready-gate evidence integrity pass while keeping partial gates as discount - risks. -- The Figma Slides payload checker confirms the buyer diligence deck payload has - 11 slides, 4 objectives, explicit no-Code-Connect wording, readiness - scorecard content, discount-risk content, and claim-boundary wording. +- The unused `tika-parsers-standard-package` dependency remains absent, eliminating Tika transitive review-required components `jhighlight`, `junrar`, and `juniversalchardet` from the buyer-release graph. +- Spring Boot's default Logback starter is replaced with `spring-boot-starter-log4j2`, and `jakarta.annotation-api` remains excluded from the current starter paths. +- The standard-library attribution renderer generates `docs/legal/2026-07-03-third-party-attribution.md` from the same SBOM. +- The buyer-release license policy records 61 allowed components, 0 review-required components, and 0 unlisted violations. -Evidence: +Primary generated evidence: -- `sbom-cyclonedx.log` - `sbom-cyclonedx.json` +- `docs/legal/2026-07-03-third-party-attribution.md` +- `docs/security/2026-08-05-netty-4.1.136-remediation.md` +- `scripts/test_render_third_party_attribution.py` +- `src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java` + +Related historical and buyer-handoff evidence: + +- `sbom-cyclonedx.log` - `sbom-status.txt` - `license-policy.log` - `license-policy-summary.json` @@ -101,7 +134,6 @@ Evidence: - `buyer-readiness-scorecard-summary.json` - `figma-deck-payload-check.json` - `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json` -- `docs/legal/2026-07-03-third-party-attribution.md` - `docs/security/2026-07-02-license-allowlist-review.md` - `docs/security/2026-07-02-license-policy.json` - `docs/security/2026-07-02-auth-tenant-model.md` @@ -116,78 +148,36 @@ Evidence: - `docs/superpowers/plans/2026-07-02-conversion-job-lifecycle-events.md` - `docs/superpowers/plans/2026-07-03-conversion-recovery-sweep.md` - `buyer-deployment-slice-verification.md` -- FigJam diagrams: - [Clearfolio Gateway Signed Tenant Claims Flow](https://www.figma.com/board/114nJPcTcQzXvAEIS9T4gM) - and `Clearfolio KPI Snapshot Evidence Ledger Flow` plus - `Clearfolio KPI Snapshot Export Evidence API Flow` and - `Clearfolio Buyer Demo KPI Evidence Panel Flow` plus - `Clearfolio Operator Recovery Evidence Flow` and - `Clearfolio Conversion State Store Implementation Flow` plus - `Clearfolio Conversion Lifecycle Event Trail Flow` plus - `Clearfolio Buyer Readiness Scorecard Gate Map` plus - `Clearfolio Buyer Diligence Slides Storyboard` plus - `Clearfolio Ready Gate Evidence Integrity Check` plus - `Clearfolio Conversion Recovery Sweep Flow`. + +FigJam handoff includes the gateway signed-tenant flow, KPI snapshot ledger/export flows, buyer-demo KPI panel, operator recovery flow, conversion state-store and lifecycle-event flows, buyer readiness gate map, diligence slides storyboard, ready-gate evidence integrity check, and conversion recovery sweep flow. ## Local Smoke -Command path: +Original command path: -- Start app on a random local port with - `clearfolio.tenant-claims.hmac-secret` and - `clearfolio.artifact-link-ledger.path` plus - `clearfolio.analytics-snapshot-ledger.path` configured. -- Runtime Java: 21.0.11. -- Verify `GET /`, buyer-demo KPI evidence panel markup, - buyer-demo operator recovery evidence panel markup, `/assets/viewer/demo.js`, - demo JS KPI export endpoint reference, - missing-auth KPI denial, unsigned tenant-claim KPI denial, authenticated empty - KPI snapshot with signed tenant claims, authenticated empty KPI export lookup, - document upload with signed tenant headers, status polling to `SUCCEEDED`, - `/viewer/{docId}`, authenticated viewer bootstrap, signed artifact URL - creation, unsigned artifact denial, signed artifact range access, artifact - read audit lookup, artifact token revocation, revoked-token denial, - cross-tenant status denial, post-upload KPI snapshot, post-upload KPI export - lookup, and file-backed KPI snapshot ledger append evidence. +- Start the application on a random local port with `clearfolio.tenant-claims.hmac-secret`, `clearfolio.artifact-link-ledger.path`, and `clearfolio.analytics-snapshot-ledger.path` configured. +- Verify the root shell, buyer-demo KPI and recovery panels, demo assets, signed claims, upload and status polling, viewer/bootstrap, signed and ranged artifact access, read audit, revocation, cross-tenant concealment, KPI snapshots and exports, and file-backed ledger append evidence. -Result: +Original result: -- Root shell: 200. -- Root shell evidence panel: present. -- Root shell operator recovery panel: present. -- Demo JS: 200. -- Demo JS KPI export endpoint reference: present. -- Missing-auth KPI: 401. -- Unsigned tenant-claim KPI with secret configured: 401. -- Authenticated empty KPI: 200. -- Authenticated empty KPI exports: 200, 1 record, tenant id omitted. -- Final conversion status: `SUCCEEDED`. -- Status tenant: `buyer-demo`. -- Viewer HTML: 200. -- Viewer bootstrap: 200. -- Artifact link creation: 200. -- Unsigned artifact read: 401. -- Signed artifact range read: 206. -- Artifact read audit lookup: 200, 1 event, last status 206. -- Artifact token revocation: 200, `revoked=true`. -- Revoked artifact read: 403. +- Runtime Java: 21.0.11. +- Root shell: 200; evidence and recovery panels present. +- Missing or unsigned tenant claims: 401. +- Authenticated empty KPI and exports: 200. +- Final conversion status: `SUCCEEDED` for tenant `buyer-demo`. +- Viewer and bootstrap: 200. +- Signed artifact range read: 206; unsigned read: 401. +- Artifact read audit: 200; revocation succeeded; revoked read: 403. - Cross-tenant status lookup: 404. -- Post-upload KPI: `totalJobs=1`, `succeededJobs=1`, - `conversionSuccessRate=1.0`, numeric `p95TimeToPreviewMs`. -- Post-upload KPI exports: 200, 2 records, latest `totalJobs=1`, tenant id - omitted. -- Artifact ledger file: present, 2 `ISSUED` lines, 1 `REVOKED` line, - and 1 `READ` line. -- KPI snapshot ledger file: present, 2 `SNAPSHOT` lines. +- Post-upload KPI: one successful job and numeric preview latency. +- Artifact and KPI ledger append evidence present. Evidence: - `smoke-local.txt` - `smoke-ui-root.txt` +- `smoke-app.log` -## GitHub Checks +## GitHub Acceptance -This evidence refresh was produced locally before publishing the recovery-sweep -branch. The PR body should carry the local gate results from this file. Review -and queued GitHub checks are not treated as blockers for continuing the -sale-readiness work. +The historical snapshot is not a substitute for current pull-request evidence. A release or merge requires the exact current head to pass repository CI, Maven `verify`, zero missed production lines and branches, warning-free public Javadocs, Security Scan, SAST, every fuzz target, dependency/security review, current automated review, zero unresolved threads, and a counted independent approval. Queued, pending, cancelled, skipped-required, stale-head, or local-only results are not passing. From 06452c3f39f2deb38d31d189e46de4b25512baa0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:05:38 +0900 Subject: [PATCH 34/85] docs(changelog): record deterministic Netty buyer evidence --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78d7e5a7..4c5ca2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - CI가 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하며, 합성 merge revision은 별도 호환성 작업에서 검증하도록 분리했습니다. - Maven `verify` 단계에서 JaCoCo production line 및 branch missed count가 각각 0인지 강제하고, 실패 시 누락 위치 진단을 출력하도록 했습니다. - Jazzer fuzzing도 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하도록 강화했습니다. +- CycloneDX Maven Plugin 2.9.1의 정확한 `outputFormat`/`outputName` 사용자 속성으로 생성한 61개 구성요소 SBOM과 제3자 고지문을 buyer evidence에 반영했습니다. 생성 source head, UTC 시각, artifact/archive/SBOM/attribution 해시, 17개 Netty 구성요소의 purl·bom-ref·dependency-edge 정합성, 로컬 생성 증거와 공유 가능한 데이터룸 증거의 경계를 ADR 및 실행 가능한 drift test로 고정했습니다. ### Security From 1907593d4c4e79c9dc6506fe4a5f8c9bf01bf3bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:26:29 +0900 Subject: [PATCH 35/85] test(security): require auditable policy overrides --- .../security/AuditKeySeparationGuardTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java index 5d44c4d5..9b896b75 100644 --- a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -25,6 +25,19 @@ void rejectsIdenticalConfiguredKeysDuringStartup() { ); } + @Test + void rejectsEnabledPolicySigningWithoutAnAuditPseudonymKey() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret(POLICY_KEY); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + + assertTrue(exception.getMessage().contains("audit pseudonym key is required")); + } + @Test void rejectsConfiguredPolicyKeyShorterThanThirtyTwoUtf8Bytes() { ConversionProperties properties = new ConversionProperties(); @@ -51,6 +64,7 @@ void acceptsDistinctConfiguredKeys() { void acceptsConfiguredPolicyKeyMeasuredAsThirtyTwoOrMoreUtf8Bytes() { ConversionProperties properties = new ConversionProperties(); properties.setPolicyOverrideSecret("가나다라마바사아자차카"); + properties.setAuditPseudonymSecret(AUDIT_KEY); assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); } @@ -62,6 +76,14 @@ void permitsMissingPolicyKeyWhenPolicySigningIsDisabled() { assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); } + @Test + void permitsAnAuditKeyWhenPolicySigningIsDisabled() { + ConversionProperties properties = new ConversionProperties(); + properties.setAuditPseudonymSecret(AUDIT_KEY); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + @Test void permitsDisabledSecurityPurposesWithoutComparingMissingValues() { assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(null, "audit-key")); From 0189136007262642daa4674766299e73832beb7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:28:25 +0900 Subject: [PATCH 36/85] fix(security): fail closed without override audit key --- .../security/AuditKeySeparationGuard.java | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java index 3b45d45f..cd38cedd 100644 --- a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -8,14 +8,16 @@ import com.clearfolio.viewer.config.ConversionProperties; /** - * Fails application startup when configured policy-signing key material is weak - * or when policy signing and audit pseudonymization reuse the same key. + * Fails application startup when policy-override key material cannot support a + * private and attributable administrative audit trail. * *

The policy-override key protects an administrative authorization decision, - * so a configured value must contain at least 32 UTF-8 bytes. The policy and - * audit HMAC purposes also form separate security domains. Reusing one value - * would allow a holder of the audit key to create policy-override signatures, - * so nonblank configured values must remain distinct.

+ * so a configured value must contain at least 32 UTF-8 bytes. Enabling that + * signing key also requires a dedicated audit pseudonym key: accepting an + * override while emitting only an unavailable marker would prevent operators + * from distinguishing approvers during an investigation. The policy and audit + * HMAC purposes remain separate security domains, and their configured values + * must therefore be distinct.

*/ @Component public final class AuditKeySeparationGuard { @@ -28,11 +30,11 @@ public final class AuditKeySeparationGuard { * @param properties bound conversion configuration */ public AuditKeySeparationGuard(ConversionProperties properties) { - requireStrongPolicySecret(properties.getPolicyOverrideSecret()); - requireDistinct( - properties.getPolicyOverrideSecret(), - properties.getAuditPseudonymSecret() - ); + String policySecret = properties.getPolicyOverrideSecret(); + String auditSecret = properties.getAuditPseudonymSecret(); + requireStrongPolicySecret(policySecret); + requireAuditKeyWhenPolicySigningIsEnabled(policySecret, auditSecret); + requireDistinct(policySecret, auditSecret); } static void requireStrongPolicySecret(String policySecret) { @@ -47,6 +49,17 @@ static void requireStrongPolicySecret(String policySecret) { } } + static void requireAuditKeyWhenPolicySigningIsEnabled( + String policySecret, + String auditSecret + ) { + if (isConfigured(policySecret) && !isConfigured(auditSecret)) { + throw new IllegalStateException( + "audit pseudonym key is required when policy override signing is enabled" + ); + } + } + static void requireDistinct(String policySecret, String auditSecret) { if (!isConfigured(policySecret) || !isConfigured(auditSecret)) { return; From c57af8be953445bdfd89ddf24aa331046315a7ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:29:41 +0900 Subject: [PATCH 37/85] docs(security): require auditable override startup --- .../security/2026-08-04-audit-pseudonymization.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md index 6f39f223..98d44059 100644 --- a/docs/security/2026-08-04-audit-pseudonymization.md +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -10,7 +10,7 @@ The approver field is named `approverFingerprint`, not `approverId`, so downstre ### 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. +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. Configuring a valid policy-override key without a dedicated audit pseudonym key also fails startup, because accepting an administrative exception without approver-correlatable audit evidence would make the security decision operationally unauditable. The startup gates measure encoded bytes rather than Java character count and never log supplied key material. 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). @@ -31,9 +31,9 @@ The first 128 bits are encoded as lowercase hexadecimal and prefixed by the non- :<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. +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:` only while policy-override signing is disabled and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. Once a policy-signing key is configured, a missing or blank audit key prevents application startup. -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). +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 only for deployments where policy override remains disabled; a nonblank weak key, or an absent key paired with an enabled policy-signing 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. @@ -59,14 +59,14 @@ 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. +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 because policy override remains disabled. If a deployment supplies `conversion.policy-override-secret`, it must supply a distinct strong `conversion.audit-pseudonym-secret` in the same rollout; otherwise startup fails before traffic is accepted. ## 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. +- `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. It is mandatory whenever `conversion.policy-override-secret` is configured. - 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. +- The application startup guard rejects an enabled policy-signing key without a configured audit key and 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. @@ -93,10 +93,11 @@ 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; +- startup rejection when policy-override signing is enabled without a configured audit pseudonym key; - 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; +- distinct absent, empty, and unavailable approver behavior while policy signing is disabled; - 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; From 50735738df84603fdf027b3a01f4545113b5542f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:30:43 +0900 Subject: [PATCH 38/85] docs(changelog): record auditable override gate --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5ca2e8..4b3ce9e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,8 @@ ### Security - Spring Boot 3.5.16이 관리하던 Netty `4.1.135.Final` 전이 의존성 전체를 Spring Boot의 공식 `netty.version` 속성을 통해 `4.1.136.Final`로 정렬했습니다. 실제 POM을 읽는 회귀 테스트와 보안 ADR을 추가해 개별 Netty 모듈의 혼합 버전 및 향후 무의식적 downgrade를 차단했습니다. -- 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. +- 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 정책 재정의 서명이 비활성화된 경우에만 전용 키 부재를 비상관 `unavailable` 표식으로 표현하며, 원문이나 비키 해시로 폴백하지 않습니다. +- 정책 재정의 서명 키를 활성화하면서 전용 감사 가명화 키를 누락하면 애플리케이션 시작을 거부하도록 강화했습니다. 관리자 예외를 승인하면서 승인자별 상관 가능한 감사 증거를 남기지 못하는 구성을 fail closed로 차단하고, 두 키의 최소 강도와 용도 분리를 유지합니다. - 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. - 경로·쿼리 파라미터 타입 변환 실패 응답에서 사용자가 제출한 거부 값을 고정된 `[redacted]` 표식으로 대체해 오류 응답을 통한 개인정보·비밀값 반사를 차단했습니다. 값이 실제로 없었던 경우에만 `null` 진단을 유지합니다. From ff1046268255c0417a04c643da902fb09de20378 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:35:00 +0900 Subject: [PATCH 39/85] test(security): require standalone override auditability --- ...entValidationServiceConfigurationTest.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java new file mode 100644 index 00000000..91ffc42a --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java @@ -0,0 +1,24 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.config.ConversionProperties; + +class DefaultDocumentValidationServiceConfigurationTest { + + @Test + void rejectsEnabledPolicyOverrideWithoutDedicatedAuditKey() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("0123456789abcdef0123456789abcdef"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new DefaultDocumentValidationService(properties) + ); + + assertTrue(exception.getMessage().contains("audit pseudonym key is required")); + } +} From f08d7896c04f1fe61c7726eba50ece8b1cbefc0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:36:53 +0900 Subject: [PATCH 40/85] refactor(security): expose reusable override key validation --- .../security/AuditKeySeparationGuard.java | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java index cd38cedd..222261ba 100644 --- a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -2,14 +2,15 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.Objects; import org.springframework.stereotype.Component; import com.clearfolio.viewer.config.ConversionProperties; /** - * Fails application startup when policy-override key material cannot support a - * private and attributable administrative audit trail. + * Validates policy-override key material before an override-capable component + * can accept traffic. * *

The policy-override key protects an administrative authorization decision, * so a configured value must contain at least 32 UTF-8 bytes. Enabling that @@ -18,6 +19,11 @@ * from distinguishing approvers during an investigation. The policy and audit * HMAC purposes remain separate security domains, and their configured values * must therefore be distinct.

+ * + *

Spring creates this component during application startup. Modular or + * standalone callers that construct an override-capable service directly use + * {@link #validate(ConversionProperties)} so they receive the same fail-closed + * contract without depending on the Spring container.

*/ @Component public final class AuditKeySeparationGuard { @@ -28,10 +34,36 @@ public final class AuditKeySeparationGuard { * Validates the bound conversion security configuration during bean startup. * * @param properties bound conversion configuration + * @throws NullPointerException if {@code properties} is {@code null} + * @throws IllegalStateException if configured key material is weak, + * incomplete, or reused across security purposes */ public AuditKeySeparationGuard(ConversionProperties properties) { - String policySecret = properties.getPolicyOverrideSecret(); - String auditSecret = properties.getAuditPseudonymSecret(); + validate(properties); + } + + /** + * Applies the complete policy-override key contract for Spring-managed, + * standalone, and modular service construction. + * + *

When policy override is disabled, both keys may be absent. When policy + * signing is enabled, its key must contain at least 32 UTF-8 bytes, a + * dedicated audit pseudonym key must be present, and the two values must be + * different. The audit key performs its own strength validation when the + * pseudonymizer is constructed.

+ * + * @param properties conversion security configuration to validate + * @throws NullPointerException if {@code properties} is {@code null} + * @throws IllegalStateException if configured key material is weak, + * incomplete, or reused across security purposes + */ + public static void validate(ConversionProperties properties) { + ConversionProperties requiredProperties = Objects.requireNonNull( + properties, + "properties" + ); + String policySecret = requiredProperties.getPolicyOverrideSecret(); + String auditSecret = requiredProperties.getAuditPseudonymSecret(); requireStrongPolicySecret(policySecret); requireAuditKeyWhenPolicySigningIsEnabled(policySecret, auditSecret); requireDistinct(policySecret, auditSecret); From 9c33db4987160970e7b2825cfaa2a583a9af4b82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:38:03 +0900 Subject: [PATCH 41/85] fix(security): enforce override auditability in standalone service --- .../service/DefaultDocumentValidationService.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java index 712a74bd..f00b8b8d 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java @@ -18,10 +18,15 @@ import com.clearfolio.viewer.config.ConversionProperties; import com.clearfolio.viewer.exception.UnsupportedDocumentFormatException; +import com.clearfolio.viewer.security.AuditKeySeparationGuard; import com.clearfolio.viewer.security.AuditPseudonymizer; /** * Default document validator that enforces extension and size constraints. + * + *

Construction also validates the complete policy-override key contract, so + * direct standalone or modular use cannot bypass the same fail-closed security + * checks that Spring applies during application startup.

*/ @Service public class DefaultDocumentValidationService implements DocumentValidationService { @@ -38,9 +43,19 @@ public class DefaultDocumentValidationService implements DocumentValidationServi /** * Creates the validation service from conversion configuration values. * + *

When policy override is enabled, construction rejects a weak signing + * key, a missing dedicated audit pseudonym key, or reuse of one key for both + * security purposes. This invariant applies even when callers construct the + * service outside the Spring container.

+ * * @param conversionProperties conversion configuration values + * @throws NullPointerException if {@code conversionProperties} is + * {@code null} + * @throws IllegalStateException if policy-override key material is weak, + * incomplete, or reused across security purposes */ public DefaultDocumentValidationService(ConversionProperties conversionProperties) { + AuditKeySeparationGuard.validate(conversionProperties); this.blockedExtensions = conversionProperties.getBlockedExtensions(); this.maxUploadSizeBytes = conversionProperties.getMaxUploadSizeBytes(); this.policyOverrideSecret = conversionProperties.getPolicyOverrideSecret(); From 10bfb26206ce3ca2a01885e8ada0f0978d08bed0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:40:36 +0900 Subject: [PATCH 42/85] test(fuzz): use separated override audit keys --- .../viewer/fuzz/DocumentValidationFuzzTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java b/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java index 7598632a..79be372d 100644 --- a/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java +++ b/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java @@ -21,11 +21,17 @@ */ final class DocumentValidationFuzzTest { + private static final String POLICY_OVERRIDE_KEY = + "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_PSEUDONYM_KEY = + "fedcba9876543210fedcba9876543210"; + private final DefaultDocumentValidationService validator = createValidator(); private static DefaultDocumentValidationService createValidator() { ConversionProperties properties = new ConversionProperties(); - properties.setPolicyOverrideSecret("fuzz-test-secret"); + properties.setPolicyOverrideSecret(POLICY_OVERRIDE_KEY); + properties.setAuditPseudonymSecret(AUDIT_PSEUDONYM_KEY); return new DefaultDocumentValidationService(properties); } From 162166a600848c5f8032a9ca0307ccca5c0db96e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:42:50 +0900 Subject: [PATCH 43/85] test(web): use separated override audit keys --- ...onversionControllerMultipartLimitTest.java | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java index ff776274..fed3bf8b 100644 --- a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java @@ -3,31 +3,32 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.http.MediaType; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; -import java.nio.charset.StandardCharsets; -import java.util.HexFormat; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.BodyInserters; +import com.clearfolio.viewer.artifact.ArtifactLinkService; +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; import com.clearfolio.viewer.auth.TenantAccessService; import com.clearfolio.viewer.auth.TenantContext; import com.clearfolio.viewer.auth.TenantPermissions; -import com.clearfolio.viewer.artifact.ArtifactLinkService; -import com.clearfolio.viewer.artifact.InMemoryArtifactStore; import com.clearfolio.viewer.config.ConversionProperties; import com.clearfolio.viewer.repository.ConversionJobRepository; import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; @@ -46,11 +47,15 @@ properties = { "conversion.max-upload-size-bytes=1024", "spring.codec.max-in-memory-size=2048", - "conversion.policy-override-secret=test-secret" + "conversion.policy-override-secret=0123456789abcdef0123456789abcdef", + "conversion.audit-pseudonym-secret=fedcba9876543210fedcba9876543210" } ) class ConversionControllerMultipartLimitTest { + private static final String POLICY_OVERRIDE_KEY = + "0123456789abcdef0123456789abcdef"; + @SpringBootConfiguration @EnableAutoConfiguration @EnableConfigurationProperties(ConversionProperties.class) @@ -98,7 +103,7 @@ DocumentConversionService documentConversionService( repository, validationService, conversionWorker, - new com.clearfolio.viewer.artifact.InMemoryArtifactStore(), + new InMemoryArtifactStore(), conversionProperties ); } @@ -157,7 +162,7 @@ private String generateSignature(String approverId, String extension, String sec @Test void submitAcceptsBlockedExtensionWhenPolicyOverrideHeadersAreValid() { - String validSignature = generateSignature("approver-99", "hwp", "test-secret"); + String validSignature = generateSignature("approver-99", "hwp", POLICY_OVERRIDE_KEY); submit("contract.hwp", "hello".getBytes(), "true", validSignature, "approver-99") .expectStatus().isAccepted() .expectBody() From 967fe258850552c7eb973b77c8ecc329c5420034 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:45:56 +0900 Subject: [PATCH 44/85] test(service): use separated override audit keys --- .../DefaultDocumentValidationServiceTest.java | 219 ++++++++++++++---- 1 file changed, 177 insertions(+), 42 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java index 4f27bdcc..77cd74bd 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java @@ -2,8 +2,8 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -26,28 +26,32 @@ class DefaultDocumentValidationServiceTest { + private static final String POLICY_OVERRIDE_KEY = + "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_PSEUDONYM_KEY = + "fedcba9876543210fedcba9876543210"; + private static final Object SECURITY_PROVIDERS_LOCK = new Object(); + @Test void sanitizeFilenameReturnsNullWhenFilenameIsNull() throws Exception { ConversionProperties conversionProperties = new ConversionProperties(); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); + Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); method.setAccessible(true); String sanitized = (String) method.invoke(validationService, new Object[] {null}); assertNull(sanitized); } - @Test void sanitizeFilenameReturnsCleanPathWhenNoSlashIsPresent() throws Exception { ConversionProperties conversionProperties = new ConversionProperties(); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); + Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); method.setAccessible(true); String sanitized = (String) method.invoke(validationService, "simple-file.txt"); assertEquals("simple-file.txt", sanitized); } - @Test void stripsDirectoryTraversalFromFilename() { ConversionProperties conversionProperties = new ConversionProperties(); @@ -57,16 +61,18 @@ void stripsDirectoryTraversalFromFilename() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "../../../etc/passwd.hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "../../../etc/passwd.hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); assertEquals("hwp", ex.getExtension()); } - - private static final Object SECURITY_PROVIDERS_LOCK = new Object(); - @Test void rejectsHwpAndHwpxByDefault() { ConversionProperties conversionProperties = new ConversionProperties(); @@ -76,7 +82,12 @@ void rejectsHwpAndHwpxByDefault() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -99,12 +110,17 @@ private String generateSignature(String approverId, String extension, String sec void allowsBlockedExtensionWhenOverrideHeadersAreValid() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - String validSignature = generateSignature("approver-1", "hwp", "test-secret"); + String validSignature = generateSignature("approver-1", "hwp", POLICY_OVERRIDE_KEY); assertDoesNotThrow(() -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", validSignature, "approver-1") )); } @@ -113,13 +129,18 @@ void allowsBlockedExtensionWhenOverrideHeadersAreValid() { void rejectsBlockedExtensionWhenOverrideSignatureIsInvalid() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", "invalid-token", "approver-1") ) ); @@ -131,7 +152,7 @@ void rejectsBlockedExtensionWhenOverrideSignatureIsInvalid() { void rejectsBlockedExtensionWhenSignatureIsWellFormedButDoesNotMatch() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); // Valid hex of the correct length, but computed with the wrong secret, so the @@ -141,7 +162,12 @@ void rejectsBlockedExtensionWhenSignatureIsWellFormedButDoesNotMatch() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", wrongSignature, "approver-1") ) ); @@ -159,7 +185,12 @@ void rejectsBlockedExtensionWhenSecretIsNotConfigured() { IllegalStateException ex = assertThrows( IllegalStateException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", "any-token", "approver-1") ) ); @@ -176,7 +207,12 @@ void rejectsBlockedExtensionWhenOverrideFlagIsInvalid() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("not-boolean", "token-123", "approver-1") ) ); @@ -193,12 +229,20 @@ void rejectsBlockedExtensionWhenOverrideTokenIsMissing() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", " ", "approver-1") ) ); - assertEquals("X-Clearfolio-Approval-Token is required when policy override is true.", ex.getMessage()); + assertEquals( + "X-Clearfolio-Approval-Token is required when policy override is true.", + ex.getMessage() + ); } @Test @@ -210,12 +254,20 @@ void rejectsBlockedExtensionWhenOverrideTokenIsNull() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", null, "approver-1") ) ); - assertEquals("X-Clearfolio-Approval-Token is required when policy override is true.", ex.getMessage()); + assertEquals( + "X-Clearfolio-Approval-Token is required when policy override is true.", + ex.getMessage() + ); } @Test @@ -227,12 +279,20 @@ void rejectsBlockedExtensionWhenOverrideApproverIsMissing() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", "token-123", " ") ) ); - assertEquals("X-Clearfolio-Approver-Id is required when policy override is true.", ex.getMessage()); + assertEquals( + "X-Clearfolio-Approver-Id is required when policy override is true.", + ex.getMessage() + ); } @Test @@ -244,7 +304,12 @@ void rejectsBlockedExtensionWhenOverrideFlagIsFalse() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("false", "token-123", "approver-1") ) ); @@ -261,7 +326,12 @@ void rejectsBlockedExtensionWhenOverrideFlagIsBlank() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of(" ", "token-123", "approver-1") ) ); @@ -276,7 +346,12 @@ void ignoresInvalidOverrideFlagForSupportedExtension() { DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); assertDoesNotThrow(() -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.docx", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("invalid", null, null) )); } @@ -288,7 +363,12 @@ void allowsSupportedExtensions() { DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); assertDoesNotThrow(() -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[] {1}) + new MockMultipartFile( + "file", + "contract.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + new byte[] {1} + ) )); } @@ -301,7 +381,12 @@ void rejectsMissingExtension() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -352,7 +437,12 @@ void rejectsNullFilename() { assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", (String) null, "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + (String) null, + "application/octet-stream", + new byte[] {1} + ) ) ); } @@ -367,7 +457,12 @@ void rejectsOversizedPayload() { assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[] {1, 2, 3}) + new MockMultipartFile( + "file", + "contract.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + new byte[] {1, 2, 3} + ) ) ); } @@ -395,7 +490,12 @@ void rejectsEmptyFile() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/octet-stream", new byte[0]) + new MockMultipartFile( + "file", + "contract.docx", + "application/octet-stream", + new byte[0] + ) ) ); @@ -430,7 +530,12 @@ void rejectsFilenameEndingWithDot() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract.", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -446,7 +551,12 @@ void rejectsLeadingDotFilenameAsMissingExtension() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", ".hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + ".hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -462,7 +572,12 @@ void trimsFilenameBeforeBlockedExtensionCheck() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", " contract.hwp ", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + " contract.hwp ", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -478,7 +593,12 @@ void rejectsNullByteInFilename() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract\u0000.hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract\u0000.hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); assertEquals("File name contains null byte.", ex.getMessage()); @@ -493,7 +613,12 @@ void handlesNullOverrideRequestByFallingBackToDefaultPolicy() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), null ) ); @@ -529,11 +654,11 @@ void sanitizeForLogReplacesTabCharacter() throws Exception { void throwsWhenSha256DigestIsUnavailableForOverrideAuditFingerprint() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - // Generate the signature BEFORE removing security providers - String validSignature = generateSignature("approver-1", "hwp", "test-secret"); + // Generate the signature BEFORE removing security providers. + String validSignature = generateSignature("approver-1", "hwp", POLICY_OVERRIDE_KEY); synchronized (SECURITY_PROVIDERS_LOCK) { Provider[] providers = Security.getProviders(); @@ -545,7 +670,12 @@ void throwsWhenSha256DigestIsUnavailableForOverrideAuditFingerprint() { IllegalStateException ex = assertThrows( IllegalStateException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", validSignature, "approver-1") ) ); @@ -558,4 +688,9 @@ void throwsWhenSha256DigestIsUnavailableForOverrideAuditFingerprint() { } } } + + private static void configureOverrideKeys(ConversionProperties properties) { + properties.setPolicyOverrideSecret(POLICY_OVERRIDE_KEY); + properties.setAuditPseudonymSecret(AUDIT_PSEUDONYM_KEY); + } } From 92ce1c6deb122cfca339c8fd5861d591fd264794 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:46:53 +0900 Subject: [PATCH 45/85] test(audit): reject unauditable policy signing --- ...ultDocumentValidationServiceAuditTest.java | 49 ++++++++----------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java index 7cafcd86..6ed06539 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; @@ -26,16 +27,21 @@ class DefaultDocumentValidationServiceAuditTest { - private static final String AUDIT_PSEUDONYM_SECRET = + private static final String POLICY_OVERRIDE_SECRET = "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_PSEUDONYM_SECRET = + "fedcba9876543210fedcba9876543210"; @Test void acceptedOverrideLogsOnlyPrivacySafeFingerprints() { String approverId = "employee-007@example.com"; - String policySecret = "policy-signing-secret"; - String approvalToken = generateSignature(approverId, "hwp", policySecret); + String approvalToken = generateSignature( + approverId, + "hwp", + POLICY_OVERRIDE_SECRET + ); ConversionProperties properties = configuredProperties( - policySecret, + POLICY_OVERRIDE_SECRET, AUDIT_PSEUDONYM_SECRET, "rotation-7" ); @@ -65,32 +71,19 @@ void acceptedOverrideLogsOnlyPrivacySafeFingerprints() { } @Test - void missingDedicatedAuditKeyUsesNonCorrelatableSentinel() { - String approverId = "employee-008"; - String policySecret = "policy-signing-secret"; - String approvalToken = generateSignature(approverId, "hwp", policySecret); - ConversionProperties properties = configuredProperties(policySecret, "", "v9"); - DefaultDocumentValidationService service = new DefaultDocumentValidationService(properties); - CapturingAppender appender = attachAppender(); + void enabledPolicySigningRejectsMissingDedicatedAuditKeyBeforeLogging() { + ConversionProperties properties = configuredProperties( + POLICY_OVERRIDE_SECRET, + "", + "v9" + ); - try { - service.validateOrThrow( - new MockMultipartFile( - "file", - "contract.hwp", - "application/octet-stream", - new byte[] {1} - ), - PolicyOverrideRequest.of("true", approvalToken, approverId) - ); - } finally { - appender.closeAndDetach(); - } + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new DefaultDocumentValidationService(properties) + ); - String auditLine = appender.singleMessage(); - assertTrue(auditLine.contains("approverFingerprint=unavailable:v9")); - assertFalse(auditLine.contains(approverId)); - assertFalse(auditLine.contains(approvalToken)); + assertTrue(exception.getMessage().contains("audit pseudonym key is required")); } @Test From 422c4658b21705900055b21b5eddbf14959e2c8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:48:57 +0900 Subject: [PATCH 46/85] docs(security): cover standalone override validation --- .../2026-08-04-audit-pseudonymization.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md index 98d44059..497679fa 100644 --- a/docs/security/2026-08-04-audit-pseudonymization.md +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -10,7 +10,7 @@ The approver field is named `approverFingerprint`, not `approverId`, so downstre ### 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. Configuring a valid policy-override key without a dedicated audit pseudonym key also fails startup, because accepting an administrative exception without approver-correlatable audit evidence would make the security decision operationally unauditable. The startup gates measure encoded bytes rather than Java character count and never log supplied key material. +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 and direct construction of the public validation service before any conversion endpoint or standalone module can accept traffic. Configuring a valid policy-override key without a dedicated audit pseudonym key fails through the same shared validation contract, because accepting an administrative exception without approver-correlatable audit evidence would make the security decision operationally unauditable. The gates measure encoded bytes rather than Java character count and never log supplied key material. 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). @@ -31,9 +31,9 @@ The first 128 bits are encoded as lowercase hexadecimal and prefixed by the non- :<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:` only while policy-override signing is disabled and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. Once a policy-signing key is configured, a missing or blank audit key prevents application startup. +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:` only while policy-override signing is disabled and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. Once a policy-signing key is configured, a missing or blank audit key prevents both application startup and direct construction of an override-capable validation service. -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 only for deployments where policy override remains disabled; a nonblank weak key, or an absent key paired with an enabled policy-signing 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). +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 only for deployments where policy override remains disabled; a nonblank weak key, or an absent key paired with an enabled policy-signing key, fails the shared configuration validation before traffic is accepted. 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. @@ -59,14 +59,14 @@ 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 because policy override remains disabled. If a deployment supplies `conversion.policy-override-secret`, it must supply a distinct strong `conversion.audit-pseudonym-secret` in the same rollout; otherwise startup fails before traffic is accepted. +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 because policy override remains disabled. If a deployment supplies `conversion.policy-override-secret`, it must supply a distinct strong `conversion.audit-pseudonym-secret` in the same rollout; otherwise Spring startup fails before traffic is accepted. Standalone and MSA consumers that instantiate `DefaultDocumentValidationService` directly receive the identical fail-closed validation and therefore cannot bypass the key-strength, mandatory-audit-key, or key-separation rules by omitting the Spring container. ## 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. It is mandatory whenever `conversion.policy-override-secret` is configured. - 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 an enabled policy-signing key without a configured audit key and 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. +- The shared configuration guard used by Spring startup and direct validation-service construction rejects an enabled policy-signing key without a configured audit key and 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 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. @@ -92,11 +92,11 @@ 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; -- startup rejection when policy-override signing is enabled without a configured audit pseudonym key; +- Spring-startup and direct-construction rejection of configured policy-override and audit keys shorter than 32 UTF-8 bytes; +- Spring-startup and direct-construction rejection when policy-override signing is enabled without a configured audit pseudonym key; - 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; +- Spring-startup and direct-construction rejection when policy and audit purposes reuse the same nonblank key; - distinct absent, empty, and unavailable approver behavior while policy signing is disabled; - rejection of null, empty, or blank approval tokens before token fingerprinting; - safe handling of Unicode and control characters; From cacd637da573486ed010f9c27b7675807d8e6fc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:50:00 +0900 Subject: [PATCH 47/85] docs(changelog): record standalone override guard --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b3ce9e2..afec1529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ - Spring Boot 3.5.16이 관리하던 Netty `4.1.135.Final` 전이 의존성 전체를 Spring Boot의 공식 `netty.version` 속성을 통해 `4.1.136.Final`로 정렬했습니다. 실제 POM을 읽는 회귀 테스트와 보안 ADR을 추가해 개별 Netty 모듈의 혼합 버전 및 향후 무의식적 downgrade를 차단했습니다. - 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 정책 재정의 서명이 비활성화된 경우에만 전용 키 부재를 비상관 `unavailable` 표식으로 표현하며, 원문이나 비키 해시로 폴백하지 않습니다. -- 정책 재정의 서명 키를 활성화하면서 전용 감사 가명화 키를 누락하면 애플리케이션 시작을 거부하도록 강화했습니다. 관리자 예외를 승인하면서 승인자별 상관 가능한 감사 증거를 남기지 못하는 구성을 fail closed로 차단하고, 두 키의 최소 강도와 용도 분리를 유지합니다. +- 정책 재정의 서명 키를 활성화하면서 전용 감사 가명화 키를 누락하면 Spring 시작과 `DefaultDocumentValidationService`의 독립·모듈식 직접 생성을 모두 거부하도록 강화했습니다. 관리자 예외를 승인하면서 승인자별 상관 가능한 감사 증거를 남기지 못하는 구성을 모든 실행 모드에서 fail closed로 차단하고, 두 키의 최소 강도와 용도 분리를 유지합니다. - 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. - 경로·쿼리 파라미터 타입 변환 실패 응답에서 사용자가 제출한 거부 값을 고정된 `[redacted]` 표식으로 대체해 오류 응답을 통한 개인정보·비밀값 반사를 차단했습니다. 값이 실제로 없었던 경우에만 `null` 진단을 유지합니다. From 08b14b74b08b3d40c4cc153e18087024953109ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:54:48 +0900 Subject: [PATCH 48/85] test(build): require warning-free public Javadocs --- .../viewer/config/DependencyPolicyTest.java | 112 +++++++++++++++++- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java b/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java index cfed8e71..2c3f0f2b 100644 --- a/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java +++ b/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java @@ -53,6 +53,56 @@ void pomPinsPatchedNettyLineForReactiveHttpServing() throws Exception { ); } + @Test + void mavenVerifyGeneratesWarningFreePublicApiJavadocs() throws Exception { + Document document = parsedPom(); + Element properties = (Element) document.getElementsByTagName("properties").item(0); + + assertEquals( + "3.12.0", + directChildTextOf(properties, "maven.javadoc.version"), + "the warning-free public Javadoc gate must use the reviewed Maven Javadoc Plugin release" + ); + + Element plugin = buildPlugin( + document, + "org.apache.maven.plugins", + "maven-javadoc-plugin" + ); + assertTrue( + plugin != null, + "mvn verify must include the Maven Javadoc Plugin instead of relying on undocumented local checks" + ); + assertEquals( + "${maven.javadoc.version}", + directChildTextOf(plugin, "version"), + "the Javadoc plugin version must be controlled by the authoritative version property" + ); + + Element configuration = directChildElementOf(plugin, "configuration"); + assertTrue(configuration != null, "the Javadoc plugin must declare fail-closed configuration"); + assertEquals("all", directChildTextOf(configuration, "doclint")); + assertEquals("true", directChildTextOf(configuration, "failOnError")); + assertEquals("true", directChildTextOf(configuration, "failOnWarnings")); + assertEquals("public", directChildTextOf(configuration, "show")); + + Element execution = executionById(plugin, "validate-public-api-documentation"); + assertTrue( + execution != null, + "the public Javadoc acceptance gate must have a stable execution identifier" + ); + assertEquals( + "verify", + directChildTextOf(execution, "phase"), + "public Javadoc validation must run in the authoritative Maven verify lifecycle" + ); + assertTrue( + directChildTextsOf(directChildElementOf(execution, "goals"), "goal") + .contains("javadoc"), + "the verify-bound execution must invoke the Javadoc goal" + ); + } + private static void assertSpringStarterExcludes( Map dependencies, String coordinate @@ -108,13 +158,65 @@ private static Set exclusionsOf(Element dependency) { return exclusions; } - private static String directChildTextOf(Element dependency, String tagName) { - for (Node node = dependency.getFirstChild(); node != null; node = node.getNextSibling()) { + private static Element buildPlugin( + Document document, + String groupId, + String artifactId + ) { + var pluginNodes = document.getElementsByTagName("plugin"); + for (int index = 0; index < pluginNodes.getLength(); index++) { + Element plugin = (Element) pluginNodes.item(index); + if (groupId.equals(directChildTextOf(plugin, "groupId")) + && artifactId.equals(directChildTextOf(plugin, "artifactId"))) { + return plugin; + } + } + return null; + } + + private static Element executionById(Element plugin, String executionId) { + Element executions = directChildElementOf(plugin, "executions"); + if (executions == null) { + return null; + } + for (Node node = executions.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element execution + && "execution".equals(execution.getTagName()) + && executionId.equals(directChildTextOf(execution, "id"))) { + return execution; + } + } + return null; + } + + private static Set directChildTextsOf(Element parent, String tagName) { + Set values = new HashSet<>(); + if (parent == null) { + return values; + } + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element element && tagName.equals(element.getTagName())) { + values.add(element.getTextContent().strip()); + } + } + return values; + } + + private static Element directChildElementOf(Element parent, String tagName) { + if (parent == null) { + return null; + } + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { if (node instanceof Element element && tagName.equals(element.getTagName())) { - return element.getTextContent().strip(); + return element; } } - return ""; + return null; + } + + private static String directChildTextOf(Element dependency, String tagName) { + Element child = directChildElementOf(dependency, tagName); + return child == null ? "" : child.getTextContent().strip(); } private record DependencyDeclaration(String groupId, String artifactId, Set exclusions) { @@ -126,4 +228,4 @@ private boolean excludes(String coordinate) { return exclusions.contains(coordinate); } } -} \ No newline at end of file +} From f86581fec9ce93f79effbaca329e40e42ac8cda4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 22:57:30 +0900 Subject: [PATCH 49/85] build(docs): gate warning-free public Javadocs --- pom.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pom.xml b/pom.xml index 1c3c6c27..362f4055 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ UTF-8 0.8.15 + 3.12.0 3.0.8 6.1.200