feat(compliance): implement compliance verfication for get info on PDF (#5435)

# Description of Changes


This PR replaces the legacy, fragile PDF/A validation logic (which
relied on manual regex parsing and PDFBox Preflight) with the robust
**VeraPDF** integration. Additionally, it introduces a new check for
**SEC/EDGAR compliance** and refactors the frontend report view to
display detailed verification results.

### Key Changes

**Backend (`GetInfoOnPDF.java`):**

* **VeraPDF Integration:** Replaced the custom `checkForStandard` and
`validatePdfAWithPreflight` methods with `VeraPDFService.validatePDF()`.
This aligns validation with industry standards.
* **Code Cleanup:** Removed approximately ~200 lines of technical debt,
including raw XML regex parsing and file-locking prone `PreflightParser`
logic.
* **SEC Compliance:** Added `isSECCompliant()` logic to validate
documents against typical EDGAR requirements:
* No Encryption.
* No Active Content (JavaScript).
* No External Links.
* No Embedded Files.
* No AcroForms.



**Frontend (`GetPdfInfo`):**

* **New Component:** Created `ComplianceSection.tsx` to handle the
complexity of compliance reporting.
* **Enhanced UI:**
* Added visual badges (Passed/Failed/Not Detected) using
`CheckIcon`/`CloseIcon`.
* Added support for displaying the detailed VeraPDF summary alongside
legacy boolean flags.
* Improved handling of "Not PDF/A" states.


### Motivation

The previous validation logic was prone to false positives/negatives and
memory issues with large files (due to Preflight). Moving to VeraPDF
provides accurate, profile-based validation (e.g., PDF/A-1b, 2b, etc.).
The SEC check satisfies a growing need for users validating documents
for financial filings.



<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <[email protected]>
Signed-off-by: brios <[email protected]>
This commit is contained in:
Balázs Szücs
2026-01-23 21:36:35 +00:00
committed by GitHub
parent 0b86dd79d3
commit 188408fc1e
7 changed files with 637 additions and 329 deletions
@@ -1,22 +1,30 @@
package stirling.software.SPDF.controller.api.security;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.GregorianCalendar;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.*;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
import org.apache.pdfbox.pdmodel.encryption.ProtectionPolicy;
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionLaunch;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
@@ -35,6 +43,8 @@ import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -43,6 +53,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
class GetInfoOnPDFTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private VeraPDFService veraPDFService;
@InjectMocks private GetInfoOnPDF getInfoOnPDF;
@@ -661,41 +672,6 @@ class GetInfoOnPDFTest {
void testGetPageSize(float width, float height, String expected) {
Assertions.assertEquals(expected, GetInfoOnPDF.getPageSize(width, height));
}
@Test
@DisplayName("Should check for PDF/A standard")
void testCheckForStandard_PdfA() throws IOException {
// This would require a real PDF/A document or mocking
PDDocument document = createSimplePdfWithText("Test");
boolean result = GetInfoOnPDF.checkForStandard(document, "PDF/A");
Assertions.assertFalse(result); // Simple PDF is not PDF/A compliant
document.close();
}
@Test
@DisplayName("Should handle null document in checkForStandard")
void testCheckForStandard_NullDocument() {
boolean result = GetInfoOnPDF.checkForStandard(null, "PDF/A");
Assertions.assertFalse(result);
}
@Test
@DisplayName("Should get PDF/A conformance level")
void testGetPdfAConformanceLevel() throws IOException {
PDDocument document = createSimplePdfWithText("Test");
String level = GetInfoOnPDF.getPdfAConformanceLevel(document);
Assertions.assertNull(level);
document.close();
}
@Test
@DisplayName("Should handle encrypted document in getPdfAConformanceLevel")
void testGetPdfAConformanceLevel_EncryptedDocument() throws IOException {
PDDocument document = createEncryptedPdf();
String level = GetInfoOnPDF.getPdfAConformanceLevel(document);
Assertions.assertNull(level); // Encrypted documents return null
document.close();
}
}
@Nested
@@ -776,8 +752,8 @@ class GetInfoOnPDFTest {
class ComplianceTests {
@Test
@DisplayName("Should check PDF/A compliance")
void testCompliance_PdfA() throws IOException {
@DisplayName("Should extract compliance info using VeraPDF")
void testCompliance_PdfA() throws Exception {
PDDocument document = createSimplePdfWithText("Test PDF/A");
MockMultipartFile mockFile = documentToMultipartFile(document, "pdfa.pdf");
@@ -791,16 +767,22 @@ class GetInfoOnPDFTest {
ArgumentMatchers.anyBoolean()))
.thenReturn(loadedDoc);
// Mock VeraPDFService
PDFVerificationResult result = new PDFVerificationResult();
result.setStandard("pdfa-1b");
result.setCompliant(true);
result.setComplianceSummary("PDF/A-1b compliant");
Mockito.when(veraPDFService.validatePDF(ArgumentMatchers.any(InputStream.class)))
.thenReturn(List.of(result));
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
JsonNode compliancy = jsonNode.get("Compliancy");
Assertions.assertTrue(compliancy.has("IsPDF/ACompliant"));
Assertions.assertTrue(compliancy.has("IsPDF/XCompliant"));
Assertions.assertTrue(compliancy.has("IsPDF/ECompliant"));
Assertions.assertTrue(compliancy.has("IsPDF/UACompliant"));
Assertions.assertTrue(compliancy.has("pdfa-1b"));
Assertions.assertTrue(compliancy.get("pdfa-1b").asBoolean());
loadedDoc.close();
}
@@ -840,4 +822,104 @@ class GetInfoOnPDFTest {
loadedDoc.close();
}
}
@Test
@DisplayName("SEC Compliance: Clean document should pass")
void testSecCompliance_Clean() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
checkSecCompliance(doc, true);
}
}
@Test
@DisplayName("SEC Compliance: JavaScript action should fail")
void testSecCompliance_JavaScript() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
PDActionJavaScript jsAction = new PDActionJavaScript("app.alert('Hi')");
doc.getDocumentCatalog().setOpenAction(jsAction);
checkSecCompliance(doc, false);
}
}
@Test
@DisplayName("SEC Compliance: External URI Link should fail")
void testSecCompliance_ExternalLink() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
PDAnnotationLink link = new PDAnnotationLink();
PDActionURI action = new PDActionURI();
action.setURI("http://google.com");
link.setAction(action);
page.getAnnotations().add(link);
checkSecCompliance(doc, false);
}
}
@Test
@DisplayName("SEC Compliance: Launch Action should fail")
void testSecCompliance_LaunchAction() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
PDAnnotationLink link = new PDAnnotationLink();
PDActionLaunch action = new PDActionLaunch();
link.setAction(action);
page.getAnnotations().add(link);
checkSecCompliance(doc, false);
}
}
@Test
@DisplayName("SEC Compliance: Embedded File should fail")
void testSecCompliance_EmbeddedFile() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
PDComplexFileSpecification fs = new PDComplexFileSpecification();
fs.setFile("test.txt");
PDEmbeddedFile ef =
new PDEmbeddedFile(doc, new ByteArrayInputStream("test".getBytes()));
fs.setEmbeddedFile(ef);
PDEmbeddedFilesNameTreeNode efTree = new PDEmbeddedFilesNameTreeNode();
efTree.setNames(Collections.singletonMap("test", fs));
PDDocumentNameDictionary names = new PDDocumentNameDictionary(doc.getDocumentCatalog());
names.setEmbeddedFiles(efTree);
doc.getDocumentCatalog().setNames(names);
checkSecCompliance(doc, false);
}
}
private void checkSecCompliance(PDDocument doc, boolean expected) throws Exception {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
doc.save(baos);
byte[] bytes = baos.toByteArray();
Mockito.when(
pdfDocumentFactory.load(
ArgumentMatchers.any(MultipartFile.class),
ArgumentMatchers.anyBoolean()))
.thenReturn(Loader.loadPDF(bytes));
PDFFile request = new PDFFile();
request.setFileInput(
new MockMultipartFile("file", "test.pdf", "application/pdf", bytes));
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
boolean actual = jsonNode.get("Compliancy").get("IsPDF/SECCompliant").asBoolean();
Assertions.assertEquals(expected, actual, "SEC Compliance check failed");
}
}
}