diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9d..67a7cd9c 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -32,3 +32,8 @@ **Vulnerability:** The document hashing routine in `DefaultDocumentConversionService` processed file streams without enforcing any maximum size limit on the bytes read. An attacker could exploit this by uploading a maliciously large stream (or exploiting a compression bomb if unzipping), exhausting system memory, CPU, or disk space (DoS). **Learning:** Checking the declared file size (e.g., `file.getSize()`) in initial validation is not always sufficient if the input stream itself can be spoofed or dynamically expanded during reading. The actual bytes read must be verified against bounds continuously. **Prevention:** Always enforce a strict, configurable size limit (e.g., `ConversionProperties.maxUploadSizeBytes`) within the `while` loop that reads from untrusted input streams. Track `totalRead` and throw an exception immediately if the limit is exceeded. + +## 2026-07-26 - [PII exposure in audit logging] +**Vulnerability:** The `approverId` in the `PolicyOverrideRequest` was being logged in plaintext when a blocked file extension was allowed. +**Learning:** System audit logs were capturing sensitive user identifiers without pseudonimization, violating PII rules. +**Prevention:** Always hash or fingerprint sensitive identifiers (using a secure hashing algorithm like SHA-256) before appending them to audit log streams. diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java index 95e67228..5e2a7c0a 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java @@ -115,7 +115,7 @@ public void validateOrThrow(MultipartFile file, PolicyOverrideRequest overrideRe LOGGER.info( "Blocked-format override accepted extension={} approverId={} tokenFingerprint={}", sanitizeForLog(extension), - sanitizeForLog(overrideApproverIdForAudit), + hashApproverId(overrideApproverIdForAudit), tokenFingerprint(overrideTokenForAudit) ); } @@ -209,6 +209,19 @@ private String tokenFingerprint(String approvalToken) { } } + private String hashApproverId(String approverId) { + if (approverId == null) { + return "null"; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(approverId.getBytes(StandardCharsets.UTF_8)); + return HEX_FORMAT.formatHex(hashed); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 digest unavailable", ex); + } + } + private String sanitizeForLog(final String value) { if (value == null) { return ""; diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java index 4f27bdcc..1c2ed630 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java @@ -513,6 +513,16 @@ void sanitizeForLogReturnsEmptyWhenInputIsNull() throws Exception { assertEquals("", sanitized); } + @Test + void hashApproverIdReturnsNullStringWhenInputIsNull() throws Exception { + ConversionProperties conversionProperties = new ConversionProperties(); + DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); + Method method = DefaultDocumentValidationService.class.getDeclaredMethod("hashApproverId", String.class); + method.setAccessible(true); + String hashed = (String) method.invoke(validationService, (String) null); + assertEquals("null", hashed); + } + @Test void sanitizeForLogReplacesTabCharacter() throws Exception { ConversionProperties conversionProperties = new ConversionProperties(); @@ -525,6 +535,33 @@ void sanitizeForLogReplacesTabCharacter() throws Exception { assertEquals("approver_id", sanitized); } + @Test + void throwsWhenSha256DigestIsUnavailableForHashApproverId() throws Exception { + ConversionProperties conversionProperties = new ConversionProperties(); + DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); + Method method = DefaultDocumentValidationService.class.getDeclaredMethod("hashApproverId", String.class); + method.setAccessible(true); + + synchronized (SECURITY_PROVIDERS_LOCK) { + Provider[] providers = Security.getProviders(); + for (Provider provider : providers) { + Security.removeProvider(provider.getName()); + } + try { + java.lang.reflect.InvocationTargetException ex = assertThrows( + java.lang.reflect.InvocationTargetException.class, + () -> method.invoke(validationService, "approver-1") + ); + assertEquals(IllegalStateException.class, ex.getCause().getClass()); + assertEquals("SHA-256 digest unavailable", ex.getCause().getMessage()); + } finally { + for (int index = 0; index < providers.length; index++) { + Security.insertProviderAt(providers[index], index + 1); + } + } + } + } + @Test void throwsWhenSha256DigestIsUnavailableForOverrideAuditFingerprint() { ConversionProperties conversionProperties = new ConversionProperties();