From 3691d8070e2fabe12f7ea4a2b6eb8b057ad233b2 Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Sat, 16 Aug 2025 08:46:26 -1000 Subject: [PATCH 1/3] feat(api,osi,parser): descriptive filenames, robust OSI tool JSON parsing, large-payload handling, and stable OSI healthcheck - OSIController: accept multipart/form-data; parse `toolNames` JSON string into List with fallback to project tools; rename final SBOM to `ProjectName-OSI-{CDX14|SPDX23}-{JSON|XML|TAGVALUE}-yyyyMMdd-HHmmss.{json|xml|spdx}`. - ParserController: align naming to `ProjectName-PARSERS-...`; correct extension mapping for SPDX TAGVALUE (.spdx), SPDX JSON (.json), CDX JSON (.json), CDX XML (.xml). - SBOMFileService: add `rename(id, newName)` helper. - OSIService: raise Jackson max string length for OSI `/generate` JSON (supports large base64 payloads). - compose.yaml / compose.dev.yaml: fix OSI healthcheck (use Python to GET `/tools`), increase start period/timeout/retries for reliability. Refs: ensure UI-provided `toolNames` as JSON array is parsed consistently; final filenames are human-readable and searchable. --- .dockerignore | 3 +- .../svip/api/controller/OSIController.java | 44 ++++++++++++++----- .../svip/api/controller/ParserController.java | 22 +++++++++- .../org/svip/api/services/OSIService.java | 11 ++++- .../svip/api/services/SBOMFileService.java | 17 +++++++ .../resources/sample_projects/Java/Bar.java | 2 +- compose.dev.yaml | 10 ++--- compose.yaml | 10 ++--- .../osi/sampleProject/SampleJavaClass.java | 2 +- 9 files changed, 93 insertions(+), 28 deletions(-) diff --git a/.dockerignore b/.dockerignore index d16386367..d5e60da7e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,2 @@ -build/ \ No newline at end of file +build/ +.env diff --git a/api/src/main/java/org/svip/api/controller/OSIController.java b/api/src/main/java/org/svip/api/controller/OSIController.java index 4ae46d9e9..eeae59875 100644 --- a/api/src/main/java/org/svip/api/controller/OSIController.java +++ b/api/src/main/java/org/svip/api/controller/OSIController.java @@ -155,24 +155,24 @@ public ResponseEntity uploadProject(@RequestPart("project") MultipartFile pro * possible tools will be used. * @return The ID of the uploaded SBOM. */ - @PostMapping(value = "") + @PostMapping(value = "", consumes = { MediaType.MULTIPART_FORM_DATA_VALUE }) public ResponseEntity generateWithOSI(@RequestParam("projectName") String projectName, @RequestParam("schema") SerializerFactory.Schema schema, @RequestParam("format") SerializerFactory.Format format, - @RequestParam(value = "toolNames", required = false) String toolNames) { + @RequestParam(value = "toolNames", required = false) String toolNamesJson) { HashMap generatedSBOMs; try { // Run with requested tools, default to relevant ones List tools; - if (toolNames != null) { - /* - todo - this is a hotfix - tldr when gui sends multipart form "toolNames" is sent as string ( "["foo","bar"]" ) - and not an actual String[]. This hotfix just converts the string to an array - */ - ObjectMapper mapper = new ObjectMapper(); - tools = List.of(mapper.readValue(toolNames, String[].class)); + if (toolNamesJson != null && !toolNamesJson.isBlank()) { + try { + ObjectMapper mapper = new ObjectMapper(); + tools = mapper.readValue(toolNamesJson, List.class); + } catch (Exception parseEx) { + LOGGER.warn("POST /svip/generators/osi - Invalid toolNames JSON; defaulting to project tools. Error: {}", parseEx.getMessage()); + tools = this.osiService.getTools("project"); + } } else { tools = this.osiService.getTools("project"); } @@ -258,7 +258,29 @@ public ResponseEntity generateWithOSI(@RequestParam("projectName") String pro return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } - // todo how to set file name using projectName + // Set descriptive filename: ProjectName-OSI-Schema-Format-Timestamp.ext + try { + String extension; + if (schema == SerializerFactory.Schema.SPDX23) { + extension = (format == SerializerFactory.Format.TAGVALUE) ? ".spdx" : ".json"; + } else { // CDX14 + extension = (format == SerializerFactory.Format.XML) ? ".xml" : ".json"; + } + + String schemaStr = (schema == SerializerFactory.Schema.SPDX23) ? "SPDX23" : "CDX14"; + String formatStr = switch (format) { + case JSON -> "JSON"; + case XML -> "XML"; + case TAGVALUE -> "TAGVALUE"; + }; + + String safeProject = (projectName == null ? "SBOM" : projectName).replaceAll("[^A-Za-z0-9._-]+", "-"); + String ts = java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); + String finalName = safeProject + "-OSI-" + schemaStr + "-" + formatStr + "-" + ts + extension; + sbomService.rename(convertedID, finalName); + } catch (Exception ignored) { + // keep auto name if rename fails + } // Return ID return new ResponseEntity<>(convertedID, HttpStatus.OK); diff --git a/api/src/main/java/org/svip/api/controller/ParserController.java b/api/src/main/java/org/svip/api/controller/ParserController.java index fee03527c..1f3eee3ed 100644 --- a/api/src/main/java/org/svip/api/controller/ParserController.java +++ b/api/src/main/java/org/svip/api/controller/ParserController.java @@ -135,9 +135,27 @@ public ResponseEntity generateParsers(@RequestParam("zipFile") MultipartFile // Convert & save according to overwrite boolean SBOMFile converted; try { + // Build descriptive filename: ProjectName-PARSERS-Schema-Format-Timestamp.ext + String extension; + if (schema == SerializerFactory.Schema.SPDX23) { + extension = (format == SerializerFactory.Format.TAGVALUE) ? ".spdx" : ".json"; + } else { // CDX14 + extension = (format == SerializerFactory.Format.XML) ? ".xml" : ".json"; + } + + String schemaStr = (schema == SerializerFactory.Schema.SPDX23) ? "SPDX23" : "CDX14"; + String formatStr = switch (format) { + case JSON -> "JSON"; + case XML -> "XML"; + case TAGVALUE -> "TAGVALUE"; + }; + + String safeProject = (projectName == null ? "SBOM" : projectName).replaceAll("[^A-Za-z0-9._-]+", "-"); + String ts = java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); + String finalName = safeProject + "-PARSERS-" + schemaStr + "-" + formatStr + "-" + ts + extension; + // convert result sbomfile to sbom - UploadSBOMFileInput u = new UploadSBOMFileInput(projectName + ((format == SerializerFactory.Format.JSON) - ? ".json" : ".spdx"), contents); + UploadSBOMFileInput u = new UploadSBOMFileInput(finalName, contents); converted = u.toSBOMFile(); sbomService.upload(converted); } catch (JsonProcessingException e) { diff --git a/api/src/main/java/org/svip/api/services/OSIService.java b/api/src/main/java/org/svip/api/services/OSIService.java index 97a584b70..e6e7de9cc 100644 --- a/api/src/main/java/org/svip/api/services/OSIService.java +++ b/api/src/main/java/org/svip/api/services/OSIService.java @@ -24,6 +24,8 @@ package org.svip.api.services; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -135,8 +137,13 @@ public HashMap generateSBOMs(List toolNames) throws IOEx if (response.getStatusLine().getStatusCode() == 204) return new HashMap<>(); - // Convert osi json string into map - ObjectMapper mapper = new ObjectMapper(); + // Convert osi json string into map. Increase max string length to support large base64 bodies + JsonFactory factory = JsonFactory.builder() + .streamReadConstraints(StreamReadConstraints.builder() + .maxStringLength(100_000_000) // 100MB, higher than default 20MB + .build()) + .build(); + ObjectMapper mapper = new ObjectMapper(factory); return mapper.readValue(EntityUtils.toString(response.getEntity()), new TypeReference<>() { }); } diff --git a/api/src/main/java/org/svip/api/services/SBOMFileService.java b/api/src/main/java/org/svip/api/services/SBOMFileService.java index 27c17aa43..c0b3de175 100644 --- a/api/src/main/java/org/svip/api/services/SBOMFileService.java +++ b/api/src/main/java/org/svip/api/services/SBOMFileService.java @@ -361,6 +361,23 @@ private Long update(Long id, SBOMFile patch) { return sbomFile.getId(); } + /** + * Rename an SBOM entry in the database + * + * @param id ID of SBOM to rename + * @param newName New file name to set + * @return id of the updated SBOM or null if not found + */ + public Long rename(Long id, String newName) { + SBOMFile sbomFile = getSBOMFile(id); + if (sbomFile == null) { + return null; + } + sbomFile.setName(newName); + this.sbomFileRepository.save(sbomFile); + return sbomFile.getId(); + } + /** * Returns a map of fixes * diff --git a/api/src/test/resources/sample_projects/Java/Bar.java b/api/src/test/resources/sample_projects/Java/Bar.java index 9a6abf7b2..c4653fba7 100644 --- a/api/src/test/resources/sample_projects/Java/Bar.java +++ b/api/src/test/resources/sample_projects/Java/Bar.java @@ -22,6 +22,6 @@ * SOFTWARE. */ -package org.svip.api.sample_projects.Java; +package sample_projects.Java; diff --git a/compose.dev.yaml b/compose.dev.yaml index 21a263221..c0e3352a2 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -14,18 +14,18 @@ services: ports: - "5000:5000" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5000/healthcheck"] - start_period: 15s # small delay to let startup scripts finish + test: ["CMD-SHELL", "python3 -c \"import urllib.request,sys; urllib.request.urlopen('http://localhost:5000/tools', timeout=5); sys.exit(0)\""] + start_period: 60s # allow validation to finish before first check interval: 20s - timeout: 5s - retries: 3 + timeout: 10s + retries: 5 # MySQL server sbox_db: container_name: sbox_db platform: linux/amd64 image: mysql:8.4.6 - command: --max_allowed_packet=32505856 # Set max_allowed_packet to 256M + command: --max_allowed_packet=268435456 # Set max_allowed_packet to 256M env_file: - path: .env required: true diff --git a/compose.yaml b/compose.yaml index b75d98d31..450d2db64 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,18 +12,18 @@ services: platform: linux/amd64 build: osi healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5000/healthcheck"] - start_period: 15s # small delay to let startup scripts finish + test: ["CMD-SHELL", "python3 -c \"import urllib.request,sys; urllib.request.urlopen('http://localhost:5000/tools', timeout=5); sys.exit(0)\""] + start_period: 60s # allow validation to finish before first check interval: 20s - timeout: 5s - retries: 3 + timeout: 10s + retries: 5 # MySQL server sbox_db: container_name: sbox_db platform: linux/amd64 image: mysql:8.4.6 - command: --max_allowed_packet=32505856 # Set max_allowed_packet to 256M + command: --max_allowed_packet=268435456 # Set max_allowed_packet to 256M env_file: - path: .env required: true diff --git a/core/src/test/resources/osi/sampleProject/SampleJavaClass.java b/core/src/test/resources/osi/sampleProject/SampleJavaClass.java index 187b12011..64a6e461f 100644 --- a/core/src/test/resources/osi/sampleProject/SampleJavaClass.java +++ b/core/src/test/resources/osi/sampleProject/SampleJavaClass.java @@ -22,7 +22,7 @@ * SOFTWARE. */ -package projects.sampleProject; +package osi.sampleProject; public class SampleJavaClass { public static void main(String[] args) { From 1877437d73a95bdf1e7ab34a100b1d4a702a3e28 Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Thu, 21 Aug 2025 23:40:28 -1000 Subject: [PATCH 2/3] chore(backend): Increase MySQL and Java memory settings in compose files Raised MySQL's max_allowed_packet and innodb_redo_log_capacity to 1GB in both compose.dev.yaml and compose.yaml. Added JAVA_TOOL_OPTIONS to set higher heap and GC options for the relevant service, with different memory allocations for dev and prod. --- compose.dev.yaml | 3 ++- compose.yaml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/compose.dev.yaml b/compose.dev.yaml index c0e3352a2..c3970752a 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -25,7 +25,7 @@ services: container_name: sbox_db platform: linux/amd64 image: mysql:8.4.6 - command: --max_allowed_packet=268435456 # Set max_allowed_packet to 256M + command: --max_allowed_packet=1073741824 --innodb_redo_log_capacity=1073741824 # Set max_allowed_packet to 256M env_file: - path: .env required: true @@ -61,6 +61,7 @@ services: # create mysql user defined in the db MYSQL_USER: ${MYSQL_USER:?'MYSQL_USER' must be set in the .env file} MYSQL_PASSWORD: ${MYSQL_PASSWORD:?'MYSQL_PASSWORD' must be set in the .env file} + JAVA_TOOL_OPTIONS: "-Xms512m -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200" depends_on: sbox_db: condition: service_healthy diff --git a/compose.yaml b/compose.yaml index 450d2db64..2230ee72a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -23,7 +23,7 @@ services: container_name: sbox_db platform: linux/amd64 image: mysql:8.4.6 - command: --max_allowed_packet=268435456 # Set max_allowed_packet to 256M + command: --max_allowed_packet=1073741824 --innodb_redo_log_capacity=1073741824 # Set max_allowed_packet to 256M env_file: - path: .env required: true @@ -57,6 +57,7 @@ services: # create mysql user defined in the db MYSQL_USER: ${MYSQL_USER:?'MYSQL_USER' must be set in the .env file} MYSQL_PASSWORD: ${MYSQL_PASSWORD:?'MYSQL_PASSWORD' must be set in the .env file} + JAVA_TOOL_OPTIONS: "-Xms2g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200" depends_on: sbox_db: condition: service_healthy From 059163c7395522ea60a9267ec73024c1d4e4f0c3 Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Wed, 27 Aug 2025 20:24:01 -1000 Subject: [PATCH 3/3] refactor(sbom): decouple entity serialization and prevent circular refs - Introduce SBOMFileDTO to separate API responses from entity models - Configure Jackson to increase max nesting depth and handle self-references - Apply @JsonIgnore and @JsonBackReference to entity relationships - Update SBOMController and related tests to use DTOs - Improve filename extraction in UploadSBOMFileInput - Enhance robustness in NVDClient and OSVClient: - Filter out non-package files - Handle API failures gracefully - Add null checks for supplier fields in serializers and fix related logic --- .../org/svip/api/config/JacksonConfig.java | 30 ++++ .../svip/api/controller/SBOMController.java | 6 +- .../java/org/svip/api/dto/SBOMFileDTO.java | 105 +++++++++++++ .../svip/api/entities/QualityReportFile.java | 2 + .../java/org/svip/api/entities/SBOMFile.java | 6 + .../java/org/svip/api/entities/VEXFile.java | 2 + .../api/entities/diff/ComparisonFile.java | 8 +- .../svip/api/entities/diff/ConflictFile.java | 5 + .../api/requests/UploadSBOMFileInput.java | 12 +- .../api/controller/SBOMControllerTest.java | 7 +- .../java/org/svip/repair/fix/PURLFixes.java | 4 +- .../SPDX23TagValueDeserializer.java | 2 +- .../serializer/SPDX23JSONSerializer.java | 4 +- .../serializer/SPDX23TagValueSerializer.java | 4 +- .../java/org/svip/vex/database/NVDClient.java | 79 +++++----- .../java/org/svip/vex/database/OSVClient.java | 143 ++++++++++++++++-- 16 files changed, 355 insertions(+), 64 deletions(-) create mode 100644 api/src/main/java/org/svip/api/config/JacksonConfig.java create mode 100644 api/src/main/java/org/svip/api/dto/SBOMFileDTO.java diff --git a/api/src/main/java/org/svip/api/config/JacksonConfig.java b/api/src/main/java/org/svip/api/config/JacksonConfig.java new file mode 100644 index 000000000..f4817fa92 --- /dev/null +++ b/api/src/main/java/org/svip/api/config/JacksonConfig.java @@ -0,0 +1,30 @@ +package org.svip.api.config; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamWriteConstraints; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + // Increase max nesting depth for writing deep JSON structures (e.g., VEX) + JsonFactory factory = JsonFactory.builder() + .streamWriteConstraints(StreamWriteConstraints.builder() + .maxNestingDepth(5_000) + .build()) + .build(); + + ObjectMapper mapper = new ObjectMapper(factory); + + // Configure to handle circular references globally + mapper.configure(SerializationFeature.FAIL_ON_SELF_REFERENCES, false); + mapper.configure(SerializationFeature.WRITE_SELF_REFERENCES_AS_NULL, true); + + return mapper; + } +} diff --git a/api/src/main/java/org/svip/api/controller/SBOMController.java b/api/src/main/java/org/svip/api/controller/SBOMController.java index 15f4ae1e7..19ae37ebf 100644 --- a/api/src/main/java/org/svip/api/controller/SBOMController.java +++ b/api/src/main/java/org/svip/api/controller/SBOMController.java @@ -30,6 +30,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.svip.api.dto.SBOMFileDTO; import org.svip.api.entities.SBOMFile; import org.svip.api.requests.UploadSBOMFileInput; import org.svip.api.services.SBOMFileService; @@ -202,7 +203,7 @@ public ResponseEntity getSBOMObjectAsJSON(@RequestParam("id") Long id) { * @return The contents of the SBOM file. */ @GetMapping("/sboms/content") - public ResponseEntity getContent(@RequestParam("id") Long id) { + public ResponseEntity getContent(@RequestParam("id") Long id) { // todo rename endpoint? Returns more than just content // Get SBOM SBOMFile sbomFile = this.sbomService.getSBOMFile(id); @@ -216,7 +217,8 @@ public ResponseEntity getContent(@RequestParam("id") Long id) { // Log LOGGER.info("GET /svip/sboms/content?id=" + id + " - File: " + sbomFile.getName()); - return new ResponseEntity<>(sbomFile, HttpStatus.OK); + // Return DTO to avoid circular references + return new ResponseEntity<>(new SBOMFileDTO(sbomFile), HttpStatus.OK); } diff --git a/api/src/main/java/org/svip/api/dto/SBOMFileDTO.java b/api/src/main/java/org/svip/api/dto/SBOMFileDTO.java new file mode 100644 index 000000000..149e92224 --- /dev/null +++ b/api/src/main/java/org/svip/api/dto/SBOMFileDTO.java @@ -0,0 +1,105 @@ +/** + * Copyright 2021 Rochester Institute of Technology (RIT). Developed with + * government support under contract 70RCSA22C00000008 awarded by the United + * States Department of Homeland Security for Cybersecurity and Infrastructure Security Agency. + *

+ * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + *

+ * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + *

+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.svip.api.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.svip.api.entities.SBOMFile; + +/** + * file: SBOMFileDTO.java + *

+ * Data Transfer Object for SBOM Files - eliminates circular references + * + * @author Ibrahim Matar + **/ +public class SBOMFileDTO { + + @JsonProperty + private Long id; + + @JsonProperty("fileName") + private String name; + + @JsonProperty("contents") + private String content; + + @JsonProperty + private SBOMFile.Schema schema; + + @JsonProperty + private SBOMFile.FileType fileType; + + // Constructors + public SBOMFileDTO() {} + + public SBOMFileDTO(SBOMFile sbomFile) { + this.id = sbomFile.getId(); + this.name = sbomFile.getName(); + this.content = sbomFile.getContent(); + this.schema = sbomFile.getSchema(); + this.fileType = sbomFile.getFileType(); + } + + // Getters and setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public SBOMFile.Schema getSchema() { + return schema; + } + + public void setSchema(SBOMFile.Schema schema) { + this.schema = schema; + } + + public SBOMFile.FileType getFileType() { + return fileType; + } + + public void setFileType(SBOMFile.FileType fileType) { + this.fileType = fileType; + } +} diff --git a/api/src/main/java/org/svip/api/entities/QualityReportFile.java b/api/src/main/java/org/svip/api/entities/QualityReportFile.java index 9aac663f1..ff93125b5 100644 --- a/api/src/main/java/org/svip/api/entities/QualityReportFile.java +++ b/api/src/main/java/org/svip/api/entities/QualityReportFile.java @@ -24,6 +24,7 @@ package org.svip.api.entities; +import com.fasterxml.jackson.annotation.JsonBackReference; import jakarta.persistence.*; /** @@ -47,6 +48,7 @@ public class QualityReportFile { private String content; @OneToOne(mappedBy = "qualityReportFile") // name of field in SBOMFile NOT DB + @JsonBackReference("sbom-quality") private SBOMFile sbomFile; diff --git a/api/src/main/java/org/svip/api/entities/SBOMFile.java b/api/src/main/java/org/svip/api/entities/SBOMFile.java index cf89f66b4..92b68f960 100644 --- a/api/src/main/java/org/svip/api/entities/SBOMFile.java +++ b/api/src/main/java/org/svip/api/entities/SBOMFile.java @@ -26,6 +26,8 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.*; @@ -72,15 +74,19 @@ public class SBOMFile { @OneToOne(cascade = CascadeType.REMOVE, orphanRemoval = true) // delete all qa on sbom deletion @JoinColumn(name = "qa_id", referencedColumnName = "id") + @JsonIgnore private QualityReportFile qualityReportFile; @OneToOne(cascade = CascadeType.REMOVE, orphanRemoval = true) // delete all vex on sbom deletion @JoinColumn(name = "vex_id", referencedColumnName = "id") + @JsonIgnore private VEXFile vexFile; // Collection of comparisons where this was the target @OneToMany(mappedBy = "targetSBOMFile", cascade = CascadeType.REMOVE, orphanRemoval = true) + @JsonIgnore private Set comparisonsAsTarget = new HashSet<>(); // Collection of comparisons where this was the other @OneToMany(mappedBy = "otherSBOMFile", cascade = CascadeType.REMOVE, orphanRemoval = true) + @JsonIgnore private Set comparisonsAsOther = new HashSet<>(); /** diff --git a/api/src/main/java/org/svip/api/entities/VEXFile.java b/api/src/main/java/org/svip/api/entities/VEXFile.java index 23859d44b..dec86091f 100644 --- a/api/src/main/java/org/svip/api/entities/VEXFile.java +++ b/api/src/main/java/org/svip/api/entities/VEXFile.java @@ -24,6 +24,7 @@ package org.svip.api.entities; +import com.fasterxml.jackson.annotation.JsonBackReference; import jakarta.persistence.*; import org.svip.vex.model.VEXType; @@ -52,6 +53,7 @@ public class VEXFile { private Database datasource; /// Relationships @OneToOne(mappedBy = "vexFile") // name of field in SBOMFile NOT DB + @JsonBackReference("sbom-vex") private SBOMFile sbomFile; /** diff --git a/api/src/main/java/org/svip/api/entities/diff/ComparisonFile.java b/api/src/main/java/org/svip/api/entities/diff/ComparisonFile.java index ca49ed95b..43e946709 100644 --- a/api/src/main/java/org/svip/api/entities/diff/ComparisonFile.java +++ b/api/src/main/java/org/svip/api/entities/diff/ComparisonFile.java @@ -24,6 +24,8 @@ package org.svip.api.entities.diff; +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonManagedReference; import jakarta.persistence.*; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; @@ -53,17 +55,19 @@ public class ComparisonFile { @ManyToOne @JoinColumn(name = "target_sbom_id", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) + @JsonBackReference("sbom-comparison-target") private SBOMFile targetSBOMFile; // Other SBOM @ManyToOne @JoinColumn(name = "other_sbom_id", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) + @JsonBackReference("sbom-comparison-other") private SBOMFile otherSBOMFile; // Conflict collection -// @OneToMany(mappedBy = "comparison", cascade = CascadeType.REMOVE, orphanRemoval = true) - @OneToMany(mappedBy = "comparison") + @OneToMany(mappedBy = "comparison", cascade = CascadeType.REMOVE, orphanRemoval = true) + @JsonManagedReference("comparison-conflicts") private Set conflicts = new HashSet<>(); diff --git a/api/src/main/java/org/svip/api/entities/diff/ConflictFile.java b/api/src/main/java/org/svip/api/entities/diff/ConflictFile.java index 547b56d02..192b7e67b 100644 --- a/api/src/main/java/org/svip/api/entities/diff/ConflictFile.java +++ b/api/src/main/java/org/svip/api/entities/diff/ConflictFile.java @@ -24,9 +24,12 @@ package org.svip.api.entities.diff; +import com.fasterxml.jackson.annotation.JsonBackReference; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.persistence.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import org.svip.compare.conflicts.MismatchType; /** @@ -68,7 +71,9 @@ public class ConflictFile { // source comparison @ManyToOne + @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "comparison_id", nullable = false) + @JsonBackReference("comparison-conflicts") private ComparisonFile comparison; diff --git a/api/src/main/java/org/svip/api/requests/UploadSBOMFileInput.java b/api/src/main/java/org/svip/api/requests/UploadSBOMFileInput.java index 63f3eab2b..07064c612 100644 --- a/api/src/main/java/org/svip/api/requests/UploadSBOMFileInput.java +++ b/api/src/main/java/org/svip/api/requests/UploadSBOMFileInput.java @@ -28,6 +28,7 @@ import org.svip.api.entities.SBOMFile; import org.svip.serializers.SerializerFactory; import org.svip.serializers.deserializer.Deserializer; +import java.nio.file.Paths; /** * File: UploadSBOMFileInput.java @@ -46,7 +47,16 @@ public record UploadSBOMFileInput(String fileName, String contents) { public SBOMFile toSBOMFile() throws JsonProcessingException { SBOMFile sbomFile = new SBOMFile(); - sbomFile.setName(fileName) + // Extract just the filename from any path (handles Windows, Linux paths) + String extractedFileName; + try { + extractedFileName = Paths.get(fileName).getFileName().toString(); + } catch (Exception e) { + // If path parsing fails, use the original filename + extractedFileName = fileName; + } + + sbomFile.setName(extractedFileName) .setContent(contents); // Attempt to deserialize diff --git a/api/src/test/java/org/svip/api/controller/SBOMControllerTest.java b/api/src/test/java/org/svip/api/controller/SBOMControllerTest.java index 382356e9e..22d2bf5c2 100644 --- a/api/src/test/java/org/svip/api/controller/SBOMControllerTest.java +++ b/api/src/test/java/org/svip/api/controller/SBOMControllerTest.java @@ -32,6 +32,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.svip.api.dto.SBOMFileDTO; import org.svip.api.entities.SBOMFile; import org.svip.api.requests.UploadSBOMFileInput; import org.svip.api.services.SBOMFileService; @@ -222,12 +223,12 @@ void get_SBOM_content() throws Exception { // When when(sbomFileService.getSBOMFile(id)).thenReturn(sbomFile); - ResponseEntity response = sbomController.getContent(id); + ResponseEntity response = sbomController.getContent(id); // Then assertEquals(HttpStatus.OK, response.getStatusCode()); assertNotNull(response.getBody()); - assertEquals("./src/test/resources/sample_sboms/cdx-gomod-1.4.0-bin.json", response.getBody().getName()); + assertEquals("cdx-gomod-1.4.0-bin.json", response.getBody().getName()); verify(sbomFileService).getSBOMFile(id); } @@ -239,7 +240,7 @@ void get_SBOM_no_content() throws Exception { // When when(sbomFileService.getSBOMFile(id)).thenReturn(null); - ResponseEntity response = sbomController.getContent(id); + ResponseEntity response = sbomController.getContent(id); // Then assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); diff --git a/core/src/main/java/org/svip/repair/fix/PURLFixes.java b/core/src/main/java/org/svip/repair/fix/PURLFixes.java index 149fc717f..e6e642085 100644 --- a/core/src/main/java/org/svip/repair/fix/PURLFixes.java +++ b/core/src/main/java/org/svip/repair/fix/PURLFixes.java @@ -69,7 +69,7 @@ public List> fix(Result result, SBOM sbom, String componentName, Integer type = spdx23Package.getType(); // And if the Supplier is null, set the Supplier - if (spdx23Package.getSupplier() != null) + if (spdx23Package.getSupplier() != null && spdx23Package.getSupplier().getName() != null) nameSpace = spdx23Package.getSupplier().getName(); // And if the Version is null, set the Version @@ -87,7 +87,7 @@ else if (component instanceof CDX14Package cdx14Package) { type = cdx14Package.getType(); // If the Supplier is null, set the Supplier - if (cdx14Package.getSupplier() != null) + if (cdx14Package.getSupplier() != null && cdx14Package.getSupplier().getName() != null) nameSpace = cdx14Package.getSupplier().getName(); // If the Version is null, set the Version diff --git a/core/src/main/java/org/svip/serializers/deserializer/SPDX23TagValueDeserializer.java b/core/src/main/java/org/svip/serializers/deserializer/SPDX23TagValueDeserializer.java index 3a38e294d..4d88ce587 100644 --- a/core/src/main/java/org/svip/serializers/deserializer/SPDX23TagValueDeserializer.java +++ b/core/src/main/java/org/svip/serializers/deserializer/SPDX23TagValueDeserializer.java @@ -124,7 +124,7 @@ protected static void parseSPDXCreatorInfo(CreationData data, List creat // If we find an organization, set it to the supplier if there isn't already one. Otherwise, // add another author with the contact info if (creator.toLowerCase().startsWith("organization") && - (data.getSupplier() == null || data.getSupplier().getName().isEmpty())) { + (data.getSupplier() == null || data.getSupplier().getName() == null || data.getSupplier().getName().isEmpty())) { Organization supplier = new Organization(contact.getName(), null); supplier.addContact(contact); diff --git a/core/src/main/java/org/svip/serializers/serializer/SPDX23JSONSerializer.java b/core/src/main/java/org/svip/serializers/serializer/SPDX23JSONSerializer.java index f597d9ff8..862e3fe54 100644 --- a/core/src/main/java/org/svip/serializers/serializer/SPDX23JSONSerializer.java +++ b/core/src/main/java/org/svip/serializers/serializer/SPDX23JSONSerializer.java @@ -168,7 +168,7 @@ private void writeCreationData(JsonGenerator jsonGenerator, CreationData data, S creators.addAll(data.getCreationTools().stream() .map(t -> getCreatorString("Tool", t.getName(), t.getVersion())).toList()); - if (data.getSupplier() != null) { + if (data.getSupplier() != null && data.getSupplier().getName() != null) { Optional supplierContact = data.getSupplier().getContacts().stream().findFirst(); String supplierEmail = ""; if (supplierContact.isPresent()) supplierEmail = supplierContact.get().getEmail(); @@ -214,7 +214,7 @@ private void writePackage(JsonGenerator jsonGenerator, SVIPComponentObject pkg) jsonGenerator.writeStringField("versionInfo", pkg.getVersion()); jsonGenerator.writeStringField("packageFileName", pkg.getFileName()); // TODO is this correct? - if (pkg.getSupplier() != null) { + if (pkg.getSupplier() != null && pkg.getSupplier().getName() != null) { Optional supplierContact = pkg.getSupplier().getContacts().stream().findFirst(); String supplierEmail = ""; if (supplierContact.isPresent()) diff --git a/core/src/main/java/org/svip/serializers/serializer/SPDX23TagValueSerializer.java b/core/src/main/java/org/svip/serializers/serializer/SPDX23TagValueSerializer.java index 915b4a33c..342d739ef 100644 --- a/core/src/main/java/org/svip/serializers/serializer/SPDX23TagValueSerializer.java +++ b/core/src/main/java/org/svip/serializers/serializer/SPDX23TagValueSerializer.java @@ -80,7 +80,7 @@ private String getDocumentInfo(SVIPSBOM sbom) { creators.addAll(sbom.getCreationData().getCreationTools().stream() .map(t -> getCreatorString("Tool", t.getName(), t.getVersion())).toList()); - if (sbom.getCreationData().getSupplier() != null) { + if (sbom.getCreationData().getSupplier() != null && sbom.getCreationData().getSupplier().getName() != null) { Optional supplierContact = sbom.getCreationData().getSupplier().getContacts().stream().findFirst(); String supplierEmail = ""; if (supplierContact.isPresent()) @@ -188,7 +188,7 @@ private String getPackageInfo(SVIPComponentObject pkg) { out.append(buildTagValue("PackageComment", pkg.getComment())); out.append(buildTagValue("PackageFileName", pkg.getFileName())); - if (pkg.getSupplier() != null) { + if (pkg.getSupplier() != null && pkg.getSupplier().getName() != null) { Optional supplierContact = pkg.getSupplier().getContacts().stream().findFirst(); String supplierEmail = ""; if (supplierContact.isPresent()) diff --git a/core/src/main/java/org/svip/vex/database/NVDClient.java b/core/src/main/java/org/svip/vex/database/NVDClient.java index 6e26f7cf4..9b9f3d0b8 100644 --- a/core/src/main/java/org/svip/vex/database/NVDClient.java +++ b/core/src/main/java/org/svip/vex/database/NVDClient.java @@ -134,31 +134,38 @@ public List getVEXStatements(SBOMPackage s) throws Exception { // if component has no cpes continue as we can only search if there are cpes if (cpes == null || cpes.isEmpty()) - throw new Exception("Component does not have CPEs " + - "to test with NVD API"); + return vexStatements; // use the cpes to search for vulnerabilities ArrayList cpesList = new ArrayList<>(cpes); String cpeString = cpesList.get(0); componentID = cpeString; - response = accessNVD(cpeString); + try { + response = accessNVD(cpeString); + } catch (Exception e) { + // Log and continue gracefully with empty results + System.err.println("NVD API call failed for CPE " + cpeString + ": " + e.getMessage()); + return vexStatements; + } TimeUnit.SECONDS.sleep(waitTime); // check that the response was not null to find vulnerabilities - if (response != null) { + if (response != null && !response.isBlank()) { JSONObject jsonResponse = new JSONObject(response); - JSONArray vulns = new JSONArray( - jsonResponse.getJSONArray("vulnerabilities")); - for (int i = 0; i < vulns.length(); i++) { - // get the singular vulnerability and create a - // VEXStatement for it - JSONObject vulnerability = vulns.getJSONObject(i) - .getJSONObject("cve"); - VEXStatement statement = - generateVEXStatement(vulnerability, - s, componentID); - vexStatements.add(statement); + if (jsonResponse.has("vulnerabilities")) { + JSONArray vulns = new JSONArray( + jsonResponse.getJSONArray("vulnerabilities")); + for (int i = 0; i < vulns.length(); i++) { + // get the singular vulnerability and create a + // VEXStatement for it + JSONObject vulnerability = vulns.getJSONObject(i) + .getJSONObject("cve"); + VEXStatement statement = + generateVEXStatement(vulnerability, + s, componentID); + vexStatements.add(statement); + } } } return vexStatements; @@ -183,31 +190,37 @@ public List getVEXStatements(SBOMPackage s, String key) throws Exc // if component has no cpes continue as we can only search if there are cpes if (cpes == null || cpes.isEmpty()) - throw new Exception("Component does not have CPEs " + - "to test with NVD API"); + return vexStatements; // use the cpes to search for vulnerabilities ArrayList cpesList = new ArrayList<>(cpes); String cpeString = cpesList.get(0); componentID = cpeString; - response = accessNVD(cpeString, key); + try { + response = accessNVD(cpeString, key); + } catch (Exception e) { + System.err.println("NVD API call failed for CPE " + cpeString + ": " + e.getMessage()); + return vexStatements; + } TimeUnit.SECONDS.sleep(waitTime); // check that the response was not null to find vulnerabilities - if (response != null) { + if (response != null && !response.isBlank()) { JSONObject jsonResponse = new JSONObject(response); - JSONArray vulns = new JSONArray( - jsonResponse.getJSONArray("vulnerabilities")); - for (int i = 0; i < vulns.length(); i++) { - // get the singular vulnerability and create a - // VEXStatement for it - JSONObject vulnerability = vulns.getJSONObject(i) - .getJSONObject("cve"); - VEXStatement statement = - generateVEXStatement(vulnerability, - s, componentID); - vexStatements.add(statement); + if (jsonResponse.has("vulnerabilities")) { + JSONArray vulns = new JSONArray( + jsonResponse.getJSONArray("vulnerabilities")); + for (int i = 0; i < vulns.length(); i++) { + // get the singular vulnerability and create a + // VEXStatement for it + JSONObject vulnerability = vulns.getJSONObject(i) + .getJSONObject("cve"); + VEXStatement statement = + generateVEXStatement(vulnerability, + s, componentID); + vexStatements.add(statement); + } } } return vexStatements; @@ -251,11 +264,9 @@ private VEXStatement generateVEXStatement(JSONObject vulnerabilityBody, // add component as the product of the statement String productID = c.getName() + ":" + c.getVersion(); - String supplier; - if (c.getSupplier() != null) { + String supplier = "Unknown"; + if (c.getSupplier() != null && c.getSupplier().getName() != null) { supplier = c.getSupplier().getName(); - } else { - supplier = "Unknown"; } Product product = new Product(productID, supplier); statement.addProduct(product); diff --git a/core/src/main/java/org/svip/vex/database/OSVClient.java b/core/src/main/java/org/svip/vex/database/OSVClient.java index 220710e8c..129acd206 100644 --- a/core/src/main/java/org/svip/vex/database/OSVClient.java +++ b/core/src/main/java/org/svip/vex/database/OSVClient.java @@ -71,20 +71,29 @@ private String getOSVResponse(String jsonBody) throws Exception { // build the post method request = HttpRequest.newBuilder() .uri(URI.create(POST_ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) .build(); // send the response and get the APIs response CompletableFuture> apiResponse = httpClient .sendAsync(request, HttpResponse.BodyHandlers.ofString()); - String responseBody = apiResponse.get().body(); + HttpResponse httpResponse = apiResponse.get(); + String responseBody = httpResponse.body(); - // check if error code appeared with response and throw error if true + // check for OSV error format (google rpc style) JSONObject jsonObject = new JSONObject(responseBody); if (jsonObject.has("code") && jsonObject.getInt("code") == 3) { - throw new Exception("Invalid call to OSV API"); + // Return empty array instead of throwing so caller can continue gracefully + return "[]"; } - return responseBody.replace("{\"vulns\":", ""); + + // extract vulns array safely + if (jsonObject.has("vulns")) { + return jsonObject.getJSONArray("vulns").toString(); + } + return "[]"; } @@ -96,11 +105,14 @@ private String getOSVResponse(String jsonBody) throws Exception { * @param componentVersion the component's version * @return the response from the OSV API request */ - private String getOSVByNameVersionPost(String componentName, String componentVersion) throws Exception { + private String getOSVByNameVersionPost(String componentName, String componentVersion, String ecosystem) throws Exception { JSONObject body = new JSONObject(); body.put("version", componentVersion); JSONObject packageJSON = new JSONObject(); packageJSON.put("name", componentName); + if (ecosystem != null && !ecosystem.isBlank()) { + packageJSON.put("ecosystem", ecosystem); + } body.put("package", packageJSON); return getOSVResponse(body.toString()); } @@ -120,6 +132,70 @@ private String getOSVByPURLPost(String purlString) throws Exception { return getOSVResponse(body.toString()); } + private String deriveEcosystemFromPurlType(String purlType) { + if (purlType == null) return null; + return switch (purlType.toLowerCase()) { + case "maven" -> "Maven"; + case "npm" -> "npm"; + case "pypi" -> "PyPI"; + case "golang", "go" -> "Go"; + case "gem", "rubygems" -> "RubyGems"; + case "cargo" -> "crates.io"; + case "nuget" -> "NuGet"; + case "composer" -> "Packagist"; + default -> null; + }; + } + + /** + * Validate if a component is suitable for OSV API analysis + * Filters out files that are clearly not packages (config files, git files, etc.) + * + * @param s the SBOM package to validate + * @return true if the component is a valid package for OSV analysis + */ + private boolean isValidPackageForOSV(SBOMPackage s) { + if (s == null || s.getName() == null) { + return false; + } + + String name = s.getName().toLowerCase(); + + // Skip clearly non-package files + if (name.startsWith(".git/") || name.startsWith(".github/") || + name.endsWith(".git") || name.endsWith(".gitignore") || + name.endsWith(".yml") || name.endsWith(".yaml") || + name.endsWith(".json") && !name.contains("package.json") || + name.endsWith(".md") || name.endsWith(".txt") || + name.endsWith(".xml") || name.endsWith(".properties") || + name.endsWith(".config") || name.endsWith(".ini") || + name.endsWith(".log") || name.endsWith(".lock") || + name.contains("node_modules") && name.endsWith(".js") || + name.contains("test") && (name.endsWith(".js") || name.endsWith(".ts")) || + name.equals(".") || name.equals("..") || + name.startsWith("LICENSE") || name.startsWith("README") || + name.endsWith(".pem") || name.endsWith(".crt") || + name.endsWith(".exe") || name.endsWith(".dll") || + name.endsWith(".so") || name.endsWith(".dylib")) { + return false; + } + + // Additional checks for known package patterns + Set purls = s.getPURLs(); + if (purls != null && !purls.isEmpty()) { + // If it has valid PURLs, it's likely a real package + return true; + } + + // If it has both name and version, it might be a valid package + if (s.getName() != null && s.getVersion() != null && + !s.getVersion().isEmpty() && !s.getVersion().equals("unknown")) { + return true; + } + + return false; + } + /** * Get all vulnerabilities of a component using the OSV API * @@ -132,6 +208,13 @@ private String getOSVByPURLPost(String purlString) throws Exception { public List getVEXStatements(SBOMPackage s) throws Exception { List vexStatements = new ArrayList<>(); String response; + + // Skip processing files that are clearly not packages + if (!isValidPackageForOSV(s)) { + // Not a valid package for OSV analysis — skip silently + return vexStatements; + } + // check that component is not an SPDX23File, as it does not // have the necessary fields to search for vulnerabilities // cast to SBOMPackage to check for purls @@ -142,23 +225,50 @@ public List getVEXStatements(SBOMPackage s) throws Exception { // vulnerabilities ArrayList purlList = new ArrayList<>(purls); String purlString = purlList.get(0); - response = getOSVByPURLPost(purlString); + try { + response = getOSVByPURLPost(purlString); + } catch (Exception e) { + // If OSV API call fails, log and return empty list instead of throwing + System.err.println("OSV API call failed for PURL " + purlString + ": " + e.getMessage()); + return vexStatements; + } } // if component has no purls, construct API request with // name and version else if (s.getName() != null && s.getVersion() != null) { String name = s.getName(); String version = s.getVersion(); - response = getOSVByNameVersionPost(name, version); - // some components require its group and name to search - // for vulnerabilities - if (response.equals("{}")) { - name = s.getAuthor() + ":" + s.getName(); - response = getOSVByNameVersionPost(name, version); + // attempt to derive ecosystem from supplier or author hints + String ecosystem = null; + try { + // try to infer from supplier URL or name patterns if available + // leave null if unknown (OSV may reject; will be caught below) + if (s.getPURLs() != null && !s.getPURLs().isEmpty()) { + String anyPurl = new ArrayList<>(s.getPURLs()).get(0); + int idx = anyPurl.indexOf(":" ); + if (idx > -1) { + String purlType = anyPurl.substring(4, idx); // after "pkg:" + ecosystem = deriveEcosystemFromPurlType(purlType); + } + } + } catch (Exception ignored) {} + + try { + response = getOSVByNameVersionPost(name, version, ecosystem); + // some components require its group and name to search + // for vulnerabilities + if (response.equals("{}") && s.getAuthor() != null) { + name = s.getAuthor() + ":" + s.getName(); + response = getOSVByNameVersionPost(name, version, ecosystem); + } + } catch (Exception e) { + // If OSV API call fails, log and return empty list instead of throwing + System.err.println("OSV API call failed for package " + name + ":" + version + ": " + e.getMessage()); + return vexStatements; } } else { - throw new Exception("Component does not have necessary fields " + - "to test with OSV API"); + // Missing fields for OSV analysis — skip gracefully + return vexStatements; } // if jsonResponse did not have an error and is not empty, // create a vex statement for every vulnerability in response @@ -220,7 +330,10 @@ private VEXStatement generateVEXStatement(JSONObject vulnerabilityBody, SBOMPack .getString("details"), "N/A")); //Get all products and add all to the VEX Statement - String supplier = c.getSupplier().getName(); + String supplier = "Unknown"; + if (c.getSupplier() != null && c.getSupplier().getName() != null) { + supplier = c.getSupplier().getName(); + } JSONArray packages = vulnerabilityBody.getJSONArray("affected"); // for every package in the JSONArray for (int i = 0; i < packages.length(); i++) {