From 3691d8070e2fabe12f7ea4a2b6eb8b057ad233b2 Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Sat, 16 Aug 2025 08:46:26 -1000 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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++) { From 7d0783be4275959f603ba0a48ee0c06cad87deec Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Sat, 25 Oct 2025 15:17:26 -1000 Subject: [PATCH 04/11] Add application.properties and clean up ConflictFile entity Added application.properties for configuring database, Hibernate, file upload limits, and OSI endpoint. Removed unused Hibernate OnDelete annotation from ConflictFile entity to simplify JPA mapping. --- api/bin/main/application.properties | 13 +++++++++++++ .../org/svip/api/entities/diff/ConflictFile.java | 3 --- 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 api/bin/main/application.properties diff --git a/api/bin/main/application.properties b/api/bin/main/application.properties new file mode 100644 index 000000000..b22c55549 --- /dev/null +++ b/api/bin/main/application.properties @@ -0,0 +1,13 @@ +# TODO: Figure out how to use environment variables when running detached API +# Configure JDBC MySQL instance source +spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:sbox}?autoReconnect=true +spring.datasource.username=${MYSQL_USER} +spring.datasource.password=${MYSQL_PASSWORD} +# Configure Hibernate +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect +spring.jpa.hibernate.ddl-auto=update +# configure MultipartFile upload size +spring.servlet.multipart.max-file-size=2048MB +spring.servlet.multipart.max-request-size=2048MB +# configure OSI endpoint +osi.api.url=http://${OSI_HOST:localhost}:${OSI_PORT:5000} \ No newline at end of file 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 192b7e67b..480352db9 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 @@ -28,8 +28,6 @@ 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; /** @@ -71,7 +69,6 @@ public class ConflictFile { // source comparison @ManyToOne - @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "comparison_id", nullable = false) @JsonBackReference("comparison-conflicts") private ComparisonFile comparison; From 993ae9d7a47833439323505a3d6cfc4374bb5ca8 Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Mon, 27 Oct 2025 23:31:05 -1000 Subject: [PATCH 05/11] (feat):Improve null and empty checks in deserializers Added checks to skip dependencies and references that are null, missing, empty, or the string 'null' in both CDX14JSONDeserializer and CDX14XMLDeserializer. This prevents invalid relationships from being added during SBOM deserialization. --- .../deserializer/CDX14JSONDeserializer.java | 21 ++++++++++++++++--- .../deserializer/CDX14XMLDeserializer.java | 21 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/org/svip/serializers/deserializer/CDX14JSONDeserializer.java b/core/src/main/java/org/svip/serializers/deserializer/CDX14JSONDeserializer.java index f31307c19..836b9ee99 100644 --- a/core/src/main/java/org/svip/serializers/deserializer/CDX14JSONDeserializer.java +++ b/core/src/main/java/org/svip/serializers/deserializer/CDX14JSONDeserializer.java @@ -147,7 +147,16 @@ public CDX14SBOM deserialize(JsonParser jsonParser, DeserializationContext conte if (node.get("dependencies") != null) for (JsonNode depNode : node.get("dependencies")) { - String ref = depNode.get("ref").asText(); + // Skip if ref is null or missing + JsonNode refNode = depNode.get("ref"); + if (refNode == null || refNode.isNull()) { + continue; + } + String ref = refNode.asText(); + // Skip empty or "null" string refs + if (ref == null || ref.isEmpty() || "null".equalsIgnoreCase(ref)) { + continue; + } resolveDependency(depNode).forEach(d -> sbomBuilder.addRelationship(ref, d)); } @@ -363,8 +372,14 @@ private Set resolveDependency(JsonNode dep) { if (dep.get("dependsOn") == null) return relationships; for (JsonNode dependency : dep.get("dependsOn")) { - if (dependency == null) continue; - relationships.add(new Relationship(dependency.asText(), "DEPENDS_ON")); // TODO correct type? + // Skip if dependency is null or a JSON null node + if (dependency == null || dependency.isNull()) continue; + + String depText = dependency.asText(); + // Skip empty or "null" string dependencies + if (depText == null || depText.isEmpty() || "null".equalsIgnoreCase(depText)) continue; + + relationships.add(new Relationship(depText, "DEPENDS_ON")); } return relationships; diff --git a/core/src/main/java/org/svip/serializers/deserializer/CDX14XMLDeserializer.java b/core/src/main/java/org/svip/serializers/deserializer/CDX14XMLDeserializer.java index 2dcc609a1..853eebede 100644 --- a/core/src/main/java/org/svip/serializers/deserializer/CDX14XMLDeserializer.java +++ b/core/src/main/java/org/svip/serializers/deserializer/CDX14XMLDeserializer.java @@ -160,7 +160,16 @@ public CDX14SBOM deserialize(JsonParser jsonParser, DeserializationContext conte if (node.get("dependencies") != null && node.get("dependencies").asText() != "") for (JsonNode depNode : node.get("dependencies").get("dependency")) { - String ref = depNode.get("ref").asText(); + // Skip if ref is null or missing + JsonNode refNode = depNode.get("ref"); + if (refNode == null || refNode.isNull()) { + continue; + } + String ref = refNode.asText(); + // Skip empty or "null" string refs + if (ref == null || ref.isEmpty() || "null".equalsIgnoreCase(ref)) { + continue; + } resolveDependency(depNode).forEach(d -> sbomBuilder.addRelationship(ref, d)); } @@ -386,8 +395,14 @@ private Set resolveDependency(JsonNode dep) { if (dep.get("dependsOn") == null) return relationships; for (JsonNode dependency : dep.get("dependsOn")) { - if (dependency == null) continue; - relationships.add(new Relationship(dependency.asText(), "DEPENDS_ON")); // TODO correct type? + // Skip if dependency is null or a JSON null node + if (dependency == null || dependency.isNull()) continue; + + String depText = dependency.asText(); + // Skip empty or "null" string dependencies + if (depText == null || depText.isEmpty() || "null".equalsIgnoreCase(depText)) continue; + + relationships.add(new Relationship(depText, "DEPENDS_ON")); } return relationships; From a5f28ae14fe5c37c8faccdfb3df9556b0d2a54ac Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Fri, 31 Oct 2025 02:22:14 -1000 Subject: [PATCH 06/11] feat(api): integrate vulnerability scanning with dashboard tracking - Install vulnerability scanners in API container (Grype, Trivy, OSV Scanner) - Change Dockerfile base from Alpine to Debian for binary compatibility - Add scanner installation steps with version pinning - Implement VulnerabilityScanService for direct scanner execution - Run scanners in parallel via ProcessBuilder - Add --by-cve flag for Grype to prefer CVE over GHSA IDs - Add --scanners vuln flag for Trivy - Merge vulnerability results into SBOM JSON - Create vulnerability tracking system - Add VulnerabilityController with REST endpoints - Add VulnerabilityHistoryService for trend analysis - Add VulnerabilityAlertRepository for dashboard alerts - Add VulnerabilityHistory and VulnerabilityAlert entities - Fix VulnerabilityHistoryService.getAllProjects() to only return active SBOMs - Add JavaTimeModule to JacksonConfig for LocalDateTime serialization - Fix CDX14JSONSerializer by removing invalid releaseNotes string field - Fix OSVClient to handle missing JSON fields (aliases, published, affected) - Add VEX cache invalidation to auto-regenerate on errors - Update OSIController to run vulnerability scans and record history BREAKING CHANGE: Changes API container base image and adds new database entities --- Dockerfile | 20 +- api/bin/main/application.properties | 5 +- .../java/org/svip/api/config/AsyncConfig.java | 64 ++++ .../org/svip/api/config/JacksonConfig.java | 7 + .../svip/api/controller/OSIController.java | 78 +++- .../svip/api/controller/VEXController.java | 20 +- .../controller/VulnerabilityController.java | 83 ++++ .../svip/api/entities/VulnerabilityAlert.java | 194 ++++++++++ .../api/entities/VulnerabilityHistory.java | 185 +++++++++ .../VulnerabilityAlertRepository.java | 83 ++++ .../VulnerabilityHistoryRepository.java | 94 +++++ .../org/svip/api/services/VEXFileService.java | 9 + .../services/VulnerabilityHistoryService.java | 200 ++++++++++ .../services/VulnerabilityScanService.java | 354 ++++++++++++++++++ api/src/main/resources/application.properties | 5 +- .../serializer/CDX14JSONSerializer.java | 5 +- .../java/org/svip/vex/database/OSVClient.java | 82 ++-- osi/Dockerfile | 26 +- osi/osi/configs/tools/dependency-check.yml | 27 ++ osi/osi/configs/tools/grype.yml | 23 ++ osi/osi/configs/tools/osv-scanner.yml | 15 + osi/osi/configs/tools/trivy.yml | 23 ++ osi/scripts/validate.sh | 6 + 23 files changed, 1565 insertions(+), 43 deletions(-) create mode 100644 api/src/main/java/org/svip/api/config/AsyncConfig.java create mode 100644 api/src/main/java/org/svip/api/controller/VulnerabilityController.java create mode 100644 api/src/main/java/org/svip/api/entities/VulnerabilityAlert.java create mode 100644 api/src/main/java/org/svip/api/entities/VulnerabilityHistory.java create mode 100644 api/src/main/java/org/svip/api/repository/VulnerabilityAlertRepository.java create mode 100644 api/src/main/java/org/svip/api/repository/VulnerabilityHistoryRepository.java create mode 100644 api/src/main/java/org/svip/api/services/VulnerabilityHistoryService.java create mode 100644 api/src/main/java/org/svip/api/services/VulnerabilityScanService.java create mode 100644 osi/osi/configs/tools/dependency-check.yml create mode 100644 osi/osi/configs/tools/grype.yml create mode 100644 osi/osi/configs/tools/osv-scanner.yml create mode 100644 osi/osi/configs/tools/trivy.yml diff --git a/Dockerfile b/Dockerfile index 2b54325e4..fff772bd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,9 +14,23 @@ COPY --chown=gradle:gradle settings.gradle . RUN gradle build --no-daemon -x test -FROM eclipse-temurin:21-jre-alpine-3.21 AS runtime -# create user -RUN adduser -H -D sbox +FROM eclipse-temurin:21-jre-jammy AS runtime +# Install vulnerability scanners +USER root +RUN apt-get update && apt-get install -y curl wget ca-certificates +# Install Grype +ARG GRYPE_VERSION=0.84.0 +RUN curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v${GRYPE_VERSION} +# Install Trivy +ARG TRIVY_VERSION=0.58.1 +RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v${TRIVY_VERSION} +# Install OSV Scanner - using install script +RUN curl -L https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64 -o /usr/local/bin/osv-scanner && chmod +x /usr/local/bin/osv-scanner || echo "OSV Scanner installation failed, continuing without it" +# Cleanup +RUN apt-get clean && rm -rf /var/lib/apt/lists/* + +# create user (Debian/Ubuntu syntax) +RUN useradd -m -s /bin/bash sbox USER sbox # copy jar WORKDIR /app diff --git a/api/bin/main/application.properties b/api/bin/main/application.properties index b22c55549..d0c8f663b 100644 --- a/api/bin/main/application.properties +++ b/api/bin/main/application.properties @@ -10,4 +10,7 @@ spring.jpa.hibernate.ddl-auto=update spring.servlet.multipart.max-file-size=2048MB spring.servlet.multipart.max-request-size=2048MB # configure OSI endpoint -osi.api.url=http://${OSI_HOST:localhost}:${OSI_PORT:5000} \ No newline at end of file +osi.api.url=http://${OSI_HOST:localhost}:${OSI_PORT:5000} +# configure vulnerability scanning +vulnerability.scan.enabled=${VULN_SCAN_ENABLED:true} +vulnerability.scan.tools=${VULN_SCAN_TOOLS:grype,trivy} \ No newline at end of file diff --git a/api/src/main/java/org/svip/api/config/AsyncConfig.java b/api/src/main/java/org/svip/api/config/AsyncConfig.java new file mode 100644 index 000000000..ac37fa306 --- /dev/null +++ b/api/src/main/java/org/svip/api/config/AsyncConfig.java @@ -0,0 +1,64 @@ +/** + * 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.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * File: AsyncConfig.java + * Configuration for asynchronous task execution, specifically for parallel vulnerability scanning + * + * @author Ibrahim Matar + */ +@Configuration +@EnableAsync +public class AsyncConfig { + + /** + * Executor service for parallel vulnerability scanning + * Uses virtual threads (Java 21+) if available, otherwise fixed thread pool + * + * @return Executor for vulnerability scanning tasks + */ + @Bean(name = "vulnerabilityScanExecutor") + public Executor vulnerabilityScanExecutor() { + // Use virtual threads if available (Java 21+), otherwise use fixed thread pool + try { + // Try to use virtual threads (more efficient for I/O-bound tasks like scanning) + return Executors.newVirtualThreadPerTaskExecutor(); + } catch (UnsupportedOperationException e) { + // Fall back to fixed thread pool for Java 17/19 + return Executors.newFixedThreadPool(4); + } + } +} + + + diff --git a/api/src/main/java/org/svip/api/config/JacksonConfig.java b/api/src/main/java/org/svip/api/config/JacksonConfig.java index f4817fa92..4cba91ee0 100644 --- a/api/src/main/java/org/svip/api/config/JacksonConfig.java +++ b/api/src/main/java/org/svip/api/config/JacksonConfig.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.StreamWriteConstraints; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -21,10 +22,16 @@ public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(factory); + // Register Java 8 date/time module for LocalDateTime support + mapper.registerModule(new JavaTimeModule()); + // Configure to handle circular references globally mapper.configure(SerializationFeature.FAIL_ON_SELF_REFERENCES, false); mapper.configure(SerializationFeature.WRITE_SELF_REFERENCES_AS_NULL, true); + // Disable writing dates as timestamps (use ISO-8601 strings instead) + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; } } 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 eeae59875..35771b515 100644 --- a/api/src/main/java/org/svip/api/controller/OSIController.java +++ b/api/src/main/java/org/svip/api/controller/OSIController.java @@ -37,6 +37,8 @@ import org.svip.api.requests.UploadSBOMFileInput; import org.svip.api.services.OSIService; import org.svip.api.services.SBOMFileService; +import org.svip.api.services.VulnerabilityScanService; +import com.fasterxml.jackson.databind.JsonNode; import org.svip.conversion.ConversionException; import org.svip.sbom.builder.SBOMBuilderException; import org.svip.serializers.SerializerFactory; @@ -65,21 +67,36 @@ public class OSIController { // Services private final SBOMFileService sbomService; private final OSIService osiService; + private final VulnerabilityScanService vulnerabilityScanService; + private final org.svip.api.services.VulnerabilityHistoryService vulnerabilityHistoryService; /** * Create new Controller with services * * @param sbomService Service for handling SBOM queries + * @param osiService Service for handling OSI operations + * @param vulnerabilityScanService Service for vulnerability scanning + * @param vulnerabilityHistoryService Service for historical tracking */ - public OSIController(SBOMFileService sbomService, OSIService osiService) { + public OSIController(SBOMFileService sbomService, OSIService osiService, + VulnerabilityScanService vulnerabilityScanService, + org.svip.api.services.VulnerabilityHistoryService vulnerabilityHistoryService) { this.sbomService = sbomService; this.osiService = osiService; + this.vulnerabilityScanService = vulnerabilityScanService; + this.vulnerabilityHistoryService = vulnerabilityHistoryService; if (this.osiService.isEnabled()) { LOGGER.info("OSI ENDPOINT ENABLED"); } else { LOGGER.warn("OSI ENDPOINT DISABLED -- Unable to communicate with OSI container; Is the container running?"); } + + if (this.vulnerabilityScanService.isEnabled()) { + LOGGER.info("VULNERABILITY SCANNING ENABLED"); + } else { + LOGGER.info("VULNERABILITY SCANNING DISABLED"); + } } /// @@ -168,7 +185,9 @@ public ResponseEntity generateWithOSI(@RequestParam("projectName") String pro if (toolNamesJson != null && !toolNamesJson.isBlank()) { try { ObjectMapper mapper = new ObjectMapper(); - tools = mapper.readValue(toolNamesJson, List.class); + @SuppressWarnings("unchecked") + List parsedTools = mapper.readValue(toolNamesJson, List.class); + tools = parsedTools; } catch (Exception parseEx) { LOGGER.warn("POST /svip/generators/osi - Invalid toolNames JSON; defaulting to project tools. Error: {}", parseEx.getMessage()); tools = this.osiService.getTools("project"); @@ -258,6 +277,61 @@ public ResponseEntity generateWithOSI(@RequestParam("projectName") String pro return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } + // VULNERABILITY SCANNING - Enrich SBOM with vulnerability data + if (vulnerabilityScanService.isEnabled()) { + try { + LOGGER.info("POST /svip/generators/osi - Running vulnerability scans"); + SBOMFile sbomToScan = sbomService.getSBOMFile(convertedID); + if (sbomToScan != null) { + String enrichedContents = vulnerabilityScanService.runVulnerabilityScans( + sbomToScan.getContent(), + sbomToScan.getName() + ); + + // Save enriched SBOM + UploadSBOMFileInput enrichedInput = new UploadSBOMFileInput( + sbomToScan.getName().replace(".json", "-with-vulns.json"), + enrichedContents + ); + SBOMFile enrichedSbom = enrichedInput.toSBOMFile(); + sbomService.upload(enrichedSbom); + + // Delete old SBOM and use enriched one + sbomService.deleteSBOMFile(sbomToScan); + convertedID = enrichedSbom.getId(); + + LOGGER.info("POST /svip/generators/osi - Successfully enriched SBOM with vulnerabilities (ID: {})", convertedID); + + // Record vulnerability history for dashboard tracking + try { + JsonNode sbomJson = new ObjectMapper().readTree(enrichedContents); + if (sbomJson.has("vulnerabilities") && sbomJson.get("vulnerabilities").isArray()) { + java.util.List vulns = new java.util.ArrayList<>(); + sbomJson.get("vulnerabilities").forEach(vulns::add); + + vulnerabilityHistoryService.recordVulnerabilities( + convertedID, + projectName, + enrichedSbom.getName(), + vulns, + "grype,trivy,osv-scanner" + ); + LOGGER.info("POST /svip/generators/osi - Recorded vulnerability history"); + } + } catch (Exception histEx) { + LOGGER.warn("POST /svip/generators/osi - Failed to record vulnerability history: {}", histEx.getMessage()); + } + } else { + LOGGER.warn("POST /svip/generators/osi - Could not find SBOM for vulnerability scanning"); + } + } catch (Exception e) { + // Log warning but don't fail the entire request + LOGGER.warn("POST /svip/generators/osi - Vulnerability scanning failed (continuing without vulnerabilities): " + e.getMessage()); + } + } else { + LOGGER.info("POST /svip/generators/osi - Vulnerability scanning is disabled, skipping"); + } + // Set descriptive filename: ProjectName-OSI-Schema-Format-Timestamp.ext try { String extension; diff --git a/api/src/main/java/org/svip/api/controller/VEXController.java b/api/src/main/java/org/svip/api/controller/VEXController.java index f858c61fb..38e275463 100644 --- a/api/src/main/java/org/svip/api/controller/VEXController.java +++ b/api/src/main/java/org/svip/api/controller/VEXController.java @@ -94,11 +94,25 @@ public ResponseEntity vex(@RequestHeader(value = "apiKey", required = fa return new ResponseEntity<>(HttpStatus.NO_CONTENT); } - // Get stored content + // Get stored content (unless it has errors, then regenerate) // todo more than 1 vex stored? Ie 1 from nvd, could run for osv // todo POST / arg to force rerun vex?? - if (sbomFile.getVEXFile() != null) - return new ResponseEntity<>(sbomFile.getVEXFile().getContent(), HttpStatus.OK); + if (sbomFile.getVEXFile() != null) { + String cachedContent = sbomFile.getVEXFile().getContent(); + // Check if cached VEX has errors; if so, regenerate + try { + if (cachedContent != null && !cachedContent.contains("\"error\":{}") && + !cachedContent.contains("JSONObject")) { + return new ResponseEntity<>(cachedContent, HttpStatus.OK); + } + // Has errors or issues, delete and regenerate + LOGGER.info("VEX /svip/sboms/vex?id=" + id + " - Cached VEX has errors, regenerating"); + this.vexFileService.delete(sbomFile.getVEXFile()); + sbomFile.setVEXFile(null); + } catch (Exception e) { + LOGGER.warn("VEX /svip/sboms/vex?id=" + id + " - Error checking cached VEX: " + e.getMessage()); + } + } // No VEX stored, generate one VEXResult vexResult = this.vexFileService.generateVEX(sbomFile.toSBOMObject(), client, format, apiKey); diff --git a/api/src/main/java/org/svip/api/controller/VulnerabilityController.java b/api/src/main/java/org/svip/api/controller/VulnerabilityController.java new file mode 100644 index 000000000..90b1e68f2 --- /dev/null +++ b/api/src/main/java/org/svip/api/controller/VulnerabilityController.java @@ -0,0 +1,83 @@ +/** + * Copyright 2021 Rochester Institute of Technology (RIT). + * @author Ibrahim Matar + */ + +package org.svip.api.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.svip.api.entities.VulnerabilityAlert; +import org.svip.api.entities.VulnerabilityHistory; +import org.svip.api.repository.VulnerabilityAlertRepository; +import org.svip.api.services.VulnerabilityHistoryService; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/svip/vulnerabilities") +public class VulnerabilityController { + + private final VulnerabilityHistoryService historyService; + private final VulnerabilityAlertRepository alertRepository; + + public VulnerabilityController(VulnerabilityHistoryService historyService, + VulnerabilityAlertRepository alertRepository) { + this.historyService = historyService; + this.alertRepository = alertRepository; + } + + // Historical Tracking APIs + @GetMapping("/history/projects") + public ResponseEntity> getAllProjects() { + return ResponseEntity.ok(historyService.getAllProjects()); + } + + @GetMapping("/history/{projectName}") + public ResponseEntity> getHistory( + @PathVariable String projectName, + @RequestParam(defaultValue = "30") int days) { + return ResponseEntity.ok(historyService.getTrend(projectName, days)); + } + + @GetMapping("/history/{projectName}/latest") + public ResponseEntity getLatest(@PathVariable String projectName) { + return ResponseEntity.ok(historyService.getLatest(projectName)); + } + + // Alert APIs + @GetMapping("/alerts") + public ResponseEntity> getAllAlerts() { + return ResponseEntity.ok(alertRepository.findAllByOrderByCreatedAtDesc()); + } + + @GetMapping("/alerts/unacknowledged") + public ResponseEntity> getUnacknowledgedAlerts() { + return ResponseEntity.ok(alertRepository.findByAcknowledgedFalseOrderByCreatedAtDesc()); + } + + @GetMapping("/alerts/stats") + public ResponseEntity> getAlertStats() { + Map stats = new HashMap<>(); + stats.put("total_unacknowledged", alertRepository.countByAcknowledgedFalse()); + stats.put("critical_unacknowledged", alertRepository.countBySeverityAndAcknowledgedFalse("CRITICAL")); + stats.put("high_unacknowledged", alertRepository.countBySeverityAndAcknowledgedFalse("HIGH")); + return ResponseEntity.ok(stats); + } + + @PostMapping("/alerts/{alertId}/acknowledge") + public ResponseEntity acknowledgeAlert( + @PathVariable Long alertId, + @RequestParam(defaultValue = "system") String acknowledgedBy) { + alertRepository.findById(alertId).ifPresent(alert -> { + alert.acknowledge(acknowledgedBy); + alertRepository.save(alert); + }); + return ResponseEntity.ok().build(); + } +} + + + diff --git a/api/src/main/java/org/svip/api/entities/VulnerabilityAlert.java b/api/src/main/java/org/svip/api/entities/VulnerabilityAlert.java new file mode 100644 index 000000000..c370134e4 --- /dev/null +++ b/api/src/main/java/org/svip/api/entities/VulnerabilityAlert.java @@ -0,0 +1,194 @@ +/** + * 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.entities; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +/** + * File: VulnerabilityAlert.java + * Entity representing dashboard alerts for critical/high vulnerabilities + * + * @author Ibrahim Matar + */ +@Entity +@Table(name = "vulnerability_alerts", indexes = { + @Index(name = "idx_acknowledged", columnList = "acknowledged"), + @Index(name = "idx_severity", columnList = "severity"), + @Index(name = "idx_created_at", columnList = "createdAt") +}) +public class VulnerabilityAlert { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "sbom_id") + private Long sbomId; + + @Column(name = "project_name", nullable = false) + private String projectName; + + @Column(name = "sbom_name") + private String sbomName; + + @Column(name = "severity", nullable = false) + private String severity; // CRITICAL or HIGH + + @Column(name = "vuln_count") + private Integer vulnCount; + + @Column(name = "message", columnDefinition = "TEXT") + private String message; + + @Column(name = "acknowledged") + private Boolean acknowledged = false; + + @Column(name = "acknowledged_by") + private String acknowledgedBy; + + @Column(name = "acknowledged_at") + private LocalDateTime acknowledgedAt; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + // Constructors + public VulnerabilityAlert() { + this.createdAt = LocalDateTime.now(); + this.acknowledged = false; + } + + public VulnerabilityAlert(Long sbomId, String projectName, String sbomName, + String severity, Integer vulnCount, String message) { + this.sbomId = sbomId; + this.projectName = projectName; + this.sbomName = sbomName; + this.severity = severity; + this.vulnCount = vulnCount; + this.message = message; + this.createdAt = LocalDateTime.now(); + this.acknowledged = false; + } + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getSbomId() { + return sbomId; + } + + public void setSbomId(Long sbomId) { + this.sbomId = sbomId; + } + + public String getProjectName() { + return projectName; + } + + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public String getSbomName() { + return sbomName; + } + + public void setSbomName(String sbomName) { + this.sbomName = sbomName; + } + + public String getSeverity() { + return severity; + } + + public void setSeverity(String severity) { + this.severity = severity; + } + + public Integer getVulnCount() { + return vulnCount; + } + + public void setVulnCount(Integer vulnCount) { + this.vulnCount = vulnCount; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Boolean getAcknowledged() { + return acknowledged; + } + + public void setAcknowledged(Boolean acknowledged) { + this.acknowledged = acknowledged; + } + + public String getAcknowledgedBy() { + return acknowledgedBy; + } + + public void setAcknowledgedBy(String acknowledgedBy) { + this.acknowledgedBy = acknowledgedBy; + } + + public LocalDateTime getAcknowledgedAt() { + return acknowledgedAt; + } + + public void setAcknowledgedAt(LocalDateTime acknowledgedAt) { + this.acknowledgedAt = acknowledgedAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + // Helper methods + public void acknowledge(String acknowledgedBy) { + this.acknowledged = true; + this.acknowledgedBy = acknowledgedBy; + this.acknowledgedAt = LocalDateTime.now(); + } +} + + + diff --git a/api/src/main/java/org/svip/api/entities/VulnerabilityHistory.java b/api/src/main/java/org/svip/api/entities/VulnerabilityHistory.java new file mode 100644 index 000000000..fcf15304a --- /dev/null +++ b/api/src/main/java/org/svip/api/entities/VulnerabilityHistory.java @@ -0,0 +1,185 @@ +/** + * 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.entities; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +/** + * File: VulnerabilityHistory.java + * Entity representing a snapshot of vulnerability scan results for tracking trends + * + * @author Ibrahim Matar + */ +@Entity +@Table(name = "vulnerability_history", indexes = { + @Index(name = "idx_project_date", columnList = "projectName,scanDate"), + @Index(name = "idx_sbom_id", columnList = "sbomId") +}) +public class VulnerabilityHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "sbom_id") + private Long sbomId; + + @Column(name = "project_name", nullable = false) + private String projectName; + + @Column(name = "scan_date", nullable = false) + private LocalDateTime scanDate; + + @Column(name = "total_vulnerabilities") + private Integer totalVulnerabilities; + + @Column(name = "critical_count") + private Integer criticalCount; + + @Column(name = "high_count") + private Integer highCount; + + @Column(name = "medium_count") + private Integer mediumCount; + + @Column(name = "low_count") + private Integer lowCount; + + @Column(name = "scanners_used") + private String scannersUsed; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + // Constructors + public VulnerabilityHistory() { + this.createdAt = LocalDateTime.now(); + } + + public VulnerabilityHistory(Long sbomId, String projectName, int total, int critical, int high, int medium, int low) { + this.sbomId = sbomId; + this.projectName = projectName; + this.scanDate = LocalDateTime.now(); + this.totalVulnerabilities = total; + this.criticalCount = critical; + this.highCount = high; + this.mediumCount = medium; + this.lowCount = low; + this.createdAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getSbomId() { + return sbomId; + } + + public void setSbomId(Long sbomId) { + this.sbomId = sbomId; + } + + public String getProjectName() { + return projectName; + } + + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public LocalDateTime getScanDate() { + return scanDate; + } + + public void setScanDate(LocalDateTime scanDate) { + this.scanDate = scanDate; + } + + public Integer getTotalVulnerabilities() { + return totalVulnerabilities; + } + + public void setTotalVulnerabilities(Integer totalVulnerabilities) { + this.totalVulnerabilities = totalVulnerabilities; + } + + public Integer getCriticalCount() { + return criticalCount; + } + + public void setCriticalCount(Integer criticalCount) { + this.criticalCount = criticalCount; + } + + public Integer getHighCount() { + return highCount; + } + + public void setHighCount(Integer highCount) { + this.highCount = highCount; + } + + public Integer getMediumCount() { + return mediumCount; + } + + public void setMediumCount(Integer mediumCount) { + this.mediumCount = mediumCount; + } + + public Integer getLowCount() { + return lowCount; + } + + public void setLowCount(Integer lowCount) { + this.lowCount = lowCount; + } + + public String getScannersUsed() { + return scannersUsed; + } + + public void setScannersUsed(String scannersUsed) { + this.scannersUsed = scannersUsed; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} + + + diff --git a/api/src/main/java/org/svip/api/repository/VulnerabilityAlertRepository.java b/api/src/main/java/org/svip/api/repository/VulnerabilityAlertRepository.java new file mode 100644 index 000000000..1e1a1b64d --- /dev/null +++ b/api/src/main/java/org/svip/api/repository/VulnerabilityAlertRepository.java @@ -0,0 +1,83 @@ +/** + * 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.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.svip.api.entities.VulnerabilityAlert; + +import java.util.List; + +/** + * File: VulnerabilityAlertRepository.java + * Repository for vulnerability alert data access + * + * @author Ibrahim Matar + */ +@Repository +public interface VulnerabilityAlertRepository extends JpaRepository { + + /** + * Find all unacknowledged alerts ordered by creation date (newest first) + * + * @return List of unacknowledged alerts + */ + List findByAcknowledgedFalseOrderByCreatedAtDesc(); + + /** + * Find all alerts ordered by creation date (newest first) + * + * @return List of all alerts + */ + List findAllByOrderByCreatedAtDesc(); + + /** + * Find alerts by severity and acknowledged status + * + * @param severity Severity level + * @param acknowledged Acknowledged status + * @return List of alerts + */ + List findBySeverityAndAcknowledgedOrderByCreatedAtDesc( + String severity, Boolean acknowledged); + + /** + * Count unacknowledged alerts + * + * @return Number of unacknowledged alerts + */ + Long countByAcknowledgedFalse(); + + /** + * Count unacknowledged alerts by severity + * + * @param severity Severity level + * @return Number of unacknowledged alerts with given severity + */ + Long countBySeverityAndAcknowledgedFalse(String severity); +} + + + diff --git a/api/src/main/java/org/svip/api/repository/VulnerabilityHistoryRepository.java b/api/src/main/java/org/svip/api/repository/VulnerabilityHistoryRepository.java new file mode 100644 index 000000000..d554a5fad --- /dev/null +++ b/api/src/main/java/org/svip/api/repository/VulnerabilityHistoryRepository.java @@ -0,0 +1,94 @@ +/** + * 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.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.svip.api.entities.VulnerabilityHistory; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * File: VulnerabilityHistoryRepository.java + * Repository for vulnerability history data access + * + * @author Ibrahim Matar + */ +@Repository +public interface VulnerabilityHistoryRepository extends JpaRepository { + + /** + * Find all history records for a specific project + * + * @param projectName Project name + * @return List of vulnerability history records + */ + List findByProjectNameOrderByScanDateDesc(String projectName); + + /** + * Find history records for a project within a date range + * + * @param projectName Project name + * @param startDate Start date + * @param endDate End date + * @return List of vulnerability history records + */ + List findByProjectNameAndScanDateBetweenOrderByScanDateDesc( + String projectName, LocalDateTime startDate, LocalDateTime endDate); + + /** + * Find the most recent history record for a project + * + * @param projectName Project name + * @return Most recent vulnerability history record + */ + @Query("SELECT vh FROM VulnerabilityHistory vh WHERE vh.projectName = :projectName ORDER BY vh.scanDate DESC") + List findLatestByProjectNameList(@Param("projectName") String projectName); + + default VulnerabilityHistory findLatestByProjectName(String projectName) { + List results = findLatestByProjectNameList(projectName); + return results.isEmpty() ? null : results.get(0); + } + + /** + * Find all unique project names + * + * @return List of project names + */ + @Query("SELECT DISTINCT vh.projectName FROM VulnerabilityHistory vh ORDER BY vh.projectName") + List findAllProjectNames(); + + /** + * Find history by SBOM ID + * + * @param sbomId SBOM ID + * @return Vulnerability history record + */ + VulnerabilityHistory findBySbomId(Long sbomId); +} + diff --git a/api/src/main/java/org/svip/api/services/VEXFileService.java b/api/src/main/java/org/svip/api/services/VEXFileService.java index e1361a65d..eaf16ca56 100644 --- a/api/src/main/java/org/svip/api/services/VEXFileService.java +++ b/api/src/main/java/org/svip/api/services/VEXFileService.java @@ -79,6 +79,15 @@ public VEXFile upload(VEXFile vf) throws Exception { } } + /** + * Delete a VEX file from the database + * + * @param vf VEX file to delete + */ + public void delete(VEXFile vf) { + this.vexFileRepository.delete(vf); + } + /** * Generate VEX for a given SBOM * diff --git a/api/src/main/java/org/svip/api/services/VulnerabilityHistoryService.java b/api/src/main/java/org/svip/api/services/VulnerabilityHistoryService.java new file mode 100644 index 000000000..db3a9b583 --- /dev/null +++ b/api/src/main/java/org/svip/api/services/VulnerabilityHistoryService.java @@ -0,0 +1,200 @@ +/** + * 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. + */ + +package org.svip.api.services; + +import com.fasterxml.jackson.databind.JsonNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.svip.api.entities.SBOMFile; +import org.svip.api.entities.VulnerabilityAlert; +import org.svip.api.entities.VulnerabilityHistory; +import org.svip.api.repository.SBOMFileRepository; +import org.svip.api.repository.VulnerabilityAlertRepository; +import org.svip.api.repository.VulnerabilityHistoryRepository; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * File: VulnerabilityHistoryService.java + * Service for tracking and analyzing vulnerability trends over time + * + * @author Ibrahim Matar + */ +@Service +public class VulnerabilityHistoryService { + + private static final Logger LOGGER = LoggerFactory.getLogger(VulnerabilityHistoryService.class); + + private final VulnerabilityHistoryRepository historyRepository; + private final VulnerabilityAlertRepository alertRepository; + private final SBOMFileRepository sbomFileRepository; + + public VulnerabilityHistoryService(VulnerabilityHistoryRepository historyRepository, + VulnerabilityAlertRepository alertRepository, + SBOMFileRepository sbomFileRepository) { + this.historyRepository = historyRepository; + this.alertRepository = alertRepository; + this.sbomFileRepository = sbomFileRepository; + } + + /** + * Record vulnerability scan results for historical tracking + * + * @param sbomId SBOM ID + * @param projectName Project name + * @param sbomName SBOM filename + * @param vulnerabilities List of vulnerability nodes from SBOM + * @param scannersUsed Comma-separated list of scanners used + */ + public void recordVulnerabilities(Long sbomId, String projectName, String sbomName, + List vulnerabilities, String scannersUsed) { + try { + // Count vulnerabilities by severity + Map counts = countBySeverity(vulnerabilities); + + // Create history record + VulnerabilityHistory history = new VulnerabilityHistory( + sbomId, + projectName, + vulnerabilities.size(), + counts.get("CRITICAL"), + counts.get("HIGH"), + counts.get("MEDIUM"), + counts.get("LOW") + ); + history.setScannersUsed(scannersUsed); + history.setScanDate(LocalDateTime.now()); + + historyRepository.save(history); + + LOGGER.info("Recorded vulnerability history for {}: {} total vulnerabilities ({} critical, {} high)", + projectName, vulnerabilities.size(), counts.get("CRITICAL"), counts.get("HIGH")); + + // Create alerts for critical/high vulnerabilities + if (counts.get("CRITICAL") > 0 || counts.get("HIGH") > 0) { + createAlerts(sbomId, projectName, sbomName, counts); + } + + } catch (Exception e) { + LOGGER.error("Failed to record vulnerability history: {}", e.getMessage()); + } + } + + /** + * Count vulnerabilities by severity level + */ + private Map countBySeverity(List vulnerabilities) { + Map counts = new HashMap<>(); + counts.put("CRITICAL", 0); + counts.put("HIGH", 0); + counts.put("MEDIUM", 0); + counts.put("LOW", 0); + + for (JsonNode vuln : vulnerabilities) { + if (vuln.has("ratings") && vuln.get("ratings").isArray()) { + JsonNode rating = vuln.get("ratings").get(0); + if (rating != null && rating.has("severity")) { + String severity = rating.get("severity").asText().toUpperCase(); + counts.put(severity, counts.getOrDefault(severity, 0) + 1); + } + } + } + + return counts; + } + + /** + * Create dashboard alerts for critical/high vulnerabilities + */ + private void createAlerts(Long sbomId, String projectName, String sbomName, Map counts) { + // Prefer human-friendly project name in alert messages + String displayTarget = (projectName != null && !projectName.isBlank()) ? projectName : sbomName; + + if (counts.get("CRITICAL") > 0) { + VulnerabilityAlert alert = new VulnerabilityAlert( + sbomId, + projectName, + sbomName, + "CRITICAL", + counts.get("CRITICAL"), + String.format("%d critical vulnerabilities found in %s", counts.get("CRITICAL"), displayTarget) + ); + alertRepository.save(alert); + } + + if (counts.get("HIGH") > 0) { + VulnerabilityAlert alert = new VulnerabilityAlert( + sbomId, + projectName, + sbomName, + "HIGH", + counts.get("HIGH"), + String.format("%d high severity vulnerabilities found in %s", counts.get("HIGH"), displayTarget) + ); + alertRepository.save(alert); + } + } + + /** + * Get vulnerability trend for a project + */ + public List getTrend(String projectName, int days) { + LocalDateTime since = LocalDateTime.now().minusDays(days); + return historyRepository.findByProjectNameAndScanDateBetweenOrderByScanDateDesc( + projectName, since, LocalDateTime.now()); + } + + /** + * Get all unique project names from ACTIVE SBOMs only + * Does NOT include deleted/historical projects + */ + public List getAllProjects() { + List activeSboms = new ArrayList<>(); + sbomFileRepository.findAll().forEach(activeSboms::add); + + // Extract unique project names from active SBOMs only + return activeSboms.stream() + .map(sbom -> extractProjectName(sbom.getName())) + .filter(Objects::nonNull) + .distinct() + .sorted() + .collect(Collectors.toList()); + } + + /** + * Extract project name from SBOM filename + * Example: "precipitation_application-OSI-CDX14-JSON-20251028-123456.json" -> "precipitation_application" + */ + private String extractProjectName(String fileName) { + if (fileName == null || fileName.isEmpty()) return null; + + // Remove path separators (handle Windows paths like C:\...) + String baseName = fileName.replaceAll(".*[/\\\\]", ""); + + // Remove extension + String nameWithoutExt = baseName.replaceAll("\\.(json|xml|spdx)$", ""); + + // Extract part before -OSI- or first timestamp pattern + if (nameWithoutExt.contains("-OSI-")) { + return nameWithoutExt.split("-OSI-")[0]; + } + + // Fallback: remove timestamp pattern (YYYYMMDD-HHMMSS) + return nameWithoutExt.replaceAll("-\\d{8}-\\d{6}$", ""); + } + + /** + * Get latest scan for a project + */ + public VulnerabilityHistory getLatest(String projectName) { + return historyRepository.findLatestByProjectName(projectName); + } +} + diff --git a/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java new file mode 100644 index 000000000..eb988cc38 --- /dev/null +++ b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java @@ -0,0 +1,354 @@ +/** + * 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.services; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Qualifier; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.stream.Collectors; + +/** + * File: VulnerabilityScanService.java + * Service for running vulnerability scans on SBOMs using multiple scanning tools + * + * @author Ibrahim Matar + */ +@Component +public class VulnerabilityScanService { + private static final Logger LOGGER = LoggerFactory.getLogger(VulnerabilityScanService.class); + + @Value("${vulnerability.scan.enabled:true}") + private boolean scanningEnabled; + + @Value("${vulnerability.scan.tools:grype,trivy}") + private String scanTools; + + private final ObjectMapper objectMapper; + private final Executor vulnerabilityScanExecutor; + + public VulnerabilityScanService(@Qualifier("vulnerabilityScanExecutor") Executor vulnerabilityScanExecutor) { + this.objectMapper = new ObjectMapper(); + this.vulnerabilityScanExecutor = vulnerabilityScanExecutor; + } + + /** + * Check if vulnerability scanning is enabled + * + * @return true if enabled, false otherwise + */ + public boolean isEnabled() { + return scanningEnabled; + } + + /** + * Run vulnerability scans on an SBOM and merge vulnerability data + * Executes scanners directly via command line (not through OSI container) + * + * @param sbomContents Contents of the SBOM to scan + * @param sbomFileName Name of the SBOM file + * @return SBOM contents enriched with vulnerability data + * @throws Exception if scanning fails + */ + public String runVulnerabilityScans(String sbomContents, String sbomFileName) throws Exception { + if (!scanningEnabled) { + LOGGER.info("Vulnerability scanning is disabled, skipping"); + return sbomContents; + } + + LOGGER.info("Starting vulnerability scanning for SBOM: {}", sbomFileName); + + // Create temp file for SBOM + Path tempSbomPath = Files.createTempFile("sbom-to-scan-", ".json"); + try { + Files.writeString(tempSbomPath, sbomContents); + + // Get list of tools to use + List tools = parseScanTools(); + LOGGER.info("Running vulnerability scans with tools: {}", tools); + + // Run vulnerability scans in parallel + Map scanResults = runScannersDirectly(tempSbomPath.toString(), tools); + + // Merge vulnerability data into original SBOM + String enrichedSbom = mergeVulnerabilityData(sbomContents, scanResults); + + LOGGER.info("Vulnerability scanning completed successfully with {} scanners", scanResults.size()); + return enrichedSbom; + + } finally { + // Cleanup temp file + try { + Files.deleteIfExists(tempSbomPath); + } catch (IOException e) { + LOGGER.warn("Failed to delete temp SBOM file: {}", e.getMessage()); + } + } + } + + /** + * Run vulnerability scanners directly via command line execution + * This method runs scanners in parallel for improved performance + * + * @param sbomFilePath Path to the SBOM file to scan + * @param tools List of scanner tool names + * @return Map of scanner name to scan results (SBOM content with vulnerabilities) + */ + private Map runScannersDirectly(String sbomFilePath, List tools) { + LOGGER.info("Starting parallel vulnerability scanning with {} scanners", tools.size()); + long startTime = System.currentTimeMillis(); + + // Create a CompletableFuture for each scanner + List>> futures = tools.stream() + .map(tool -> CompletableFuture.supplyAsync(() -> runScannerCommand(tool, sbomFilePath), vulnerabilityScanExecutor)) + .collect(Collectors.toList()); + + // Wait for all scanners to complete + CompletableFuture allOf = CompletableFuture.allOf( + futures.toArray(new CompletableFuture[0]) + ); + + try { + // Wait for completion + allOf.join(); + + // Collect results from all completed futures + Map results = new HashMap<>(); + futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .forEach(entry -> results.putIfAbsent(entry.getKey(), entry.getValue())); + + long duration = System.currentTimeMillis() - startTime; + LOGGER.info("Parallel vulnerability scanning completed in {}ms with {} successful scans", + duration, results.size()); + + return results; + } catch (Exception e) { + LOGGER.error("Failed to complete vulnerability scans: {}", e.getMessage()); + return new HashMap<>(); // Return empty map instead of throwing + } + } + + /** + * Execute a single vulnerability scanner command + * + * @param scannerName Scanner to run (grype, trivy, osv-scanner) + * @param sbomFilePath Path to SBOM file + * @return Map entry with scanner name and result, or null if failed + */ + private Map.Entry runScannerCommand(String scannerName, String sbomFilePath) { + try { + LOGGER.info("Running vulnerability scanner: {}", scannerName); + + // Create temp output file + Path outputPath = Files.createTempFile("vuln-scan-" + scannerName + "-", ".json"); + + // Build command based on scanner + ProcessBuilder pb = buildScannerCommand(scannerName, sbomFilePath, outputPath.toString()); + if (pb == null) { + LOGGER.warn("Scanner {} is not supported for SBOM-only scans", scannerName); + return null; + } + + pb.redirectErrorStream(true); + + // Execute scanner + Process process = pb.start(); + + // Capture output for debugging + java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(process.getInputStream())); + StringBuilder output = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + } + + int exitCode = process.waitFor(); + + // Log scanner output if failed + if (exitCode != 0) { + LOGGER.warn("Scanner {} failed with exit code {}. Output: {}", + scannerName, exitCode, output.toString()); + } + + // Read output file + if (Files.exists(outputPath)) { + String result = Files.readString(outputPath); + Files.deleteIfExists(outputPath); + + if (!result.isEmpty()) { + LOGGER.info("Successfully completed scan with {} ({} bytes)", scannerName, result.length()); + return Map.entry(scannerName, result); + } + } + + LOGGER.warn("Scanner {} produced no output file (exit code: {})", scannerName, exitCode); + return null; + + } catch (Exception e) { + LOGGER.error("Scanner {} failed: {}", scannerName, e.getMessage(), e); + return null; + } + } + + /** + * Build command for specific vulnerability scanner + * + * @param scannerName Scanner name + * @param inputPath Input SBOM path + * @param outputPath Output path + * @return ProcessBuilder configured for the scanner + */ + private ProcessBuilder buildScannerCommand(String scannerName, String inputPath, String outputPath) { + switch (scannerName.toLowerCase()) { + case "grype": + return new ProcessBuilder( + "grype", + "sbom:" + inputPath, + "-o", "cyclonedx-json", + "--by-cve", // Prefer CVE IDs over GHSA IDs + "--file", outputPath + ); + + case "trivy": + return new ProcessBuilder( + "trivy", + "sbom", + "--format", "cyclonedx", + "--scanners", "vuln", // Explicitly enable vulnerability scanning + "--output", outputPath, + inputPath + ); + + case "osv-scanner": + return new ProcessBuilder( + "osv-scanner", + "--sbom=" + inputPath, + "--format", "cyclonedx-1-4", + "--output", outputPath + ); + + case "dependency-check": + // Dependency-Check works differently, skip for now + LOGGER.info("Dependency-Check requires project files, skipping for SBOM-only scan"); + return null; + + default: + LOGGER.warn("Unknown scanner: {}", scannerName); + return null; + } + } + + /** + * Parse the configured scan tools from properties + * + * @return List of tool names + */ + private List parseScanTools() { + List tools = new ArrayList<>(); + if (scanTools != null && !scanTools.isEmpty()) { + String[] toolArray = scanTools.split(","); + for (String tool : toolArray) { + String trimmed = tool.trim(); + if (!trimmed.isEmpty()) { + tools.add(trimmed); + } + } + } + + // Default to grype and trivy if no tools configured + if (tools.isEmpty()) { + tools.add("grype"); + tools.add("trivy"); + } + + return tools; + } + + + /** + * Merge vulnerability data from scan results into the original SBOM + * + * @param originalSbom Original SBOM contents + * @param scanResults Map of tool name to scan results (JSON string) + * @return Enriched SBOM with vulnerability data + * @throws Exception if merging fails + */ + private String mergeVulnerabilityData(String originalSbom, Map scanResults) throws Exception { + try { + JsonNode originalNode = objectMapper.readTree(originalSbom); + + // Collect all vulnerabilities from scan results + List allVulnerabilities = new ArrayList<>(); + + for (Map.Entry entry : scanResults.entrySet()) { + try { + // Parse scan result directly (not base64 encoded) + JsonNode scanResult = objectMapper.readTree(entry.getValue()); + + // Extract vulnerabilities array if present + if (scanResult.has("vulnerabilities") && scanResult.get("vulnerabilities").isArray()) { + scanResult.get("vulnerabilities").forEach(allVulnerabilities::add); + } + } catch (Exception e) { + LOGGER.warn("Failed to parse scan result from {}: {}", entry.getKey(), e.getMessage()); + } + } + + // If we have vulnerabilities, add them to the original SBOM + if (!allVulnerabilities.isEmpty()) { + LOGGER.info("Merging {} vulnerabilities into SBOM", allVulnerabilities.size()); + + // Create a mutable copy of the original SBOM + @SuppressWarnings("unchecked") + Map sbomMap = objectMapper.convertValue(originalNode, Map.class); + sbomMap.put("vulnerabilities", allVulnerabilities); + + // Serialize back to JSON + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(sbomMap); + } else { + LOGGER.info("No vulnerabilities found, returning original SBOM"); + return originalSbom; + } + + } catch (Exception e) { + LOGGER.error("Failed to merge vulnerability data: {}", e.getMessage()); + throw new Exception("Failed to merge vulnerability data: " + e.getMessage()); + } + } +} + diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index b22c55549..d0c8f663b 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -10,4 +10,7 @@ spring.jpa.hibernate.ddl-auto=update spring.servlet.multipart.max-file-size=2048MB spring.servlet.multipart.max-request-size=2048MB # configure OSI endpoint -osi.api.url=http://${OSI_HOST:localhost}:${OSI_PORT:5000} \ No newline at end of file +osi.api.url=http://${OSI_HOST:localhost}:${OSI_PORT:5000} +# configure vulnerability scanning +vulnerability.scan.enabled=${VULN_SCAN_ENABLED:true} +vulnerability.scan.tools=${VULN_SCAN_TOOLS:grype,trivy} \ No newline at end of file diff --git a/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java b/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java index 5e1078b9b..524e21b88 100644 --- a/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java +++ b/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java @@ -422,7 +422,10 @@ private void writeComponent(JsonGenerator jsonGenerator, SVIPComponentObject com // External Refs writeExternalRefs(jsonGenerator, component.getExternalReferences()); - jsonGenerator.writeStringField("releaseNotes", "Release Date: " + component.getReleaseDate()); + // Release Notes - Skip writing as string (CycloneDX expects object, not string) + // If needed in future, implement proper ReleaseNotes object serialization + // jsonGenerator.writeStringField("releaseNotes", "Release Date: " + component.getReleaseDate()); + writeProperties(jsonGenerator, component.getProperties()); // jsonGenerator.writeStringField("swid", String.join(", ", component.getSWID())); 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 129acd206..6f4791e9d 100644 --- a/core/src/main/java/org/svip/vex/database/OSVClient.java +++ b/core/src/main/java/org/svip/vex/database/OSVClient.java @@ -303,48 +303,70 @@ public List getVEXStatements(SBOMPackage s, String key) throws Exc */ private VEXStatement generateVEXStatement(JSONObject vulnerabilityBody, SBOMPackage c) { VEXStatement.Builder statement = new VEXStatement.Builder(); - // add general fields to the statement - statement.setStatementID(vulnerabilityBody.getString("id")); + + // General fields with safe fallbacks + final String id = vulnerabilityBody.optString("id", "UNKNOWN"); + final String firstIssued = vulnerabilityBody.optString("published", + vulnerabilityBody.optString("modified", String.valueOf(java.time.LocalDateTime.now()))); + final String lastUpdated = vulnerabilityBody.optString("modified", firstIssued); + + statement.setStatementID(id); statement.setStatementVersion("1.0"); - statement.setStatementFirstIssued(vulnerabilityBody - .getString("published")); - statement.setStatementLastUpdated(vulnerabilityBody - .getString("modified")); + statement.setStatementFirstIssued(firstIssued); + statement.setStatementLastUpdated(lastUpdated); - // Set the statement's vulnerability - JSONArray aliases = vulnerabilityBody.getJSONArray("aliases"); - String vulnID = aliases.getString(0); - String vulnDesc; - // check if summary key is in json object - // if not default to using details key - if (!vulnerabilityBody.has("summary")) { - vulnDesc = vulnerabilityBody.getString("details"); - } else { - vulnDesc = vulnerabilityBody.getString("summary"); + // Vulnerability ID: prefer CVE in aliases; else fall back to id + String vulnID = id; + JSONArray aliases = vulnerabilityBody.optJSONArray("aliases"); + if (aliases != null && aliases.length() > 0) { + // pick first CVE-like alias if present, otherwise first alias + String firstAlias = aliases.optString(0, id); + for (int i = 0; i < aliases.length(); i++) { + String alias = aliases.optString(i, firstAlias); + if (alias != null && alias.toUpperCase().startsWith("CVE-")) { + firstAlias = alias; + break; + } + } + vulnID = firstAlias; } + + // Description: prefer summary, fallback to details, then placeholder + String vulnDesc = vulnerabilityBody.optString("summary", + vulnerabilityBody.optString("details", "No description provided")); statement.setVulnerability(new Vulnerability(vulnID, vulnDesc)); - //set the statement's affected status + // Status with safe details + String details = vulnerabilityBody.optString("details", vulnDesc); statement.setStatus(new Status(VulnStatus.AFFECTED, - Justification.NOT_APPLICABLE, vulnerabilityBody - .getString("details"), "N/A")); + Justification.NOT_APPLICABLE, details, "N/A")); - //Get all products and add all to the VEX Statement + // Products (affected packages) 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++) { - JSONObject vulnPackage = packages.getJSONObject(i); - // extract the package's info and create a new Product - JSONObject packageInfo = vulnPackage.getJSONObject("package"); - String packageID = packageInfo.getString("name") - + ":" + packageInfo.getString("ecosystem") - + ":" + c.getVersion(); - statement.addProduct(new Product(packageID, supplier)); + + JSONArray packages = vulnerabilityBody.optJSONArray("affected"); + if (packages != null) { + for (int i = 0; i < packages.length(); i++) { + JSONObject vulnPackage = packages.optJSONObject(i); + if (vulnPackage == null) continue; + JSONObject packageInfo = vulnPackage.optJSONObject("package"); + if (packageInfo == null) continue; + String name = packageInfo.optString("name", c.getName() != null ? c.getName() : "unknown"); + String eco = packageInfo.optString("ecosystem", "unknown"); + String version = (c.getVersion() != null) ? c.getVersion() : "unknown"; + String packageID = name + ":" + eco + ":" + version; + statement.addProduct(new Product(packageID, supplier)); + } + } else { + // Fallback: at least include the component itself + String version = (c.getVersion() != null) ? c.getVersion() : "unknown"; + String name = (c.getName() != null) ? c.getName() : "unknown"; + statement.addProduct(new Product(name + ":unknown:" + version, supplier)); } + return statement.build(); } } diff --git a/osi/Dockerfile b/osi/Dockerfile index 014465086..34f4f81b4 100644 --- a/osi/Dockerfile +++ b/osi/Dockerfile @@ -29,9 +29,28 @@ RUN curl -L $GRADLE -o gradle.zip # Install anchore tooling on debian container FROM debian:bookworm-slim AS anchore_install ARG SYFT_VERSION=1.30.0 +ARG GRYPE_VERSION=0.84.0 WORKDIR /tmp RUN apt update && apt install curl -y RUN curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /tmp "v$SYFT_VERSION" +RUN curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /tmp "v$GRYPE_VERSION" + + +# Install vulnerability scanners +FROM debian:bookworm-slim AS vuln_scanner_install +ARG TRIVY_VERSION=0.58.1 +ARG OSV_SCANNER_VERSION=1.9.2 +ARG DEPENDENCY_CHECK_VERSION=11.1.1 +WORKDIR /tmp +RUN apt update && apt install curl unzip wget -y +# Trivy +RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /tmp "v$TRIVY_VERSION" +# OSV Scanner +RUN curl -L "https://github.com/google/osv-scanner/releases/download/v$OSV_SCANNER_VERSION/osv-scanner_$OSV_SCANNER_VERSION\_linux_amd64" -o /tmp/osv-scanner && chmod +x /tmp/osv-scanner +# OWASP Dependency-Check +RUN wget "https://github.com/jeremylong/DependencyCheck/releases/download/v$DEPENDENCY_CHECK_VERSION/dependency-check-$DEPENDENCY_CHECK_VERSION-release.zip" -O dependency-check.zip \ + && unzip dependency-check.zip \ + && chmod +x dependency-check/bin/dependency-check.sh # Main container @@ -44,7 +63,7 @@ ENV DEBIAN_FRONTEND=noninteractive ENV DEBCONF_NONINTERACTIVE_SEEN=true ENV COMPOSER_ALLOW_SUPERUSER=1 # set paths -ENV PATH=$PATH:/opt/gradle/gradle/bin +ENV PATH=$PATH:/opt/gradle/gradle/bin:/opt/dependency-check/bin # Working directories ENV CODE_IN=/tmp/code @@ -110,7 +129,10 @@ RUN composer \ global require cyclonedx/cyclonedx-php-composer:5.2.3 # Copy binaries COPY --from=bin_download /tmp/spdx-sbom-generator /tmp/jbom.jar /tmp/cyclonedx-cli /usr/local/bin/ -COPY --from=anchore_install /tmp/syft /usr/local/bin/ +COPY --from=anchore_install /tmp/syft /tmp/grype /usr/local/bin/ +# Copy vulnerability scanners +COPY --from=vuln_scanner_install /tmp/trivy /tmp/osv-scanner /usr/local/bin/ +COPY --from=vuln_scanner_install /tmp/dependency-check /opt/dependency-check # Cleanup USER root RUN apt remove -y curl git unzip gnupg apt-transport-https software-properties-common diff --git a/osi/osi/configs/tools/dependency-check.yml b/osi/osi/configs/tools/dependency-check.yml new file mode 100644 index 000000000..cda9951ed --- /dev/null +++ b/osi/osi/configs/tools/dependency-check.yml @@ -0,0 +1,27 @@ +# OWASP Dependency-Check Vulnerability Scanner Configuration +# @author Ibrahim Matar +source: "https://github.com/jeremylong/DependencyCheck" +profiles: + - schema: "cyclonedx" + spec_version: "1.4" + format: "json" + languages: + - "Java" + - "JavaScript" + - "Python" + - ".NET" + - "Ruby" + - "PHP" + package_managers: + - "Maven" + - "Gradle" + - "NPM" + - "Yarn" + - "Pip" + - "Composer" + - "NuGet" + - "Gem" + commands: + - "dependency-check --scan $CODE_IN --format JSON --format XML --format HTML --out $SBOM_OUT --enableExperimental" + - "mv $SBOM_OUT/dependency-check-report.json $SBOM_OUT/dependency-check-vulns.json" + diff --git a/osi/osi/configs/tools/grype.yml b/osi/osi/configs/tools/grype.yml new file mode 100644 index 000000000..404cabac7 --- /dev/null +++ b/osi/osi/configs/tools/grype.yml @@ -0,0 +1,23 @@ +# Grype Vulnerability Scanner Configuration +# @author Ibrahim Matar +source: "https://github.com/anchore/grype" +profiles: + - schema: "cyclonedx" + spec_version: "1.4" + format: "json" + languages: + - "All" + package_managers: + - "All" + commands: + - "grype sbom:$SBOM_OUT/merged-sbom.json -o cyclonedx-json --file $SBOM_OUT/grype-vulns-cdx14.json" + - schema: "cyclonedx" + spec_version: "1.5" + format: "json" + languages: + - "All" + package_managers: + - "All" + commands: + - "grype sbom:$SBOM_OUT/merged-sbom.json -o cyclonedx-json@1.5 --file $SBOM_OUT/grype-vulns-cdx15.json" + diff --git a/osi/osi/configs/tools/osv-scanner.yml b/osi/osi/configs/tools/osv-scanner.yml new file mode 100644 index 000000000..2d09133d9 --- /dev/null +++ b/osi/osi/configs/tools/osv-scanner.yml @@ -0,0 +1,15 @@ +# OSV Scanner Vulnerability Scanner Configuration +# @author Ibrahim Matar +source: "https://github.com/google/osv-scanner" +profiles: + - schema: "cyclonedx" + spec_version: "1.4" + format: "json" + languages: + - "All" + package_managers: + - "All" + commands: + - "osv-scanner --sbom=$SBOM_OUT/merged-sbom.json --format json --output $SBOM_OUT/osv-scanner-vulns.json" + - "osv-scanner --sbom=$SBOM_OUT/merged-sbom.json --format cyclonedx-1-4 --output $SBOM_OUT/osv-scanner-vulns-cdx14.json" + diff --git a/osi/osi/configs/tools/trivy.yml b/osi/osi/configs/tools/trivy.yml new file mode 100644 index 000000000..7f4d8eb71 --- /dev/null +++ b/osi/osi/configs/tools/trivy.yml @@ -0,0 +1,23 @@ +# Trivy Vulnerability Scanner Configuration +# @author Ibrahim Matar +source: "https://github.com/aquasecurity/trivy" +profiles: + - schema: "cyclonedx" + spec_version: "1.4" + format: "json" + languages: + - "All" + package_managers: + - "All" + commands: + - "trivy sbom --format cyclonedx --output $SBOM_OUT/trivy-vulns-cdx14.json $SBOM_OUT/merged-sbom.json" + - schema: "cyclonedx" + spec_version: "1.5" + format: "json" + languages: + - "All" + package_managers: + - "All" + commands: + - "trivy sbom --format cyclonedx --scanners vuln --output $SBOM_OUT/trivy-vulns-cdx15.json $SBOM_OUT/merged-sbom.json" + diff --git a/osi/scripts/validate.sh b/osi/scripts/validate.sh index 5456d2de2..abb51ddd7 100644 --- a/osi/scripts/validate.sh +++ b/osi/scripts/validate.sh @@ -88,6 +88,12 @@ function verify_tools(){ sbom-tool --version &> /dev/null && fail "sbom-tool" || pass "sbom-tool" 3 # returns 1 on success spdx-sbom-generator -h &> /dev/null && pass "spdx-sbom-generator" 3 || fail "spdx-sbom-generator" syft -h &> /dev/null && pass "syft" 3 || fail "syft" + + # Vulnerability Scanners + grype version &> /dev/null && pass "grype" 3 || fail "grype" + trivy --version &> /dev/null && pass "trivy" 3 || fail "trivy" + osv-scanner --version &> /dev/null && pass "osv-scanner" 3 || fail "osv-scanner" + dependency-check.sh --version &> /dev/null && pass "dependency-check" 3 || fail "dependency-check" } From 06e2b54abbba57c651666493b987000a7b42f547 Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Thu, 6 Nov 2025 10:13:58 -1000 Subject: [PATCH 07/11] Enhance SBOM scanning and project preparation Scanning pipeline: orchestrate Trivy and Grype against uploaded SBOMs, merge findings, and store results in database Improves vulnerability scanning by normalizing SBOMs, extracting and re-adding vulnerabilities after merge, and supporting multiple scanner formats (CycloneDX, Grype, Trivy). Adds robust project preparation in the Python server to auto-generate lock files and metadata for various ecosystems before scanning. Updates dependency-check tool config for persistent data and improves dependency serialization in the CycloneDX serializer. --- .gitignore | 3 + .../svip/api/controller/OSIController.java | 118 +++-- .../services/VulnerabilityScanService.java | 409 +++++++++++++++++- .../serializer/CDX14JSONSerializer.java | 82 +++- osi/osi/configs/tools/dependency-check.yml | 4 +- osi/osi/osi_server.py | 145 +++++++ 6 files changed, 708 insertions(+), 53 deletions(-) diff --git a/.gitignore b/.gitignore index 17e598e08..3f7a77d02 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,9 @@ gradle-app.setting # Mac files .DS_Store +# Generated Gradle classes +core/bin/ + # dev enviroment files /.dev-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 35771b515..bd6c8db9b 100644 --- a/api/src/main/java/org/svip/api/controller/OSIController.java +++ b/api/src/main/java/org/svip/api/controller/OSIController.java @@ -208,9 +208,32 @@ public ResponseEntity generateWithOSI(@RequestParam("projectName") String pro if (generatedSBOMs.isEmpty()) return new ResponseEntity<>("No SBOMs were generated", HttpStatus.NO_CONTENT); - // Upload SBOMs to SB + // SCAN INDIVIDUAL SBOMs BEFORE MERGING (to preserve metadata) + Map scannedSBOMs = new HashMap<>(); + if (vulnerabilityScanService.isEnabled()) { + LOGGER.info("POST /svip/generators/osi - Running vulnerability scans on individual SBOMs before merging"); + for (Map.Entry entry : generatedSBOMs.entrySet()) { + String fileName = entry.getKey(); + String sbomContent = new String(Base64.getDecoder().decode(entry.getValue())); + + try { + String enrichedContent = vulnerabilityScanService.runVulnerabilityScans(sbomContent, fileName); + // Re-encode to base64 for consistency + scannedSBOMs.put(fileName, Base64.getEncoder().encodeToString(enrichedContent.getBytes())); + LOGGER.info("POST /svip/generators/osi - Scanned {}", fileName); + } catch (Exception e) { + LOGGER.warn("POST /svip/generators/osi - Failed to scan {}: {}. Using original.", fileName, e.getMessage()); + scannedSBOMs.put(fileName, entry.getValue()); + } + } + } else { + LOGGER.info("POST /svip/generators/osi - Vulnerability scanning disabled, using original SBOMs"); + scannedSBOMs.putAll(generatedSBOMs); + } + + // Upload SBOMs to DB List uploaded = new ArrayList<>(); - generatedSBOMs.forEach((fileName, base64Content) -> { + scannedSBOMs.forEach((fileName, base64Content) -> { // Try to upload new SBOM to DB try { UploadSBOMFileInput input = new UploadSBOMFileInput( @@ -239,6 +262,24 @@ public ResponseEntity generateWithOSI(@RequestParam("projectName") String pro LOGGER.info("POST /svip/generators/osi - Parsed {} SBOMs successfully", uploaded.size()); + // Collect vulnerabilities from scanned SBOMs before merging + List allVulnerabilities = new ArrayList<>(); + if (vulnerabilityScanService.isEnabled()) { + for (SBOMFile sbomFile : uploaded) { + try { + JsonNode sbomJson = new ObjectMapper().readTree(sbomFile.getContent()); + if (sbomJson.has("vulnerabilities") && sbomJson.get("vulnerabilities").isArray()) { + sbomJson.get("vulnerabilities").forEach(allVulnerabilities::add); + } + } catch (Exception e) { + LOGGER.warn("POST /svip/generators/osi - Failed to extract vulnerabilities from {}: {}", + sbomFile.getName(), e.getMessage()); + } + } + LOGGER.info("POST /svip/generators/osi - Collected {} total vulnerabilities from individual SBOMs", + allVulnerabilities.size()); + } + // Merge SBOMs Long mergedID; if (uploaded.size() >= 2) { @@ -269,7 +310,7 @@ public ResponseEntity generateWithOSI(@RequestParam("projectName") String pro try { LOGGER.info("POST /svip/generators/osi - Converting SBOM to {} {}", schema, format); convertedID = sbomService.convert(mergedID, schema, format, true); - LOGGER.info("POST /svip/generators/osi - Successfully merged SBOMs to SBOM with id {}", convertedID); + LOGGER.info("POST /svip/generators/osi - Successfully converted SBOM to id {}", convertedID); } catch (DeserializerException | JsonProcessingException | SerializerException | SBOMBuilderException | ConversionException e) { // Failed to convert @@ -277,59 +318,40 @@ public ResponseEntity generateWithOSI(@RequestParam("projectName") String pro return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } - // VULNERABILITY SCANNING - Enrich SBOM with vulnerability data - if (vulnerabilityScanService.isEnabled()) { + // RE-ADD VULNERABILITIES AFTER MERGE (merge strips them) + if (!allVulnerabilities.isEmpty() && vulnerabilityScanService.isEnabled()) { try { - LOGGER.info("POST /svip/generators/osi - Running vulnerability scans"); - SBOMFile sbomToScan = sbomService.getSBOMFile(convertedID); - if (sbomToScan != null) { - String enrichedContents = vulnerabilityScanService.runVulnerabilityScans( - sbomToScan.getContent(), - sbomToScan.getName() - ); + LOGGER.info("POST /svip/generators/osi - Re-adding {} vulnerabilities to merged SBOM", allVulnerabilities.size()); + SBOMFile mergedSbom = sbomService.getSBOMFile(convertedID); + if (mergedSbom != null) { + JsonNode sbomJson = new ObjectMapper().readTree(mergedSbom.getContent()); - // Save enriched SBOM - UploadSBOMFileInput enrichedInput = new UploadSBOMFileInput( - sbomToScan.getName().replace(".json", "-with-vulns.json"), - enrichedContents - ); - SBOMFile enrichedSbom = enrichedInput.toSBOMFile(); - sbomService.upload(enrichedSbom); + @SuppressWarnings("unchecked") + Map sbomMap = new ObjectMapper().convertValue(sbomJson, Map.class); + sbomMap.put("vulnerabilities", allVulnerabilities); - // Delete old SBOM and use enriched one - sbomService.deleteSBOMFile(sbomToScan); - convertedID = enrichedSbom.getId(); + String enrichedContent = new ObjectMapper().writerWithDefaultPrettyPrinter() + .writeValueAsString(sbomMap); - LOGGER.info("POST /svip/generators/osi - Successfully enriched SBOM with vulnerabilities (ID: {})", convertedID); + // Update with enriched content + mergedSbom.setContent(enrichedContent); + sbomService.upload(mergedSbom); - // Record vulnerability history for dashboard tracking - try { - JsonNode sbomJson = new ObjectMapper().readTree(enrichedContents); - if (sbomJson.has("vulnerabilities") && sbomJson.get("vulnerabilities").isArray()) { - java.util.List vulns = new java.util.ArrayList<>(); - sbomJson.get("vulnerabilities").forEach(vulns::add); - - vulnerabilityHistoryService.recordVulnerabilities( - convertedID, - projectName, - enrichedSbom.getName(), - vulns, - "grype,trivy,osv-scanner" - ); - LOGGER.info("POST /svip/generators/osi - Recorded vulnerability history"); - } - } catch (Exception histEx) { - LOGGER.warn("POST /svip/generators/osi - Failed to record vulnerability history: {}", histEx.getMessage()); - } - } else { - LOGGER.warn("POST /svip/generators/osi - Could not find SBOM for vulnerability scanning"); + LOGGER.info("POST /svip/generators/osi - Successfully re-added vulnerabilities to merged SBOM"); + + // Record history + vulnerabilityHistoryService.recordVulnerabilities( + convertedID, + projectName, + mergedSbom.getName(), + allVulnerabilities, + "grype,trivy" + ); + LOGGER.info("POST /svip/generators/osi - Recorded {} vulnerabilities to history", allVulnerabilities.size()); } } catch (Exception e) { - // Log warning but don't fail the entire request - LOGGER.warn("POST /svip/generators/osi - Vulnerability scanning failed (continuing without vulnerabilities): " + e.getMessage()); + LOGGER.warn("POST /svip/generators/osi - Failed to re-add vulnerabilities: {}", e.getMessage()); } - } else { - LOGGER.info("POST /svip/generators/osi - Vulnerability scanning is disabled, skipping"); } // Set descriptive filename: ProjectName-OSI-Schema-Format-Timestamp.ext diff --git a/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java index eb988cc38..eaa26f09c 100644 --- a/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java +++ b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java @@ -26,6 +26,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -93,7 +95,8 @@ public String runVulnerabilityScans(String sbomContents, String sbomFileName) th // Create temp file for SBOM Path tempSbomPath = Files.createTempFile("sbom-to-scan-", ".json"); try { - Files.writeString(tempSbomPath, sbomContents); + String normalizedSbom = normalizeSbomForScanning(sbomContents, sbomFileName); + Files.writeString(tempSbomPath, normalizedSbom); // Get list of tools to use List tools = parseScanTools(); @@ -212,6 +215,22 @@ private Map.Entry runScannerCommand(String scannerName, String s if (!result.isEmpty()) { LOGGER.info("Successfully completed scan with {} ({} bytes)", scannerName, result.length()); + + try { + JsonNode debugNode = objectMapper.readTree(result); + StringBuilder keys = new StringBuilder(); + debugNode.fieldNames().forEachRemaining(key -> keys.append(key).append(", ")); + if (keys.length() > 2) { + keys.setLength(keys.length() - 2); + } + LOGGER.info("{} output top-level keys: {}", scannerName, keys.length() > 0 ? keys : ""); + + String preview = result.length() > 500 ? result.substring(0, 500) + "..." : result; + LOGGER.debug("{} output preview: {}", scannerName, preview); + } catch (Exception e) { + LOGGER.warn("Could not parse {} output as JSON: {}", scannerName, e.getMessage()); + } + return Map.entry(scannerName, result); } } @@ -319,10 +338,34 @@ private String mergeVulnerabilityData(String originalSbom, Map s try { // Parse scan result directly (not base64 encoded) JsonNode scanResult = objectMapper.readTree(entry.getValue()); - + + StringBuilder keys = new StringBuilder(); + scanResult.fieldNames().forEachRemaining(key -> keys.append(key).append(", ")); + if (keys.length() > 2) { + keys.setLength(keys.length() - 2); + } + LOGGER.info("Parsing {} scan result, top-level keys: {}", entry.getKey(), keys.length() > 0 ? keys : ""); + // Extract vulnerabilities array if present if (scanResult.has("vulnerabilities") && scanResult.get("vulnerabilities").isArray()) { - scanResult.get("vulnerabilities").forEach(allVulnerabilities::add); + JsonNode vulns = scanResult.get("vulnerabilities"); + vulns.forEach(allVulnerabilities::add); + LOGGER.info("Extracted {} vulnerabilities from {} (CycloneDX format)", vulns.size(), entry.getKey()); + } + // Grype native JSON format exposes "matches" + else if (scanResult.has("matches") && scanResult.get("matches").isArray()) { + List converted = convertGrypeMatchesToCycloneDX(scanResult); + allVulnerabilities.addAll(converted); + LOGGER.info("Converted {} grype matches to CycloneDX vulnerabilities", converted.size()); + } + // Trivy format exposes "Results" with nested "Vulnerabilities" + else if (scanResult.has("Results") && scanResult.get("Results").isArray()) { + List converted = convertTrivyResultsToCycloneDX(scanResult); + allVulnerabilities.addAll(converted); + LOGGER.info("Converted {} trivy vulnerabilities to CycloneDX format", converted.size()); + } + else { + LOGGER.warn("{} returned unrecognized format (no vulnerabilities/matches/Results array)", entry.getKey()); } } catch (Exception e) { LOGGER.warn("Failed to parse scan result from {}: {}", entry.getKey(), e.getMessage()); @@ -350,5 +393,365 @@ private String mergeVulnerabilityData(String originalSbom, Map s throw new Exception("Failed to merge vulnerability data: " + e.getMessage()); } } + + private String normalizeSbomForScanning(String sbomContents, String sbomFileName) { + try { + JsonNode root = objectMapper.readTree(sbomContents); + if (!(root instanceof ObjectNode rootObject)) { + return sbomContents; + } + + JsonNode components = rootObject.get("components"); + if (components == null || !components.isArray()) { + return sbomContents; + } + + boolean modified = false; + + for (JsonNode node : components) { + if (!(node instanceof ObjectNode component)) { + continue; + } + + boolean needsPurl = component.path("purl").asText("").isBlank(); + boolean needsCpe = component.path("cpe").asText("").isBlank(); + String detectedPurl = null; + String detectedCpe = null; + + JsonNode externalRefs = component.get("externalReferences"); + if (externalRefs != null && externalRefs.isArray()) { + for (JsonNode ref : externalRefs) { + String type = ref.path("type").asText(""); + if (needsPurl && type.startsWith("pkg:")) { + detectedPurl = type; + } + if (needsCpe && type.startsWith("cpe:")) { + detectedCpe = type; + } + } + } + + if (needsPurl && detectedPurl != null) { + component.put("purl", detectedPurl); + modified = true; + } + + if (needsCpe && detectedCpe != null) { + component.put("cpe", detectedCpe); + modified = true; + } + + if (detectedPurl != null) { + String ecosystem = extractEcosystem(detectedPurl); + if (ecosystem != null) { + ArrayNode properties = ensurePropertiesArray(component); + if (!hasProperty(properties, "syft:package:type")) { + ObjectNode prop = objectMapper.createObjectNode(); + prop.put("name", "syft:package:type"); + prop.put("value", mapEcosystemToSyftType(ecosystem)); + properties.add(prop); + modified = true; + } + } + } + } + + if (modified) { + LOGGER.info("Normalized SBOM {} for scanning (added missing identifiers)", sbomFileName); + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(rootObject); + } + } catch (Exception e) { + LOGGER.warn("Failed to normalize SBOM {} for scanning: {}", sbomFileName, e.getMessage()); + } + + return sbomContents; + } + + private ArrayNode ensurePropertiesArray(ObjectNode component) { + JsonNode propsNode = component.get("properties"); + if (propsNode instanceof ArrayNode arrayNode) { + return arrayNode; + } + + ArrayNode newArray = objectMapper.createArrayNode(); + component.set("properties", newArray); + return newArray; + } + + private boolean hasProperty(ArrayNode properties, String name) { + for (JsonNode prop : properties) { + if (prop.path("name").asText("").equalsIgnoreCase(name)) { + return true; + } + } + return false; + } + + private String extractEcosystem(String purl) { + if (purl == null || purl.isBlank()) { + return null; + } + + int colon = purl.indexOf(':'); + if (colon < 0 || colon + 1 >= purl.length()) { + return null; + } + + int slash = purl.indexOf('/', colon + 1); + if (slash < 0) { + return null; + } + + return purl.substring(colon + 1, slash); + } + + private String mapEcosystemToSyftType(String ecosystem) { + if (ecosystem == null) { + return "UnknownPackage"; + } + + return switch (ecosystem.toLowerCase(Locale.ROOT)) { + case "npm" -> "npm"; + case "pypi" -> "python"; + case "maven" -> "java-archive"; + case "nuget" -> "dotnet"; + case "gem" -> "rubygem"; + case "golang" -> "go"; + case "cargo" -> "rust"; + case "composer" -> "php-composer"; + default -> ecosystem; + }; + } + + private List convertGrypeMatchesToCycloneDX(JsonNode grypeOutput) { + List vulnerabilities = new ArrayList<>(); + + JsonNode matches = grypeOutput.get("matches"); + if (matches == null || !matches.isArray()) { + LOGGER.warn("Grype output does not contain a matches array"); + return vulnerabilities; + } + + for (JsonNode match : matches) { + JsonNode vulnerability = match.get("vulnerability"); + JsonNode artifact = match.get("artifact"); + if (vulnerability == null) { + continue; + } + + ObjectNode vulnNode = objectMapper.createObjectNode(); + + String vulnId = vulnerability.path("id").asText(""); + if (!vulnId.isEmpty()) { + vulnNode.put("id", vulnId); + } + + ObjectNode sourceNode = objectMapper.createObjectNode(); + String namespace = vulnerability.path("namespace").asText(""); + if (!namespace.isEmpty()) { + sourceNode.put("name", namespace); + } + String dataSource = vulnerability.path("dataSource").asText(""); + if (!dataSource.isEmpty()) { + sourceNode.put("url", dataSource); + } + if (sourceNode.size() > 0) { + vulnNode.set("source", sourceNode); + } + + String description = vulnerability.path("description").asText(""); + if (!description.isEmpty()) { + vulnNode.put("description", description); + } + + JsonNode cvssNode = vulnerability.get("cvss"); + if (cvssNode != null) { + ArrayNode ratingsArray = objectMapper.createArrayNode(); + + if (cvssNode.isArray()) { + for (JsonNode cvss : cvssNode) { + addCvssRating(ratingsArray, cvss); + } + } else { + addCvssRating(ratingsArray, cvssNode); + } + + if (!ratingsArray.isEmpty()) { + vulnNode.set("ratings", ratingsArray); + } + } + + if (artifact != null) { + ArrayNode affects = objectMapper.createArrayNode(); + ObjectNode affect = objectMapper.createObjectNode(); + + String bomRef = artifact.path("id").asText(""); + if (bomRef.isEmpty()) { + bomRef = artifact.path("purl").asText(""); + } + if (bomRef.isEmpty()) { + String name = artifact.path("name").asText(""); + String version = artifact.path("version").asText(""); + if (!name.isEmpty()) { + bomRef = version.isEmpty() ? name : name + "@" + version; + } + } + + if (!bomRef.isEmpty()) { + affect.put("ref", bomRef); + affects.add(affect); + } + + if (!affects.isEmpty()) { + vulnNode.set("affects", affects); + } + } + + vulnerabilities.add(vulnNode); + } + + return vulnerabilities; + } + + private void addCvssRating(ArrayNode ratingsArray, JsonNode cvss) { + if (cvss == null || cvss.isMissingNode()) { + return; + } + + ObjectNode rating = objectMapper.createObjectNode(); + + String source = cvss.path("source").asText(""); + if (!source.isEmpty()) { + rating.put("source", source); + } + + String method = cvss.path("version").asText(""); + if (!method.isEmpty()) { + rating.put("method", method); + } + + double score = cvss.path("metrics").path("baseScore").asDouble(Double.NaN); + if (Double.isNaN(score)) { + score = cvss.path("baseScore").asDouble(Double.NaN); + } + if (!Double.isNaN(score)) { + rating.put("score", score); + } + + String severity = cvss.path("metrics").path("baseSeverity").asText(""); + if (severity.isEmpty()) { + severity = cvss.path("severity").asText(""); + } + if (!severity.isEmpty()) { + rating.put("severity", severity); + } + + if (!rating.isEmpty()) { + ratingsArray.add(rating); + } + } + + private List convertTrivyResultsToCycloneDX(JsonNode trivyOutput) { + List vulnerabilities = new ArrayList<>(); + + JsonNode results = trivyOutput.get("Results"); + if (results == null || !results.isArray()) { + LOGGER.warn("Trivy output does not contain a Results array"); + return vulnerabilities; + } + + for (JsonNode result : results) { + JsonNode vulns = result.get("Vulnerabilities"); + if (vulns == null || !vulns.isArray()) { + continue; + } + + String target = result.path("Target").asText(""); + + for (JsonNode vuln : vulns) { + ObjectNode vulnNode = objectMapper.createObjectNode(); + + String vulnId = vuln.path("VulnerabilityID").asText(""); + if (!vulnId.isEmpty()) { + vulnNode.put("id", vulnId); + } + + ObjectNode sourceNode = objectMapper.createObjectNode(); + sourceNode.put("name", "trivy"); + + String primaryUrl = vuln.path("PrimaryURL").asText(""); + if (!primaryUrl.isEmpty()) { + sourceNode.put("url", primaryUrl); + } + vulnNode.set("source", sourceNode); + + String description = vuln.path("Description").asText(""); + if (!description.isEmpty()) { + vulnNode.put("description", description); + } + + String severity = vuln.path("Severity").asText(""); + ArrayNode ratingsArray = objectMapper.createArrayNode(); + if (!severity.isEmpty()) { + ObjectNode rating = objectMapper.createObjectNode(); + rating.put("severity", severity); + + JsonNode cvss = vuln.get("CVSS"); + double score = Double.NaN; + if (cvss != null && cvss.isObject()) { + JsonNode cvss34 = cvss.path("nvd"); + if (!cvss34.isMissingNode()) { + score = cvss34.path("V3Score").asDouble(Double.NaN); + String vector = cvss34.path("Vectors").asText(""); + if (!vector.isEmpty()) { + rating.put("vector", vector); + } + rating.put("method", "CVSSv3"); + } + + if (Double.isNaN(score)) { + score = cvss.path("score").asDouble(Double.NaN); + } + } + + if (!Double.isNaN(score)) { + rating.put("score", score); + } + + ratingsArray.add(rating); + } + + if (!ratingsArray.isEmpty()) { + vulnNode.set("ratings", ratingsArray); + } + + ArrayNode affects = objectMapper.createArrayNode(); + ObjectNode affect = objectMapper.createObjectNode(); + + String pkgName = vuln.path("PkgName").asText(""); + String installedVersion = vuln.path("InstalledVersion").asText(""); + String ref = target; + if (ref.isEmpty()) { + ref = pkgName; + if (!installedVersion.isEmpty()) { + ref = ref + "@" + installedVersion; + } + } + + if (!ref.isEmpty()) { + affect.put("ref", ref); + affects.add(affect); + } + + if (!affects.isEmpty()) { + vulnNode.set("affects", affects); + } + + vulnerabilities.add(vulnNode); + } + } + + return vulnerabilities; + } } diff --git a/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java b/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java index 524e21b88..1d603c1c1 100644 --- a/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java +++ b/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java @@ -45,6 +45,8 @@ import java.io.IOException; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -149,7 +151,9 @@ public void serialize(SVIPSBOM sbom, JsonGenerator jsonGenerator, SerializerProv jsonGenerator.writeFieldName("dependencies"); jsonGenerator.writeStartArray(); - for (Map.Entry> dep : sbom.getRelationships().entrySet()) { + Map> relationships = getSanitizedRelationships(sbom); + + for (Map.Entry> dep : relationships.entrySet()) { jsonGenerator.writeStartObject(); jsonGenerator.writeStringField("ref", dep.getKey()); @@ -177,6 +181,82 @@ public void serialize(SVIPSBOM sbom, JsonGenerator jsonGenerator, SerializerProv //#region Helper Methods + private Map> getSanitizedRelationships(SVIPSBOM sbom) { + Map> original = sbom.getRelationships(); + Map> cleaned = new LinkedHashMap<>(); + + if (original != null) { + for (Map.Entry> entry : original.entrySet()) { + String ref = normalizeRef(entry.getKey()); + if (ref == null) continue; + + Set filtered = new LinkedHashSet<>(); + if (entry.getValue() != null) { + for (Relationship rel : entry.getValue()) { + if (rel == null) continue; + String other = normalizeRef(rel.getOtherUID()); + if (other == null) continue; + filtered.add(new Relationship(other, rel.getRelationshipType())); + } + } + + if (!filtered.isEmpty()) { + cleaned.put(ref, filtered); + } + } + } + + if (cleaned.isEmpty()) { + String rootUid = resolveRootUid(sbom); + if (rootUid != null) { + Set fallback = new LinkedHashSet<>(); + for (Component component : sbom.getComponents()) { + if (component == null) continue; + String uid = normalizeRef(component.getUID()); + if (uid == null || uid.equals(rootUid)) continue; + fallback.add(new Relationship(uid, "DEPENDS_ON")); + } + if (!fallback.isEmpty()) { + cleaned.put(rootUid, fallback); + } + } + } + + return cleaned; + } + + private String resolveRootUid(SVIPSBOM sbom) { + Component root = sbom.getRootComponent(); + if (root != null) { + String uid = normalizeRef(root.getUID()); + if (uid != null) return uid; + } + + for (Component component : sbom.getComponents()) { + if (component == null) continue; + String type = component.getType(); + if (type != null && type.equalsIgnoreCase("application")) { + String uid = normalizeRef(component.getUID()); + if (uid != null) return uid; + } + } + + for (Component component : sbom.getComponents()) { + if (component == null) continue; + String uid = normalizeRef(component.getUID()); + if (uid != null) return uid; + } + + return null; + } + + private String normalizeRef(String ref) { + if (ref == null) return null; + String trimmed = ref.trim(); + if (trimmed.isEmpty() || "null".equalsIgnoreCase(trimmed)) return null; + return trimmed; + } + private void writeCreationData(JsonGenerator jsonGenerator, CreationData data, SVIPComponentObject rootComponent) throws IOException { jsonGenerator.writeFieldName("metadata"); jsonGenerator.writeStartObject(); diff --git a/osi/osi/configs/tools/dependency-check.yml b/osi/osi/configs/tools/dependency-check.yml index cda9951ed..e86cf7ea5 100644 --- a/osi/osi/configs/tools/dependency-check.yml +++ b/osi/osi/configs/tools/dependency-check.yml @@ -22,6 +22,8 @@ profiles: - "NuGet" - "Gem" commands: - - "dependency-check --scan $CODE_IN --format JSON --format XML --format HTML --out $SBOM_OUT --enableExperimental" + - "mkdir -p /tmp/dependency-check-data" + - "if [ ! -f /tmp/dependency-check-data/odc.mv.db ]; then /opt/dependency-check/bin/dependency-check.sh --data /tmp/dependency-check-data --updateonly || true; fi" + - "/opt/dependency-check/bin/dependency-check.sh --data /tmp/dependency-check-data --scan $CODE_IN --format JSON --format XML --format HTML --out $SBOM_OUT --enableExperimental" - "mv $SBOM_OUT/dependency-check-report.json $SBOM_OUT/dependency-check-vulns.json" diff --git a/osi/osi/osi_server.py b/osi/osi/osi_server.py index 587ec1526..044e52a03 100644 --- a/osi/osi/osi_server.py +++ b/osi/osi/osi_server.py @@ -8,6 +8,7 @@ """ import base64 import configparser +import glob import os import shutil import subprocess @@ -94,6 +95,146 @@ def _get_applicable_tools(self) -> List[Profile]: tools += tool.get_matching_profiles(run_config) return tools + @staticmethod + def _command_exists(command: str) -> bool: + return shutil.which(command) is not None + + def _run_prepare_command(self, command: List[str], cwd: str, description: str) -> None: + self._app.logger.info(f"Prepare | Running {description} in {os.path.relpath(cwd, os.environ.get('CODE_IN', cwd))}") + try: + result = subprocess.run( + command, + cwd=cwd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + output = result.stdout.strip() + if output: + self._app.logger.info(f"Prepare | {description} output:\n{output}") + else: + self._app.logger.info(f"Prepare | {description} completed with no output") + except FileNotFoundError: + self._app.logger.warning(f"Prepare | Command not found for {description}") + except subprocess.CalledProcessError as exc: + output = exc.stdout.strip() if exc.stdout else "(no output)" + self._app.logger.warning( + f"Prepare | {description} failed with exit code {exc.returncode}: {output}") + + def _prepare_project(self, project_dir: str) -> None: + self._app.logger.info("Prepare | Ensuring dependency metadata for uploaded project") + + def _rel(path: str) -> str: + try: + return os.path.relpath(path, project_dir) + except ValueError: + return path + + node_manifests = glob.glob(os.path.join(project_dir, "**", "package.json"), recursive=True) + for manifest in node_manifests: + base_dir = os.path.dirname(manifest) + package_lock = os.path.join(base_dir, "package-lock.json") + if not os.path.exists(package_lock) and self._command_exists("npm"): + self._run_prepare_command( + ["npm", "install", "--package-lock-only", "--ignore-scripts"], + base_dir, + f"npm install --package-lock-only ({_rel(base_dir)})", + ) + + yarn_lock = os.path.join(base_dir, "yarn.lock") + if not os.path.exists(yarn_lock) and self._command_exists("yarn"): + self._run_prepare_command( + ["yarn", "install", "--silent", "--mode=skip-build"], + base_dir, + f"yarn install ({_rel(base_dir)})", + ) + + pnpm_lock = os.path.join(base_dir, "pnpm-lock.yaml") + if not os.path.exists(pnpm_lock) and self._command_exists("pnpm"): + self._run_prepare_command( + ["pnpm", "install", "--lockfile-only", "--ignore-scripts"], + base_dir, + f"pnpm install --lockfile-only ({_rel(base_dir)})", + ) + + go_modules = glob.glob(os.path.join(project_dir, "**", "go.mod"), recursive=True) + if self._command_exists("go"): + for go_mod in go_modules: + base_dir = os.path.dirname(go_mod) + self._run_prepare_command(["go", "mod", "tidy"], base_dir, f"go mod tidy ({_rel(base_dir)})") + + cargo_manifests = glob.glob(os.path.join(project_dir, "**", "Cargo.toml"), recursive=True) + if self._command_exists("cargo"): + for cargo_toml in cargo_manifests: + base_dir = os.path.dirname(cargo_toml) + self._run_prepare_command( + ["cargo", "generate-lockfile"], + base_dir, + f"cargo generate-lockfile ({_rel(base_dir)})", + ) + + pyprojects = glob.glob(os.path.join(project_dir, "**", "pyproject.toml"), recursive=True) + for pyproject in pyprojects: + base_dir = os.path.dirname(pyproject) + try: + with open(pyproject, "r", encoding="utf-8") as handle: + pyproject_contents = handle.read() + except OSError as exc: + self._app.logger.warning(f"Prepare | Unable to read pyproject.toml at {_rel(pyproject)}: {exc}") + pyproject_contents = "" + + if "tool.poetry" in pyproject_contents and not os.path.exists(os.path.join(base_dir, "poetry.lock")): + if self._command_exists("poetry"): + self._run_prepare_command( + ["poetry", "lock", "--no-update"], + base_dir, + f"poetry lock ({_rel(base_dir)})", + ) + else: + self._app.logger.warning( + f"Prepare | poetry not available; skipping poetry lock generation for {_rel(base_dir)}") + + pipfiles = glob.glob(os.path.join(project_dir, "**", "Pipfile"), recursive=True) + for pipfile in pipfiles: + base_dir = os.path.dirname(pipfile) + if not os.path.exists(os.path.join(base_dir, "Pipfile.lock")): + if self._command_exists("pipenv"): + self._run_prepare_command( + ["pipenv", "lock"], + base_dir, + f"pipenv lock ({_rel(base_dir)})", + ) + else: + self._app.logger.warning( + f"Prepare | pipenv not available; skipping Pipfile.lock generation for {_rel(base_dir)}") + + dotnet_projects = glob.glob(os.path.join(project_dir, "**", "*.sln"), recursive=True) + \ + glob.glob(os.path.join(project_dir, "**", "*.csproj"), recursive=True) + if dotnet_projects and self._command_exists("dotnet"): + processed_paths = set() + for project in dotnet_projects: + base_dir = os.path.dirname(project) + if base_dir not in processed_paths: + self._run_prepare_command(["dotnet", "restore"], base_dir, f"dotnet restore ({_rel(base_dir)})") + processed_paths.add(base_dir) + elif dotnet_projects: + self._app.logger.warning("Prepare | dotnet not available; skipping restore for .NET projects") + + composer_manifests = glob.glob(os.path.join(project_dir, "**", "composer.json"), recursive=True) + for composer_json in composer_manifests: + base_dir = os.path.dirname(composer_json) + if not os.path.exists(os.path.join(base_dir, "composer.lock")): + if self._command_exists("composer"): + self._run_prepare_command( + ["composer", "update", "--lock", "--no-interaction"], + base_dir, + f"composer update --lock ({_rel(base_dir)})", + ) + else: + self._app.logger.warning( + f"Prepare | composer not available; skipping composer.lock generation for {_rel(base_dir)}") + def _setup_routes(self): @self._app.route('/healthcheck', methods=['GET']) def healthcheck(): @@ -143,6 +284,10 @@ def upload_project(): with zipfile.ZipFile(BytesIO(zip_data)) as zip_ref: zip_ref.extractall(os.environ['CODE_IN']) self._app.logger.info("Extracted project successfully") + try: + self._prepare_project(os.environ['CODE_IN']) + except Exception as prep_error: + self._app.logger.warning(f"Prepare | Failed to prepare project: {prep_error}") return 'Zip extracted successfully', 201 except Exception as e: self._app.logger.error(f"Failed to extract project: {e}") From 573fb40d2316bee16a6b056b791dd26cd47f3b10 Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Thu, 6 Nov 2025 11:09:58 -1000 Subject: [PATCH 08/11] Update OSVClient.java --- core/src/main/java/org/svip/vex/database/OSVClient.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 6f4791e9d..b92609958 100644 --- a/core/src/main/java/org/svip/vex/database/OSVClient.java +++ b/core/src/main/java/org/svip/vex/database/OSVClient.java @@ -341,10 +341,12 @@ private VEXStatement generateVEXStatement(JSONObject vulnerabilityBody, SBOMPack statement.setStatus(new Status(VulnStatus.AFFECTED, Justification.NOT_APPLICABLE, details, "N/A")); - // Products (affected packages) + // Products (affected packages) - safely handle null supplier String supplier = "Unknown"; if (c.getSupplier() != null && c.getSupplier().getName() != null) { supplier = c.getSupplier().getName(); + } else if (c.getName() != null) { + supplier = c.getName(); } JSONArray packages = vulnerabilityBody.optJSONArray("affected"); From 2cff35dbb5750b10fb8f0cb8adea034c24e3401e Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Thu, 6 Nov 2025 13:22:44 -1000 Subject: [PATCH 09/11] fix(vuln-scan): filter out CVSS v4 scores, prefer v3.x only --- .../java/org/svip/api/services/VulnerabilityScanService.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java index eaa26f09c..0a6176a9a 100644 --- a/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java +++ b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java @@ -626,6 +626,10 @@ private void addCvssRating(ArrayNode ratingsArray, JsonNode cvss) { } String method = cvss.path("version").asText(""); + // Skip CVSS v4.0 scores; prefer v3.x only + if (method.startsWith("4.")) { + return; + } if (!method.isEmpty()) { rating.put("method", method); } From c1f497d7917b749bc4a04df114bf8af6424469cc Mon Sep 17 00:00:00 2001 From: Ibrahim Matar Date: Tue, 11 Nov 2025 09:38:21 -1000 Subject: [PATCH 10/11] fix(osi): remap vulnerability refs and preserve merged SBOMs map component identifiers to bom-ref during scan conversion and reattach cycles so CycloneDX vulnerabilities reference components correctly re-serialize merged SBOMs to the requested format, remap relationship targets, and default merge output to CycloneDX JSON raise JSON read limits and harden OSI dependency preparation/configs for larger SBOMs and richer dependency graphs add unit tests covering vulnerability reference remapping --- .../org/svip/api/config/JacksonConfig.java | 88 +++-- .../svip/api/controller/OSIController.java | 74 ++++- .../org/svip/api/services/OSIService.java | 2 +- .../svip/api/services/SBOMFileService.java | 10 +- .../services/VulnerabilityScanService.java | 301 ++++++++++++++++-- .../VulnerabilityScanServiceTest.java | 224 +++++++++++++ .../main/java/org/svip/merge/MergerUtils.java | 126 +++++++- .../serializer/CDX14JSONSerializer.java | 17 +- osi/osi/configs/tools/cdxgen.yml | 2 +- osi/osi/configs/tools/syft.yml | 2 +- osi/osi/osi_server.py | 103 ++++-- 11 files changed, 836 insertions(+), 113 deletions(-) create mode 100644 api/src/test/java/org/svip/api/services/VulnerabilityScanServiceTest.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 index 4cba91ee0..2b63034eb 100644 --- a/api/src/main/java/org/svip/api/config/JacksonConfig.java +++ b/api/src/main/java/org/svip/api/config/JacksonConfig.java @@ -1,37 +1,75 @@ +/** + * 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.config; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.StreamWriteConstraints; +import com.fasterxml.jackson.core.StreamReadConstraints; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +/** + * Jackson configuration to handle large SBOM files + * + * This configuration increases the maximum allowed string length for JSON parsing + * to handle very large SBOM files (up to 500MB of JSON text). + */ @Configuration public class JacksonConfig { - + + /** + * Configure Jackson to handle larger JSON strings + * Increases the max string length from 100MB to 500MB + */ + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + return builder -> { + builder.postConfigurer(objectMapper -> { + objectMapper.getFactory().setStreamReadConstraints( + StreamReadConstraints.builder() + .maxStringLength(500_000_000) // 500MB limit for JSON strings + .build() + ); + }); + }; + } + + /** + * Create a primary ObjectMapper bean with increased limits + * This will be used by default throughout the application + */ @Bean + @Primary 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); - - // Register Java 8 date/time module for LocalDateTime support - mapper.registerModule(new JavaTimeModule()); - - // Configure to handle circular references globally - mapper.configure(SerializationFeature.FAIL_ON_SELF_REFERENCES, false); - mapper.configure(SerializationFeature.WRITE_SELF_REFERENCES_AS_NULL, true); - - // Disable writing dates as timestamps (use ISO-8601 strings instead) - mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - + ObjectMapper mapper = new ObjectMapper(); + mapper.getFactory().setStreamReadConstraints( + StreamReadConstraints.builder() + .maxStringLength(500_000_000) // 500MB limit for JSON strings + .build() + ); return mapper; } -} +} \ No newline at end of file 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 bd6c8db9b..757c302fe 100644 --- a/api/src/main/java/org/svip/api/controller/OSIController.java +++ b/api/src/main/java/org/svip/api/controller/OSIController.java @@ -44,6 +44,9 @@ import org.svip.serializers.SerializerFactory; import org.svip.serializers.exceptions.DeserializerException; import org.svip.serializers.exceptions.SerializerException; +import org.svip.serializers.serializer.Serializer; +import org.svip.sbom.model.interfaces.generics.SBOM; +import org.svip.sbom.model.objects.SVIPSBOM; import java.io.IOException; import java.net.URISyntaxException; @@ -305,33 +308,57 @@ public ResponseEntity generateWithOSI(@RequestParam("projectName") String pro mergedID = uploaded.get(0).getId(); } - // Convert + // Re-serialize to requested format (preserves relationships better than conversion) Long convertedID; try { - LOGGER.info("POST /svip/generators/osi - Converting SBOM to {} {}", schema, format); - convertedID = sbomService.convert(mergedID, schema, format, true); - LOGGER.info("POST /svip/generators/osi - Successfully converted SBOM to id {}", convertedID); - } catch (DeserializerException | JsonProcessingException | SerializerException | SBOMBuilderException | - ConversionException e) { - // Failed to convert - LOGGER.error("POST /svip/generators/osi - Failed to convert - " + e.getMessage()); + SBOMFile mergedSbom = sbomService.getSBOMFile(mergedID); + + // Check if already in target schema/format + if (matchesRequestedFormat(mergedSbom, schema, format)) { + LOGGER.info("POST /svip/generators/osi - Merged SBOM already in {} {}, using as-is", schema, format); + convertedID = mergedID; + } else { + // Re-serialize instead of convert to preserve relationship structure + LOGGER.info("POST /svip/generators/osi - Re-serializing merged SBOM to {} {}", schema, format); + SBOM sbomObject = mergedSbom.toSBOMObject(); + Serializer serializer = SerializerFactory.createSerializer(schema, format, true); + serializer.setPrettyPrinting(true); + String reserializedContent = serializer.writeToString((SVIPSBOM) sbomObject); + + String newName = (sbomObject.getName() != null ? sbomObject.getName() : "merged-sbom") + "-" + schema + "-" + format; + UploadSBOMFileInput input = new UploadSBOMFileInput(newName, reserializedContent); + SBOMFile reserialized = input.toSBOMFile(); + + // Delete old merged SBOM and save new one + sbomService.deleteSBOMFile(mergedSbom); + sbomService.upload(reserialized); + convertedID = reserialized.getId(); + LOGGER.info("POST /svip/generators/osi - Successfully re-serialized to id {}", convertedID); + } + } catch (Exception e) { + // Failed to re-serialize + LOGGER.error("POST /svip/generators/osi - Failed to re-serialize - " + e.getMessage()); return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } // RE-ADD VULNERABILITIES AFTER MERGE (merge strips them) if (!allVulnerabilities.isEmpty() && vulnerabilityScanService.isEnabled()) { try { - LOGGER.info("POST /svip/generators/osi - Re-adding {} vulnerabilities to merged SBOM", allVulnerabilities.size()); SBOMFile mergedSbom = sbomService.getSBOMFile(convertedID); if (mergedSbom != null) { - JsonNode sbomJson = new ObjectMapper().readTree(mergedSbom.getContent()); - + ObjectMapper mapper = new ObjectMapper(); + JsonNode sbomJson = mapper.readTree(mergedSbom.getContent()); + List remappedVulnerabilities = vulnerabilityScanService.remapVulnerabilityReferences(sbomJson, allVulnerabilities); + allVulnerabilities = new ArrayList<>(remappedVulnerabilities); + + LOGGER.info("POST /svip/generators/osi - Re-adding {} vulnerabilities to merged SBOM", allVulnerabilities.size()); + @SuppressWarnings("unchecked") - Map sbomMap = new ObjectMapper().convertValue(sbomJson, Map.class); + Map sbomMap = mapper.convertValue(sbomJson, Map.class); sbomMap.put("vulnerabilities", allVulnerabilities); - String enrichedContent = new ObjectMapper().writerWithDefaultPrettyPrinter() - .writeValueAsString(sbomMap); + String enrichedContent = mapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(sbomMap); // Update with enriched content mergedSbom.setContent(enrichedContent); @@ -382,4 +409,23 @@ public ResponseEntity generateWithOSI(@RequestParam("projectName") String pro return new ResponseEntity<>(convertedID, HttpStatus.OK); } + private boolean matchesRequestedFormat(SBOMFile file, SerializerFactory.Schema schema, SerializerFactory.Format format) { + SBOMFile.Schema expectedSchema = switch (schema) { + case CDX14 -> SBOMFile.Schema.CYCLONEDX_14; + case SPDX23 -> SBOMFile.Schema.SPDX_23; + default -> null; + }; + + SBOMFile.FileType expectedType = switch (format) { + case JSON -> SBOMFile.FileType.JSON; + case XML -> SBOMFile.FileType.XML; + case TAGVALUE -> SBOMFile.FileType.TAG_VALUE; + }; + + return expectedSchema != null + && expectedType != null + && file.getSchema() == expectedSchema + && file.getFileType() == expectedType; + } + } \ No newline at end of file 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 e6e7de9cc..72e07eaf5 100644 --- a/api/src/main/java/org/svip/api/services/OSIService.java +++ b/api/src/main/java/org/svip/api/services/OSIService.java @@ -140,7 +140,7 @@ public HashMap generateSBOMs(List toolNames) throws IOEx // 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 + .maxStringLength(500_000_000) // 500MB, increased from 100MB to handle very large SBOMs .build()) .build(); ObjectMapper mapper = new ObjectMapper(factory); 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 c0b3de175..cb337d0ce 100644 --- a/api/src/main/java/org/svip/api/services/SBOMFileService.java +++ b/api/src/main/java/org/svip/api/services/SBOMFileService.java @@ -259,17 +259,15 @@ public Long merge(Long[] ids) throws Exception { throw new Exception("Error merging SBOMs: " + e.getMessage()); } - SerializerFactory.Schema schema = SerializerFactory.Schema.SPDX23; + SerializerFactory.Schema schema = SerializerFactory.Schema.CDX14; // serialize merged SBOM - Serializer s = SerializerFactory.createSerializer(schema, SerializerFactory.Format.TAGVALUE, // todo default to - // SPDX JSON for - // now? - true); + Serializer s = SerializerFactory.createSerializer(schema, SerializerFactory.Format.JSON, true); s.setPrettyPrinting(true); String contents; try { - contents = s.writeToString((SVIPSBOM) merged); + org.svip.sbom.model.interfaces.generics.SBOM mergedForSchema = Conversion.convert(merged, SerializerFactory.Schema.SVIP, schema); + contents = s.writeToString((SVIPSBOM) mergedForSchema); } catch (JsonProcessingException | ClassCastException e) { throw new Exception("Error deserializing merged SBOM: " + e.getMessage()); } diff --git a/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java index 0a6176a9a..f3bdc93ee 100644 --- a/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java +++ b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java @@ -35,6 +35,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; @@ -331,6 +332,9 @@ private String mergeVulnerabilityData(String originalSbom, Map s try { JsonNode originalNode = objectMapper.readTree(originalSbom); + // Build mapping from component identifiers to bom-refs + Map componentBomRefMap = buildComponentBomRefMapping(originalNode); + // Collect all vulnerabilities from scan results List allVulnerabilities = new ArrayList<>(); @@ -354,13 +358,13 @@ private String mergeVulnerabilityData(String originalSbom, Map s } // Grype native JSON format exposes "matches" else if (scanResult.has("matches") && scanResult.get("matches").isArray()) { - List converted = convertGrypeMatchesToCycloneDX(scanResult); + List converted = convertGrypeMatchesToCycloneDX(scanResult, componentBomRefMap); allVulnerabilities.addAll(converted); LOGGER.info("Converted {} grype matches to CycloneDX vulnerabilities", converted.size()); } // Trivy format exposes "Results" with nested "Vulnerabilities" else if (scanResult.has("Results") && scanResult.get("Results").isArray()) { - List converted = convertTrivyResultsToCycloneDX(scanResult); + List converted = convertTrivyResultsToCycloneDX(scanResult, componentBomRefMap); allVulnerabilities.addAll(converted); LOGGER.info("Converted {} trivy vulnerabilities to CycloneDX format", converted.size()); } @@ -372,6 +376,9 @@ else if (scanResult.has("Results") && scanResult.get("Results").isArray()) { } } + // Fix vulnerability references to use proper bom-refs + allVulnerabilities = fixVulnerabilityReferences(allVulnerabilities, componentBomRefMap); + // If we have vulnerabilities, add them to the original SBOM if (!allVulnerabilities.isEmpty()) { LOGGER.info("Merging {} vulnerabilities into SBOM", allVulnerabilities.size()); @@ -523,7 +530,212 @@ private String mapEcosystemToSyftType(String ecosystem) { }; } - private List convertGrypeMatchesToCycloneDX(JsonNode grypeOutput) { + /** + * Remap vulnerability affects references to match component bom-refs from a given SBOM. + * + * @param sbomNode The SBOM JSON node that contains components with bom-ref identifiers + * @param vulnerabilities Vulnerabilities whose affects references should align with those bom-refs + * @return Vulnerabilities with corrected affects references where possible + */ + public List remapVulnerabilityReferences(JsonNode sbomNode, List vulnerabilities) { + if (sbomNode == null) { + return vulnerabilities == null ? Collections.emptyList() : vulnerabilities; + } + if (vulnerabilities == null || vulnerabilities.isEmpty()) { + return vulnerabilities == null ? Collections.emptyList() : vulnerabilities; + } + + Map componentBomRefMap = buildComponentBomRefMapping(sbomNode); + if (componentBomRefMap.isEmpty()) { + return vulnerabilities; + } + + return fixVulnerabilityReferences(vulnerabilities, componentBomRefMap); + } + + /** + * Build a mapping from component identifiers (PURL, name@version) to their bom-refs + * + * @param sbomNode The SBOM JSON node + * @return Map of component identifiers to bom-refs + */ + private Map buildComponentBomRefMapping(JsonNode sbomNode) { + Map mapping = new HashMap<>(); + + JsonNode components = sbomNode.get("components"); + if (components == null || !components.isArray()) { + return mapping; + } + + for (JsonNode component : components) { + String bomRef = component.path("bom-ref").asText(""); + if (bomRef.isEmpty()) { + continue; + } + + // Map PURL to bom-ref + String purl = component.path("purl").asText(""); + if (!purl.isEmpty()) { + mapping.put(purl, bomRef); + String decodedPurl = decodeUrl(purl); + if (!decodedPurl.equals(purl)) { + mapping.put(decodedPurl, bomRef); + } + // Also map the base PURL without package-id + int packageIdIndex = purl.indexOf("?package-id="); + if (packageIdIndex > 0) { + String basePurl = purl.substring(0, packageIdIndex); + mapping.put(basePurl, bomRef); + String decodedBasePurl = decodeUrl(basePurl); + if (!decodedBasePurl.equals(basePurl)) { + mapping.put(decodedBasePurl, bomRef); + } + } + } + + // Map name@version to bom-ref + String name = component.path("name").asText(""); + String version = component.path("version").asText(""); + String group = component.path("group").asText(""); + if (!name.isEmpty()) { + mapping.put(name, bomRef); + if (!version.isEmpty()) { + mapping.put(name + "@" + version, bomRef); + } + if (!group.isEmpty()) { + String scopedName = group + "/" + name; + mapping.put(scopedName, bomRef); + if (!version.isEmpty()) { + mapping.put(scopedName + "@" + version, bomRef); + } + } + } + } + + return mapping; + } + + /** + * Fix vulnerability affects references to use proper component bom-refs + * + * @param vulnerabilities List of vulnerability nodes + * @param componentBomRefMap Mapping from component identifiers to bom-refs + * @return Fixed list of vulnerabilities + */ + private List fixVulnerabilityReferences(List vulnerabilities, Map componentBomRefMap) { + List fixedVulnerabilities = new ArrayList<>(); + + for (JsonNode vuln : vulnerabilities) { + if (!(vuln instanceof ObjectNode)) { + fixedVulnerabilities.add(vuln); + continue; + } + + ObjectNode vulnObj = (ObjectNode) vuln.deepCopy(); + JsonNode affects = vulnObj.get("affects"); + + if (affects != null && affects.isArray()) { + ArrayNode fixedAffects = objectMapper.createArrayNode(); + + for (JsonNode affect : affects) { + if (!(affect instanceof ObjectNode)) { + fixedAffects.add(affect); + continue; + } + + ObjectNode affectObj = (ObjectNode) affect.deepCopy(); + String ref = affectObj.path("ref").asText(""); + + // Try to find the correct bom-ref for this reference + String correctBomRef = findCorrectBomRef(ref, componentBomRefMap); + if (correctBomRef != null) { + affectObj.put("ref", correctBomRef); + LOGGER.debug("Fixed vulnerability reference: {} -> {}", ref, correctBomRef); + } + + fixedAffects.add(affectObj); + } + + vulnObj.set("affects", fixedAffects); + } + + fixedVulnerabilities.add(vulnObj); + } + + return fixedVulnerabilities; + } + + /** + * Find the correct bom-ref for a given reference string + * + * @param ref The reference string (could be PURL, name@version, etc.) + * @param componentBomRefMap Mapping from component identifiers to bom-refs + * @return The correct bom-ref, or null if not found + */ + private String findCorrectBomRef(String ref, Map componentBomRefMap) { + // Direct lookup + if (componentBomRefMap.containsKey(ref)) { + return componentBomRefMap.get(ref); + } + + String decodedRef = decodeUrl(ref); + if (!decodedRef.equals(ref) && componentBomRefMap.containsKey(decodedRef)) { + return componentBomRefMap.get(decodedRef); + } + + // Try removing package-id parameter if present + if (ref.startsWith("pkg:")) { + int packageIdIndex = ref.indexOf("?package-id="); + if (packageIdIndex > 0) { + String basePurl = ref.substring(0, packageIdIndex); + if (componentBomRefMap.containsKey(basePurl)) { + return componentBomRefMap.get(basePurl); + } + String decodedBasePurl = decodeUrl(basePurl); + if (!decodedBasePurl.equals(basePurl) && componentBomRefMap.containsKey(decodedBasePurl)) { + return componentBomRefMap.get(decodedBasePurl); + } + } + } + + // Try extracting name@version from PURL + if (ref.startsWith("pkg:")) { + String[] parts = ref.split("/"); + if (parts.length >= 2) { + String lastPart = parts[parts.length - 1]; + // Remove query parameters + int queryIndex = lastPart.indexOf("?"); + if (queryIndex > 0) { + lastPart = lastPart.substring(0, queryIndex); + } + lastPart = decodeUrl(lastPart); + if (componentBomRefMap.containsKey(lastPart)) { + return componentBomRefMap.get(lastPart); + } + } + } + + // If ref already looks like a bom-ref (starts with "bom-ref:"), return as-is + if (ref.startsWith("bom-ref:")) { + return ref; + } + + // Not found, return null (will keep original ref) + return null; + } + + private String decodeUrl(String value) { + if (value == null) { + return null; + } + try { + return java.net.URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (Exception e) { + return value; + } + } + + private List convertGrypeMatchesToCycloneDX(JsonNode grypeOutput, Map componentBomRefMap) { List vulnerabilities = new ArrayList<>(); JsonNode matches = grypeOutput.get("matches"); @@ -585,20 +797,50 @@ private List convertGrypeMatchesToCycloneDX(JsonNode grypeOutput) { ArrayNode affects = objectMapper.createArrayNode(); ObjectNode affect = objectMapper.createObjectNode(); - String bomRef = artifact.path("id").asText(""); - if (bomRef.isEmpty()) { - bomRef = artifact.path("purl").asText(""); + // Try to find the correct bom-ref for this artifact + String artifactRef = null; + + // Try using the artifact's PURL first + String purl = artifact.path("purl").asText(""); + if (!purl.isEmpty()) { + artifactRef = findCorrectBomRef(purl, componentBomRefMap); } - if (bomRef.isEmpty()) { + + // If not found, try using name@version + if (artifactRef == null) { String name = artifact.path("name").asText(""); String version = artifact.path("version").asText(""); if (!name.isEmpty()) { - bomRef = version.isEmpty() ? name : name + "@" + version; + String nameVersion = version.isEmpty() ? name : name + "@" + version; + artifactRef = findCorrectBomRef(nameVersion, componentBomRefMap); + } + } + + // If still not found, try using the artifact ID (which might be a package-id PURL) + if (artifactRef == null) { + String id = artifact.path("id").asText(""); + if (!id.isEmpty()) { + artifactRef = findCorrectBomRef(id, componentBomRefMap); + } + } + + // Fallback to original logic if no mapping found + if (artifactRef == null) { + artifactRef = artifact.path("id").asText(""); + if (artifactRef.isEmpty()) { + artifactRef = artifact.path("purl").asText(""); + } + if (artifactRef.isEmpty()) { + String name = artifact.path("name").asText(""); + String version = artifact.path("version").asText(""); + if (!name.isEmpty()) { + artifactRef = version.isEmpty() ? name : name + "@" + version; + } } } - if (!bomRef.isEmpty()) { - affect.put("ref", bomRef); + if (artifactRef != null && !artifactRef.isEmpty()) { + affect.put("ref", artifactRef); affects.add(affect); } @@ -655,7 +897,7 @@ private void addCvssRating(ArrayNode ratingsArray, JsonNode cvss) { } } - private List convertTrivyResultsToCycloneDX(JsonNode trivyOutput) { + private List convertTrivyResultsToCycloneDX(JsonNode trivyOutput, Map componentBomRefMap) { List vulnerabilities = new ArrayList<>(); JsonNode results = trivyOutput.get("Results"); @@ -734,16 +976,39 @@ private List convertTrivyResultsToCycloneDX(JsonNode trivyOutput) { String pkgName = vuln.path("PkgName").asText(""); String installedVersion = vuln.path("InstalledVersion").asText(""); - String ref = target; - if (ref.isEmpty()) { - ref = pkgName; - if (!installedVersion.isEmpty()) { - ref = ref + "@" + installedVersion; + + // Try to find the correct bom-ref for this package + String artifactRef = null; + + // Try using package name and version + if (!pkgName.isEmpty()) { + String nameVersion = installedVersion.isEmpty() ? pkgName : pkgName + "@" + installedVersion; + artifactRef = findCorrectBomRef(nameVersion, componentBomRefMap); + + // If not found, try just the package name + if (artifactRef == null) { + artifactRef = findCorrectBomRef(pkgName, componentBomRefMap); + } + } + + // If still not found, try using the target (might be a file path or PURL) + if (artifactRef == null && !target.isEmpty()) { + artifactRef = findCorrectBomRef(target, componentBomRefMap); + } + + // Fallback to original logic if no mapping found + if (artifactRef == null) { + artifactRef = target; + if (artifactRef.isEmpty()) { + artifactRef = pkgName; + if (!installedVersion.isEmpty()) { + artifactRef = artifactRef + "@" + installedVersion; + } } } - if (!ref.isEmpty()) { - affect.put("ref", ref); + if (artifactRef != null && !artifactRef.isEmpty()) { + affect.put("ref", artifactRef); affects.add(affect); } diff --git a/api/src/test/java/org/svip/api/services/VulnerabilityScanServiceTest.java b/api/src/test/java/org/svip/api/services/VulnerabilityScanServiceTest.java new file mode 100644 index 000000000..86c09d78b --- /dev/null +++ b/api/src/test/java/org/svip/api/services/VulnerabilityScanServiceTest.java @@ -0,0 +1,224 @@ +package org.svip.api.services; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; + +import static org.junit.jupiter.api.Assertions.*; + +class VulnerabilityScanServiceTest { + + private VulnerabilityScanService vulnerabilityScanService; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() throws Exception { + // Create a simple executor for testing + Executor testExecutor = Runnable::run; // Direct execution for testing + vulnerabilityScanService = new VulnerabilityScanService(testExecutor); + objectMapper = new ObjectMapper(); + + // Use reflection to set private fields + java.lang.reflect.Field objectMapperField = VulnerabilityScanService.class.getDeclaredField("objectMapper"); + objectMapperField.setAccessible(true); + objectMapperField.set(vulnerabilityScanService, objectMapper); + + java.lang.reflect.Field scanningEnabledField = VulnerabilityScanService.class.getDeclaredField("scanningEnabled"); + scanningEnabledField.setAccessible(true); + scanningEnabledField.set(vulnerabilityScanService, false); + } + + @Test + void testBuildComponentBomRefMapping() throws Exception { + // Create a sample SBOM with components + String sbomJson = """ + { + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "components": [ + { + "bom-ref": "bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef", + "name": "debug", + "version": "3.2.6", + "purl": "pkg:npm/debug@3.2.6" + }, + { + "bom-ref": "bom-ref:uuid:d3109fda-712c-4713-b973-1d25203e42b1", + "name": "moment", + "version": "2.18.1", + "purl": "pkg:npm/moment@2.18.1" + }, + { + "bom-ref": "bom-ref:uuid:2020650b-b02d-4ed6-a7d0-818f10b6edef", + "name": "mime", + "group": "@types", + "version": "1.3.5", + "purl": "pkg:npm/%40types/mime@1.3.5" + } + ] + } + """; + + JsonNode sbomNode = objectMapper.readTree(sbomJson); + + // Use reflection to call the private method + Method method = VulnerabilityScanService.class.getDeclaredMethod("buildComponentBomRefMapping", JsonNode.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + Map mapping = (Map) method.invoke(vulnerabilityScanService, sbomNode); + + assertNotNull(mapping); + + // Check that PURLs map to correct bom-refs + assertEquals("bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef", + mapping.get("pkg:npm/debug@3.2.6")); + assertEquals("bom-ref:uuid:d3109fda-712c-4713-b973-1d25203e42b1", + mapping.get("pkg:npm/moment@2.18.1")); + assertEquals("bom-ref:uuid:2020650b-b02d-4ed6-a7d0-818f10b6edef", + mapping.get("pkg:npm/%40types/mime@1.3.5")); + assertEquals("bom-ref:uuid:2020650b-b02d-4ed6-a7d0-818f10b6edef", + mapping.get("pkg:npm/@types/mime@1.3.5")); + + // Check that name@version also maps correctly + assertEquals("bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef", + mapping.get("debug@3.2.6")); + assertEquals("bom-ref:uuid:d3109fda-712c-4713-b973-1d25203e42b1", + mapping.get("moment@2.18.1")); + assertEquals("bom-ref:uuid:2020650b-b02d-4ed6-a7d0-818f10b6edef", + mapping.get("mime@1.3.5")); + assertEquals("bom-ref:uuid:2020650b-b02d-4ed6-a7d0-818f10b6edef", + mapping.get("@types/mime@1.3.5")); + + // Check that just names also map + assertEquals("bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef", + mapping.get("debug")); + assertEquals("bom-ref:uuid:d3109fda-712c-4713-b973-1d25203e42b1", + mapping.get("moment")); + assertEquals("bom-ref:uuid:2020650b-b02d-4ed6-a7d0-818f10b6edef", + mapping.get("mime")); + assertEquals("bom-ref:uuid:2020650b-b02d-4ed6-a7d0-818f10b6edef", + mapping.get("@types/mime")); + } + + @Test + void testFindCorrectBomRef() throws Exception { + Map componentBomRefMap = new HashMap<>(); + componentBomRefMap.put("pkg:npm/debug@3.2.6", "bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef"); + componentBomRefMap.put("debug@3.2.6", "bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef"); + componentBomRefMap.put("debug", "bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef"); + + // Get the findCorrectBomRef method + Method findMethod = VulnerabilityScanService.class.getDeclaredMethod( + "findCorrectBomRef", String.class, Map.class); + findMethod.setAccessible(true); + + // Test with PURL containing package-id + String ref1 = "pkg:npm/debug@3.2.6?package-id=f5ae201807959921"; + String result1 = (String) findMethod.invoke(vulnerabilityScanService, ref1, componentBomRefMap); + assertEquals("bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef", result1); + + // Test with plain PURL + String ref2 = "pkg:npm/debug@3.2.6"; + String result2 = (String) findMethod.invoke(vulnerabilityScanService, ref2, componentBomRefMap); + assertEquals("bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef", result2); + + // Test with name@version + String ref3 = "debug@3.2.6"; + String result3 = (String) findMethod.invoke(vulnerabilityScanService, ref3, componentBomRefMap); + assertEquals("bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef", result3); + + // Test with already correct bom-ref + String ref4 = "bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef"; + String result4 = (String) findMethod.invoke(vulnerabilityScanService, ref4, componentBomRefMap); + assertEquals("bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef", result4); + + // Test with unknown reference (should return null) + String ref5 = "pkg:npm/unknown@1.0.0"; + String result5 = (String) findMethod.invoke(vulnerabilityScanService, ref5, componentBomRefMap); + assertNull(result5); + } + + @Test + void testFixVulnerabilityReferences() throws Exception { + String sbomJson = """ + { + "components": [ + { + "bom-ref": "bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef", + "name": "debug", + "version": "3.2.6", + "purl": "pkg:npm/debug@3.2.6" + }, + { + "bom-ref": "bom-ref:uuid:d3109fda-712c-4713-b973-1d25203e42b1", + "name": "moment", + "version": "2.18.1", + "purl": "pkg:npm/moment@2.18.1" + }, + { + "bom-ref": "bom-ref:uuid:2020650b-b02d-4ed6-a7d0-818f10b6edef", + "name": "mime", + "group": "@types", + "version": "1.3.5", + "purl": "pkg:npm/%40types/mime@1.3.5" + } + ] + } + """; + + JsonNode sbomNode = objectMapper.readTree(sbomJson); + + // Create sample vulnerabilities with incorrect refs + String vulnsJson = """ + [ + { + "id": "CVE-2017-16137", + "affects": [ + { + "ref": "pkg:npm/debug@3.2.6?package-id=f5ae201807959921" + } + ] + }, + { + "id": "CVE-2017-18214", + "affects": [ + { + "ref": "pkg:npm/moment@2.18.1?package-id=c83ae2c1e8e3f984" + } + ] + }, + { + "id": "CVE-2017-16138", + "affects": [ + { + "ref": "pkg:npm/@types/mime@1.3.5" + } + ] + } + ] + """; + + ArrayNode vulnsArray = (ArrayNode) objectMapper.readTree(vulnsJson); + List vulnerabilities = new ArrayList<>(); + vulnsArray.forEach(v -> vulnerabilities.add(v)); + + List fixedVulns = vulnerabilityScanService.remapVulnerabilityReferences(sbomNode, vulnerabilities); + + assertNotNull(fixedVulns); + assertEquals("bom-ref:uuid:e7b8e8cc-ec48-4b0b-b73f-43353b74c5ef", + fixedVulns.get(0).get("affects").get(0).get("ref").asText()); + assertEquals("bom-ref:uuid:d3109fda-712c-4713-b973-1d25203e42b1", + fixedVulns.get(1).get("affects").get(0).get("ref").asText()); + assertEquals("bom-ref:uuid:2020650b-b02d-4ed6-a7d0-818f10b6edef", + fixedVulns.get(2).get("affects").get(0).get("ref").asText()); + } +} diff --git a/core/src/main/java/org/svip/merge/MergerUtils.java b/core/src/main/java/org/svip/merge/MergerUtils.java index 853a11960..fd80c58c7 100644 --- a/core/src/main/java/org/svip/merge/MergerUtils.java +++ b/core/src/main/java/org/svip/merge/MergerUtils.java @@ -69,6 +69,46 @@ public abstract class MergerUtils extends Merger { protected static SBOM mergeToSchema(SBOM A, SBOM B, Set componentsA, Set componentsB, SBOM mainSBOM, SBOMBuilder builder, SerializerFactory.Schema targetSchema, String newName) { + // Track old UID -> new UID mappings for dependency remapping + java.util.HashMap uidMapping = new java.util.HashMap<>(); + + // Pre-populate with original component UIDs and purls from both SBOMs + if (componentsA != null) { + for (Component comp : componentsA) { + if (comp != null && comp.getUID() != null) { + uidMapping.put(comp.getUID(), comp.getUID()); + if (comp instanceof SVIPComponentObject) { + SVIPComponentObject svipComp = (SVIPComponentObject) comp; + if (svipComp.getPURLs() != null) { + for (String purl : svipComp.getPURLs()) { + if (purl != null && !purl.isEmpty()) { + uidMapping.put(purl, comp.getUID()); + } + } + } + } + } + } + } + + if (componentsB != null) { + for (Component comp : componentsB) { + if (comp != null && comp.getUID() != null) { + uidMapping.put(comp.getUID(), comp.getUID()); + if (comp instanceof SVIPComponentObject) { + SVIPComponentObject svipComp = (SVIPComponentObject) comp; + if (svipComp.getPURLs() != null) { + for (String purl : svipComp.getPURLs()) { + if (purl != null && !purl.isEmpty()) { + uidMapping.put(purl, comp.getUID()); + } + } + } + } + } + } + } + /** Assign all top level data for the new SBOM **/ // Format @@ -143,18 +183,96 @@ else if (mainSBOM.getName() == null || mainSBOM.getName().isEmpty()) mergedComponents = componentsB; } - if (mergedComponents != null) mergedComponents.forEach(x -> builder.addComponent(x)); + if (mergedComponents != null) { + for (Component comp : mergedComponents) { + builder.addComponent(comp); + if (comp != null && comp.getUID() != null) { + // Map the component's own UID + uidMapping.put(comp.getUID(), comp.getUID()); + + // Also map purls to UID for dependency remapping (cdxgen uses purls as refs) + if (comp instanceof SVIPComponentObject) { + SVIPComponentObject svipComp = (SVIPComponentObject) comp; + if (svipComp.getPURLs() != null) { + for (String purl : svipComp.getPURLs()) { + if (purl != null && !purl.isEmpty()) { + uidMapping.put(purl, comp.getUID()); + } + } + } + } + } + } + } - // Relationships + // Relationships - remap refs to merged component UIDs + System.out.println("[MERGE-DEBUG] UID mapping size: " + uidMapping.size()); + System.out.println("[MERGE-DEBUG] First 10 mappings:"); + uidMapping.entrySet().stream().limit(10).forEach(e -> + System.out.println(" " + e.getKey() + " -> " + e.getValue()) + ); + if (A.getRelationships() != null) { Map> relationshipsA = A.getRelationships(); - relationshipsA.keySet().forEach(x -> relationshipsA.get(x).forEach(y -> builder.addRelationship(x, y))); + int totalA = relationshipsA.size(); + int remappedA = 0; + for (String originalRef : relationshipsA.keySet()) { + if (originalRef == null) continue; + + String mappedRef = uidMapping.getOrDefault(originalRef, null); + if (mappedRef == null) { + System.err.println("[MERGE-DEBUG] SBOM A: No mapping found for ref: " + originalRef); + continue; + } + + Set rels = relationshipsA.get(originalRef); + if (rels != null) { + for (Relationship rel : rels) { + if (rel == null || rel.getOtherUID() == null) continue; + + String mappedTarget = uidMapping.getOrDefault(rel.getOtherUID(), null); + if (mappedTarget != null) { + builder.addRelationship(mappedRef, new Relationship(mappedTarget, rel.getRelationshipType())); + remappedA++; + } else { + System.err.println("[MERGE-DEBUG] SBOM A: No mapping for dependency target: " + rel.getOtherUID()); + } + } + } + } + System.out.println("[MERGE-DEBUG] SBOM A: Remapped " + remappedA + " relationships from " + totalA + " entries"); } if (B.getRelationships() != null) { Map> relationshipsB = B.getRelationships(); - relationshipsB.keySet().forEach(x -> relationshipsB.get(x).forEach(y -> builder.addRelationship(x, y))); + int totalB = relationshipsB.size(); + int remappedB = 0; + for (String originalRef : relationshipsB.keySet()) { + if (originalRef == null) continue; + + String mappedRef = uidMapping.getOrDefault(originalRef, null); + if (mappedRef == null) { + System.err.println("[MERGE-DEBUG] SBOM B: No mapping found for ref: " + originalRef); + continue; + } + + Set rels = relationshipsB.get(originalRef); + if (rels != null) { + for (Relationship rel : rels) { + if (rel == null || rel.getOtherUID() == null) continue; + + String mappedTarget = uidMapping.getOrDefault(rel.getOtherUID(), null); + if (mappedTarget != null) { + builder.addRelationship(mappedRef, new Relationship(mappedTarget, rel.getRelationshipType())); + remappedB++; + } else { + System.err.println("[MERGE-DEBUG] SBOM B: No mapping for dependency target: " + rel.getOtherUID()); + } + } + } + } + System.out.println("[MERGE-DEBUG] SBOM B: Remapped " + remappedB + " relationships from " + totalB + " entries"); } // External References diff --git a/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java b/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java index 1d603c1c1..e80d5a258 100644 --- a/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java +++ b/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java @@ -207,18 +207,11 @@ private Map> getSanitizedRelationships(SVIPSBOM sbom) } if (cleaned.isEmpty()) { - String rootUid = resolveRootUid(sbom); - if (rootUid != null) { - Set fallback = new LinkedHashSet<>(); - for (Component component : sbom.getComponents()) { - if (component == null) continue; - String uid = normalizeRef(component.getUID()); - if (uid == null || uid.equals(rootUid)) continue; - fallback.add(new Relationship(uid, "DEPENDS_ON")); - } - if (!fallback.isEmpty()) { - cleaned.put(rootUid, fallback); - } + for (Component component : sbom.getComponents()) { + if (component == null) continue; + String uid = normalizeRef(component.getUID()); + if (uid == null) continue; + cleaned.put(uid, new LinkedHashSet<>()); } } diff --git a/osi/osi/configs/tools/cdxgen.yml b/osi/osi/configs/tools/cdxgen.yml index febf40ef0..eb7846144 100644 --- a/osi/osi/configs/tools/cdxgen.yml +++ b/osi/osi/configs/tools/cdxgen.yml @@ -45,4 +45,4 @@ profiles: - "Leiningen" - "Swift" commands: - - "cdxgen -r -o $SBOM_OUT/cdxgen-cdx14.json" \ No newline at end of file + - "cdxgen -r -o $SBOM_OUT/cdxgen-cdx14.json --spec-version 1.4 --include-formulation" \ No newline at end of file diff --git a/osi/osi/configs/tools/syft.yml b/osi/osi/configs/tools/syft.yml index f6a1e6d24..167e6049e 100644 --- a/osi/osi/configs/tools/syft.yml +++ b/osi/osi/configs/tools/syft.yml @@ -24,7 +24,7 @@ profiles: - "Cargo" - "Swift" commands: - - "syft . -o cyclonedx-json@1.4=$SBOM_OUT/syft-cdx14.json" + - "syft . -o cyclonedx-json@1.4=$SBOM_OUT/syft-cdx14.json --catalogers all" - schema: "spdx" spec_version: "2.3" format: "spdx" diff --git a/osi/osi/osi_server.py b/osi/osi/osi_server.py index 044e52a03..9b0a23ff3 100644 --- a/osi/osi/osi_server.py +++ b/osi/osi/osi_server.py @@ -134,28 +134,53 @@ def _rel(path: str) -> str: node_manifests = glob.glob(os.path.join(project_dir, "**", "package.json"), recursive=True) for manifest in node_manifests: base_dir = os.path.dirname(manifest) - package_lock = os.path.join(base_dir, "package-lock.json") - if not os.path.exists(package_lock) and self._command_exists("npm"): - self._run_prepare_command( - ["npm", "install", "--package-lock-only", "--ignore-scripts"], - base_dir, - f"npm install --package-lock-only ({_rel(base_dir)})", - ) - - yarn_lock = os.path.join(base_dir, "yarn.lock") - if not os.path.exists(yarn_lock) and self._command_exists("yarn"): + + # Full install for dependency tree analysis (production only, no scripts) + if self._command_exists("npm"): + self._app.logger.info(f"Prepare | Installing dependencies for dependency graph analysis in {_rel(base_dir)}") + + # First try npm ci for faster, reproducible installs + try: + result = subprocess.run( + ["npm", "ci", "--omit=dev", "--ignore-scripts"], + cwd=base_dir, + capture_output=True, + text=True, + timeout=300 + ) + if result.returncode != 0: + # npm ci failed, try regular npm install as fallback + self._app.logger.warning(f"Prepare | npm ci failed in {_rel(base_dir)}, falling back to npm install") + + # Check if node_modules already exists + node_modules_path = os.path.join(base_dir, "node_modules") + if os.path.exists(node_modules_path): + self._app.logger.info(f"Prepare | Found existing node_modules in {_rel(base_dir)}, will use for scanning") + else: + # Try npm install as fallback + self._run_prepare_command( + ["npm", "install", "--omit=dev", "--ignore-scripts"], + base_dir, + f"npm install --omit=dev ({_rel(base_dir)})", + ) + else: + self._app.logger.info(f"Prepare | npm ci succeeded in {_rel(base_dir)}") + except subprocess.TimeoutExpired: + self._app.logger.warning(f"Prepare | npm ci timed out in {_rel(base_dir)}") + except Exception as e: + self._app.logger.warning(f"Prepare | npm ci failed with error: {e}") + + elif self._command_exists("yarn") and os.path.exists(os.path.join(base_dir, "yarn.lock")): self._run_prepare_command( - ["yarn", "install", "--silent", "--mode=skip-build"], + ["yarn", "install", "--production", "--frozen-lockfile", "--ignore-scripts"], base_dir, - f"yarn install ({_rel(base_dir)})", + f"yarn install --production ({_rel(base_dir)})", ) - - pnpm_lock = os.path.join(base_dir, "pnpm-lock.yaml") - if not os.path.exists(pnpm_lock) and self._command_exists("pnpm"): + elif self._command_exists("pnpm") and os.path.exists(os.path.join(base_dir, "pnpm-lock.yaml")): self._run_prepare_command( - ["pnpm", "install", "--lockfile-only", "--ignore-scripts"], + ["pnpm", "install", "--prod", "--frozen-lockfile", "--ignore-scripts"], base_dir, - f"pnpm install --lockfile-only ({_rel(base_dir)})", + f"pnpm install --prod ({_rel(base_dir)})", ) go_modules = glob.glob(os.path.join(project_dir, "**", "go.mod"), recursive=True) @@ -184,30 +209,46 @@ def _rel(path: str) -> str: self._app.logger.warning(f"Prepare | Unable to read pyproject.toml at {_rel(pyproject)}: {exc}") pyproject_contents = "" - if "tool.poetry" in pyproject_contents and not os.path.exists(os.path.join(base_dir, "poetry.lock")): + if "tool.poetry" in pyproject_contents: if self._command_exists("poetry"): + self._app.logger.info(f"Prepare | Installing Python dependencies via poetry in {_rel(base_dir)}") self._run_prepare_command( - ["poetry", "lock", "--no-update"], + ["poetry", "install", "--no-dev", "--no-root"], base_dir, - f"poetry lock ({_rel(base_dir)})", + f"poetry install --no-dev ({_rel(base_dir)})", ) else: self._app.logger.warning( - f"Prepare | poetry not available; skipping poetry lock generation for {_rel(base_dir)}") + f"Prepare | poetry not available; skipping install for {_rel(base_dir)}") pipfiles = glob.glob(os.path.join(project_dir, "**", "Pipfile"), recursive=True) for pipfile in pipfiles: base_dir = os.path.dirname(pipfile) - if not os.path.exists(os.path.join(base_dir, "Pipfile.lock")): - if self._command_exists("pipenv"): - self._run_prepare_command( - ["pipenv", "lock"], - base_dir, - f"pipenv lock ({_rel(base_dir)})", - ) - else: - self._app.logger.warning( - f"Prepare | pipenv not available; skipping Pipfile.lock generation for {_rel(base_dir)}") + if self._command_exists("pipenv"): + self._app.logger.info(f"Prepare | Installing Python dependencies via pipenv in {_rel(base_dir)}") + self._run_prepare_command( + ["pipenv", "install", "--deploy", "--ignore-pipfile"], + base_dir, + f"pipenv install ({_rel(base_dir)})", + ) + else: + self._app.logger.warning( + f"Prepare | pipenv not available; skipping install for {_rel(base_dir)}") + + # Handle requirements.txt for pip-based projects + requirements_files = glob.glob(os.path.join(project_dir, "**", "requirements.txt"), recursive=True) + for req_file in requirements_files: + base_dir = os.path.dirname(req_file) + # Skip if poetry or pipenv already handled it + if os.path.exists(os.path.join(base_dir, "pyproject.toml")) or os.path.exists(os.path.join(base_dir, "Pipfile")): + continue + if self._command_exists("pip3"): + self._app.logger.info(f"Prepare | Installing Python dependencies via pip in {_rel(base_dir)}") + self._run_prepare_command( + ["pip3", "install", "--target", os.path.join(base_dir, ".pip-packages"), "-r", req_file, "--no-cache-dir"], + base_dir, + f"pip install -r requirements.txt ({_rel(base_dir)})", + ) dotnet_projects = glob.glob(os.path.join(project_dir, "**", "*.sln"), recursive=True) + \ glob.glob(os.path.join(project_dir, "**", "*.csproj"), recursive=True) From 3fbfe71ef10310dbaea9bb0ee6305d8b6c982cee Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sat, 22 Nov 2025 10:31:19 -1000 Subject: [PATCH 11/11] chore(repo): apply review feedback --- Dockerfile | 4 +- api/build.gradle | 1 + .../org/svip/api/config/JacksonConfig.java | 47 ++----- .../svip/api/controller/SBOMController.java | 118 +++++++++++++++++- .../controller/VulnerabilityController.java | 49 ++++++++ .../services/VulnerabilityScanService.java | 39 +++++- compose.dev.yaml | 2 +- compose.yaml | 2 +- .../serializer/CDX14JSONSerializer.java | 16 +++ osi/Dockerfile | 10 +- osi/osi/configs/tools/grype.yml | 10 -- osi/osi/configs/tools/trivy.yml | 10 -- osi/osi/osi_server.py | 5 + 13 files changed, 243 insertions(+), 70 deletions(-) diff --git a/Dockerfile b/Dockerfile index fff772bd6..f0660bcaa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,10 +19,10 @@ FROM eclipse-temurin:21-jre-jammy AS runtime USER root RUN apt-get update && apt-get install -y curl wget ca-certificates # Install Grype -ARG GRYPE_VERSION=0.84.0 +ARG GRYPE_VERSION=0.104.0 RUN curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v${GRYPE_VERSION} # Install Trivy -ARG TRIVY_VERSION=0.58.1 +ARG TRIVY_VERSION=0.67.2 RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v${TRIVY_VERSION} # Install OSV Scanner - using install script RUN curl -L https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64 -o /usr/local/bin/osv-scanner && chmod +x /usr/local/bin/osv-scanner || echo "OSV Scanner installation failed, continuing without it" diff --git a/api/build.gradle b/api/build.gradle index 3f772fac0..32db85eb0 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -20,6 +20,7 @@ dependencies { implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'com.mikemybytes:junit5-formatted-source:1.0.1' implementation 'com.mysql:mysql-connector-j:9.3.0' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.2' implementation 'org.cyclonedx:cyclonedx-core-java:10.2.1' implementation 'org.springframework:spring-web:6.2.9' implementation 'org.springframework.boot:spring-boot-autoconfigure:3.5.4' diff --git a/api/src/main/java/org/svip/api/config/JacksonConfig.java b/api/src/main/java/org/svip/api/config/JacksonConfig.java index 2b63034eb..5a3974f60 100644 --- a/api/src/main/java/org/svip/api/config/JacksonConfig.java +++ b/api/src/main/java/org/svip/api/config/JacksonConfig.java @@ -25,51 +25,28 @@ package org.svip.api.config; import com.fasterxml.jackson.core.StreamReadConstraints; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; /** - * Jackson configuration to handle large SBOM files - * - * This configuration increases the maximum allowed string length for JSON parsing - * to handle very large SBOM files (up to 500MB of JSON text). + * Configures Jackson to handle Java 8+ time types and large SBOM payloads. */ @Configuration public class JacksonConfig { - - /** - * Configure Jackson to handle larger JSON strings - * Increases the max string length from 100MB to 500MB - */ + @Bean - public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> { - builder.postConfigurer(objectMapper -> { - objectMapper.getFactory().setStreamReadConstraints( - StreamReadConstraints.builder() - .maxStringLength(500_000_000) // 500MB limit for JSON strings - .build() - ); - }); + builder.modulesToInstall(new JavaTimeModule()); + builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + builder.postConfigurer(objectMapper -> objectMapper.getFactory().setStreamReadConstraints( + StreamReadConstraints.builder() + .maxStringLength(500_000_000) // 500MB limit for JSON strings + .build() + )); }; } - - /** - * Create a primary ObjectMapper bean with increased limits - * This will be used by default throughout the application - */ - @Bean - @Primary - public ObjectMapper objectMapper() { - ObjectMapper mapper = new ObjectMapper(); - mapper.getFactory().setStreamReadConstraints( - StreamReadConstraints.builder() - .maxStringLength(500_000_000) // 500MB limit for JSON strings - .build() - ); - return mapper; - } } \ No newline at end of file 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 19ae37ebf..a50ed0b10 100644 --- a/api/src/main/java/org/svip/api/controller/SBOMController.java +++ b/api/src/main/java/org/svip/api/controller/SBOMController.java @@ -25,6 +25,8 @@ package org.svip.api.controller; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -34,12 +36,17 @@ import org.svip.api.entities.SBOMFile; import org.svip.api.requests.UploadSBOMFileInput; import org.svip.api.services.SBOMFileService; +import org.svip.api.services.VulnerabilityHistoryService; +import org.svip.api.services.VulnerabilityScanService; import org.svip.conversion.ConversionException; import org.svip.sbom.builder.SBOMBuilderException; import org.svip.serializers.SerializerFactory; import org.svip.serializers.exceptions.DeserializerException; import org.svip.serializers.exceptions.SerializerException; +import java.util.ArrayList; +import java.util.List; + /** * REST API Controller for managing SBOM and SBOM operations * @@ -55,14 +62,24 @@ public class SBOMController { public static final Logger LOGGER = LoggerFactory.getLogger(SBOMController.class); private final SBOMFileService sbomService; + private final VulnerabilityHistoryService vulnerabilityHistoryService; + private final VulnerabilityScanService vulnerabilityScanService; + private final ObjectMapper objectMapper; /** * Create new Controller with services * * @param sbomService Service for handling SBOM queries + * @param vulnerabilityHistoryService Service for recording vulnerability stats */ - public SBOMController(SBOMFileService sbomService) { + public SBOMController(SBOMFileService sbomService, + VulnerabilityHistoryService vulnerabilityHistoryService, + VulnerabilityScanService vulnerabilityScanService, + ObjectMapper objectMapper) { this.sbomService = sbomService; + this.vulnerabilityHistoryService = vulnerabilityHistoryService; + this.vulnerabilityScanService = vulnerabilityScanService; + this.objectMapper = objectMapper; } @@ -87,13 +104,19 @@ public ResponseEntity upload(@RequestBody UploadSBOMFileInput uploadSBOMIn // Attempt to deserialize sbomFile.toSBOMObject(); - this.sbomService.upload(sbomFile); + SBOMFile savedFile = this.sbomService.upload(sbomFile); + + if (vulnerabilityScanService.isEnabled()) { + runScansAndRecord(savedFile); + } else { + recordInlineVulnerabilities(savedFile); + } // Log - LOGGER.info("POST /svip/sboms - Uploaded SBOM with ID " + sbomFile.getId() + ": " + sbomFile.getName()); + LOGGER.info("POST /svip/sboms - Uploaded SBOM with ID " + savedFile.getId() + ": " + savedFile.getName()); // Return ID - return new ResponseEntity<>(sbomFile.getId(), HttpStatus.OK); + return new ResponseEntity<>(savedFile.getId(), HttpStatus.OK); } catch (IllegalArgumentException | JsonProcessingException e) { // Problem with parsing @@ -279,4 +302,91 @@ public ResponseEntity delete(@RequestParam("id") Long id) { return new ResponseEntity<>(id, HttpStatus.OK); } + private void runScansAndRecord(SBOMFile sbomFile) { + try { + LOGGER.info("POST /svip/sboms - Running vulnerability scans for {}", sbomFile.getName()); + String enrichedContent = vulnerabilityScanService.runVulnerabilityScans( + sbomFile.getContent(), + sbomFile.getName() + ); + + if (enrichedContent != null && !enrichedContent.isBlank()) { + sbomFile.setContent(enrichedContent); + sbomService.upload(sbomFile); + } + + recordVulnerabilities(sbomFile, vulnerabilityScanService.getConfiguredToolsCsv()); + } catch (Exception e) { + LOGGER.warn("POST /svip/sboms - Failed to run scans for {}: {}. Falling back to inline vulnerabilities.", + sbomFile.getName(), e.getMessage()); + recordInlineVulnerabilities(sbomFile); + } + } + + private void recordInlineVulnerabilities(SBOMFile sbomFile) { + recordVulnerabilities(sbomFile, null); + } + + private void recordVulnerabilities(SBOMFile sbomFile, String scannersOverride) { + try { + JsonNode root = objectMapper.readTree(sbomFile.getContent()); + List vulnerabilities = extractVulnerabilities(root); + if (vulnerabilities.isEmpty()) { + LOGGER.info("POST /svip/sboms - No vulnerabilities found in {}", sbomFile.getName()); + return; + } + + String projectName = extractProjectName(root, sbomFile.getName()); + String scannersUsed = (scannersOverride != null && !scannersOverride.isBlank()) + ? scannersOverride + : determineScanners(root); + + vulnerabilityHistoryService.recordVulnerabilities( + sbomFile.getId(), + projectName, + sbomFile.getName(), + vulnerabilities, + scannersUsed + ); + } catch (Exception e) { + LOGGER.warn("POST /svip/sboms - Unable to record vulnerabilities for {}: {}", sbomFile.getName(), e.getMessage()); + } + } + + private List extractVulnerabilities(JsonNode root) { + List vulnerabilities = new ArrayList<>(); + JsonNode vulnerabilitiesNode = root.path("vulnerabilities"); + if (vulnerabilitiesNode.isArray()) { + vulnerabilitiesNode.forEach(vulnerabilities::add); + } + return vulnerabilities; + } + + private String extractProjectName(JsonNode root, String fallback) { + JsonNode component = root.path("metadata").path("component"); + if (component.hasNonNull("name")) { + String name = component.get("name").asText("").trim(); + if (!name.isEmpty()) { + return name; + } + } + return fallback; + } + + private String determineScanners(JsonNode root) { + JsonNode tools = root.path("metadata").path("tools"); + if (!tools.isArray()) { + return "inline"; + } + + List toolNames = new ArrayList<>(); + tools.forEach(tool -> { + String name = tool.path("name").asText("").trim(); + if (!name.isEmpty()) { + toolNames.add(name); + } + }); + + return toolNames.isEmpty() ? "inline" : String.join(",", toolNames); + } } diff --git a/api/src/main/java/org/svip/api/controller/VulnerabilityController.java b/api/src/main/java/org/svip/api/controller/VulnerabilityController.java index 90b1e68f2..bd90c735e 100644 --- a/api/src/main/java/org/svip/api/controller/VulnerabilityController.java +++ b/api/src/main/java/org/svip/api/controller/VulnerabilityController.java @@ -16,6 +16,9 @@ import java.util.List; import java.util.Map; +/** + * REST controller exposing vulnerability history trends and alert management endpoints. + */ @RestController @RequestMapping("/svip/vulnerabilities") public class VulnerabilityController { @@ -23,6 +26,12 @@ public class VulnerabilityController { private final VulnerabilityHistoryService historyService; private final VulnerabilityAlertRepository alertRepository; + /** + * Creates a new controller with the required services. + * + * @param historyService service for tracking vulnerability history + * @param alertRepository repository used for persisting alerts + */ public VulnerabilityController(VulnerabilityHistoryService historyService, VulnerabilityAlertRepository alertRepository) { this.historyService = historyService; @@ -30,11 +39,23 @@ public VulnerabilityController(VulnerabilityHistoryService historyService, } // Historical Tracking APIs + /** + * Returns the list of projects that currently have SBOMs and vulnerability records. + * + * @return list of project names + */ @GetMapping("/history/projects") public ResponseEntity> getAllProjects() { return ResponseEntity.ok(historyService.getAllProjects()); } + /** + * Returns vulnerability history for a project across the requested time window. + * + * @param projectName project identifier + * @param days number of days to include in the trend + * @return list of daily history entries + */ @GetMapping("/history/{projectName}") public ResponseEntity> getHistory( @PathVariable String projectName, @@ -42,22 +63,43 @@ public ResponseEntity> getHistory( return ResponseEntity.ok(historyService.getTrend(projectName, days)); } + /** + * Returns the most recent vulnerability history snapshot for the project. + * + * @param projectName project identifier + * @return latest history record + */ @GetMapping("/history/{projectName}/latest") public ResponseEntity getLatest(@PathVariable String projectName) { return ResponseEntity.ok(historyService.getLatest(projectName)); } // Alert APIs + /** + * Returns all vulnerability alerts ordered by recency. + * + * @return list of alerts + */ @GetMapping("/alerts") public ResponseEntity> getAllAlerts() { return ResponseEntity.ok(alertRepository.findAllByOrderByCreatedAtDesc()); } + /** + * Returns only unacknowledged vulnerability alerts. + * + * @return list of pending alerts + */ @GetMapping("/alerts/unacknowledged") public ResponseEntity> getUnacknowledgedAlerts() { return ResponseEntity.ok(alertRepository.findByAcknowledgedFalseOrderByCreatedAtDesc()); } + /** + * Returns aggregate alert statistics for quick dashboard summaries. + * + * @return map containing alert counts keyed by severity groupings + */ @GetMapping("/alerts/stats") public ResponseEntity> getAlertStats() { Map stats = new HashMap<>(); @@ -67,6 +109,13 @@ public ResponseEntity> getAlertStats() { return ResponseEntity.ok(stats); } + /** + * Marks an alert as acknowledged by the provided user. + * + * @param alertId alert identifier + * @param acknowledgedBy operator performing the acknowledgement + * @return empty 200 response when applied + */ @PostMapping("/alerts/{alertId}/acknowledge") public ResponseEntity acknowledgeAlert( @PathVariable Long alertId, diff --git a/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java index f3bdc93ee..09792bd16 100644 --- a/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java +++ b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java @@ -62,8 +62,9 @@ public class VulnerabilityScanService { private final ObjectMapper objectMapper; private final Executor vulnerabilityScanExecutor; - public VulnerabilityScanService(@Qualifier("vulnerabilityScanExecutor") Executor vulnerabilityScanExecutor) { - this.objectMapper = new ObjectMapper(); + public VulnerabilityScanService(@Qualifier("vulnerabilityScanExecutor") Executor vulnerabilityScanExecutor, + ObjectMapper objectMapper) { + this.objectMapper = objectMapper.copy(); this.vulnerabilityScanExecutor = vulnerabilityScanExecutor; } @@ -76,6 +77,16 @@ public boolean isEnabled() { return scanningEnabled; } + /** + * Provides the configured scanner names as a comma-separated string. + * + * @return Scanner list in CSV form (e.g., "grype,trivy") + */ + public String getConfiguredToolsCsv() { + List tools = parseScanTools(); + return String.join(",", tools); + } + /** * Run vulnerability scans on an SBOM and merge vulnerability data * Executes scanners directly via command line (not through OSI container) @@ -401,6 +412,14 @@ else if (scanResult.has("Results") && scanResult.get("Results").isArray()) { } } + /** + * Normalizes an SBOM to improve scanner coverage by synthesizing bom-refs, + * ensuring optional metadata is present, and applying Anchore compatibility tweaks. + * + * @param sbomContents raw SBOM contents supplied by the user + * @param sbomFileName associated file name, used for logging + * @return normalized SBOM JSON payload + */ private String normalizeSbomForScanning(String sbomContents, String sbomFileName) { try { JsonNode root = objectMapper.readTree(sbomContents); @@ -735,6 +754,14 @@ private String decodeUrl(String value) { } } + /** + * Converts Grype match responses into CycloneDX vulnerability entries that align with + * the bom-refs present in the enriched SBOM. + * + * @param grypeOutput parsed Grype JSON document + * @param componentBomRefMap fallback lookup from package identifiers to bom-refs + * @return list of CycloneDX vulnerability nodes + */ private List convertGrypeMatchesToCycloneDX(JsonNode grypeOutput, Map componentBomRefMap) { List vulnerabilities = new ArrayList<>(); @@ -897,6 +924,14 @@ private void addCvssRating(ArrayNode ratingsArray, JsonNode cvss) { } } + /** + * Converts Trivy SBOM scan output into CycloneDX vulnerability entries while preserving + * the component references for downstream dashboards. + * + * @param trivyOutput parsed Trivy JSON document + * @param componentBomRefMap fallback lookup from package identifiers to bom-refs + * @return list of CycloneDX vulnerability nodes + */ private List convertTrivyResultsToCycloneDX(JsonNode trivyOutput, Map componentBomRefMap) { List vulnerabilities = new ArrayList<>(); diff --git a/compose.dev.yaml b/compose.dev.yaml index 110852319..c8d725154 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -15,7 +15,7 @@ services: ports: - "5000:5000" healthcheck: - test: ["CMD-SHELL", "python3 -c \"import urllib.request,sys; urllib.request.urlopen('http://localhost:5000/tools', timeout=5); sys.exit(0)\""] + test: ["CMD", "curl", "-f", "http://localhost:5000/healthcheck"] start_period: 60s # allow validation to finish before first check interval: 20s timeout: 10s diff --git a/compose.yaml b/compose.yaml index b4e168410..9fc3f3909 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,7 +13,7 @@ services: build: osi pull_policy: never # use local build healthcheck: - test: ["CMD-SHELL", "python3 -c \"import urllib.request,sys; urllib.request.urlopen('http://localhost:5000/tools', timeout=5); sys.exit(0)\""] + test: ["CMD", "curl", "-f", "http://localhost:5000/healthcheck"] start_period: 60s # allow validation to finish before first check interval: 20s timeout: 10s diff --git a/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java b/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java index e80d5a258..cab32250d 100644 --- a/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java +++ b/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java @@ -108,6 +108,15 @@ public void setPrettyPrinting(boolean prettyPrint) { this.prettyPrint = prettyPrint; } + /** + * Writes the CycloneDX document to the provided generator, emitting top-level metadata, + * components, dependencies, and attachments in a single pass. + * + * @param sbom SBOM being serialized + * @param jsonGenerator destination generator + * @param provider serializer provider passed by Jackson + * @throws IOException when the output stream cannot be written + */ @Override public void serialize(SVIPSBOM sbom, JsonGenerator jsonGenerator, SerializerProvider provider) throws IOException { jsonGenerator.writeStartObject(); @@ -449,6 +458,13 @@ private void writeExternalRefs(JsonGenerator jsonGenerator, Set None: + """ + Hydrates package manifests with lockfiles and dependency metadata so scanners such as cdxgen, + gobom, and other language-specific generators can build accurate graphs. Many tools require + node_modules/vendor directories or lockfiles to exist before producing a trustworthy SBOM. + """ self._app.logger.info("Prepare | Ensuring dependency metadata for uploaded project") def _rel(path: str) -> str: