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/.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/Dockerfile b/Dockerfile index 2b54325e4..f0660bcaa 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.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.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" +# 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 new file mode 100644 index 000000000..d0c8f663b --- /dev/null +++ b/api/bin/main/application.properties @@ -0,0 +1,16 @@ +# 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} +# 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/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/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 new file mode 100644 index 000000000..5a3974f60 --- /dev/null +++ b/api/src/main/java/org/svip/api/config/JacksonConfig.java @@ -0,0 +1,52 @@ +/** + * 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.StreamReadConstraints; +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; + +/** + * Configures Jackson to handle Java 8+ time types and large SBOM payloads. + */ +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { + return builder -> { + 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() + )); + }; + } +} \ 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 4ae46d9e9..757c302fe 100644 --- a/api/src/main/java/org/svip/api/controller/OSIController.java +++ b/api/src/main/java/org/svip/api/controller/OSIController.java @@ -37,11 +37,16 @@ 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; 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; @@ -65,21 +70,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"); + } } /// @@ -155,24 +175,26 @@ 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(); + @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"); + } } else { tools = this.osiService.getTools("project"); } @@ -189,9 +211,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( @@ -220,6 +265,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) { @@ -245,23 +308,124 @@ 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 merged SBOMs to SBOM with 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); } - // todo how to set file name using projectName + // RE-ADD VULNERABILITIES AFTER MERGE (merge strips them) + if (!allVulnerabilities.isEmpty() && vulnerabilityScanService.isEnabled()) { + try { + SBOMFile mergedSbom = sbomService.getSBOMFile(convertedID); + if (mergedSbom != null) { + 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 = mapper.convertValue(sbomJson, Map.class); + sbomMap.put("vulnerabilities", allVulnerabilities); + + String enrichedContent = mapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(sbomMap); + + // Update with enriched content + mergedSbom.setContent(enrichedContent); + sbomService.upload(mergedSbom); + + 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) { + LOGGER.warn("POST /svip/generators/osi - Failed to re-add vulnerabilities: {}", e.getMessage()); + } + } + + // 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); } + 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/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/controller/SBOMController.java b/api/src/main/java/org/svip/api/controller/SBOMController.java index 15f4ae1e7..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,20 +25,28 @@ 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; 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; +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 * @@ -54,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; } @@ -86,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 @@ -202,7 +226,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 +240,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); } @@ -277,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/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..bd90c735e --- /dev/null +++ b/api/src/main/java/org/svip/api/controller/VulnerabilityController.java @@ -0,0 +1,132 @@ +/** + * 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; + +/** + * REST controller exposing vulnerability history trends and alert management endpoints. + */ +@RestController +@RequestMapping("/svip/vulnerabilities") +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; + this.alertRepository = alertRepository; + } + + // 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, + @RequestParam(defaultValue = "30") int days) { + 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<>(); + stats.put("total_unacknowledged", alertRepository.countByAcknowledgedFalse()); + stats.put("critical_unacknowledged", alertRepository.countBySeverityAndAcknowledgedFalse("CRITICAL")); + stats.put("high_unacknowledged", alertRepository.countBySeverityAndAcknowledgedFalse("HIGH")); + 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, + @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/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/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/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..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 @@ -24,6 +24,7 @@ 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.*; @@ -69,6 +70,7 @@ public class ConflictFile { // source comparison @ManyToOne @JoinColumn(name = "comparison_id", nullable = false) + @JsonBackReference("comparison-conflicts") private ComparisonFile comparison; 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/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/main/java/org/svip/api/services/OSIService.java b/api/src/main/java/org/svip/api/services/OSIService.java index 97a584b70..72e07eaf5 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(500_000_000) // 500MB, increased from 100MB to handle very large SBOMs + .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..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()); } @@ -361,6 +359,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/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..09792bd16 --- /dev/null +++ b/api/src/main/java/org/svip/api/services/VulnerabilityScanService.java @@ -0,0 +1,1061 @@ +/** + * 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 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; +import org.springframework.stereotype.Component; +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.*; +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, + ObjectMapper objectMapper) { + this.objectMapper = objectMapper.copy(); + this.vulnerabilityScanExecutor = vulnerabilityScanExecutor; + } + + /** + * Check if vulnerability scanning is enabled + * + * @return true if enabled, false otherwise + */ + 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) + * + * @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 { + String normalizedSbom = normalizeSbomForScanning(sbomContents, sbomFileName); + Files.writeString(tempSbomPath, normalizedSbom); + + // 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()); + + 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); + } + } + + 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); + + // Build mapping from component identifiers to bom-refs + Map componentBomRefMap = buildComponentBomRefMapping(originalNode); + + // 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()); + + 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()) { + 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, 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, componentBomRefMap); + 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()); + } + } + + // 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()); + + // 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()); + } + } + + /** + * 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); + 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; + }; + } + + /** + * 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; + } + } + + /** + * 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<>(); + + 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(); + + // 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 not found, try using name@version + if (artifactRef == null) { + String name = artifact.path("name").asText(""); + String version = artifact.path("version").asText(""); + if (!name.isEmpty()) { + 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 (artifactRef != null && !artifactRef.isEmpty()) { + affect.put("ref", artifactRef); + 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(""); + // Skip CVSS v4.0 scores; prefer v3.x only + if (method.startsWith("4.")) { + return; + } + 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); + } + } + + /** + * 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<>(); + + 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(""); + + // 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 (artifactRef != null && !artifactRef.isEmpty()) { + affect.put("ref", artifactRef); + affects.add(affect); + } + + if (!affects.isEmpty()) { + vulnNode.set("affects", affects); + } + + vulnerabilities.add(vulnNode); + } + } + + return vulnerabilities; + } +} + diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index ec4b23979..d93d81600 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -9,4 +9,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/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/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/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 ee917efc4..c8d725154 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -16,16 +16,16 @@ services: - "5000:5000" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5000/healthcheck"] - start_period: 15s # small delay to let startup scripts finish + 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_dev image: mysql:8.4.6 - command: --max_allowed_packet=32505856 # 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 @@ -63,6 +63,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 8bb34874f..9fc3f3909 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,16 +14,16 @@ services: pull_policy: never # use local build healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5000/healthcheck"] - start_period: 15s # small delay to let startup scripts finish + 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 image: mysql:8.4.6 - command: --max_allowed_packet=32505856 # 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 @@ -59,6 +59,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 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/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/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; 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/CDX14JSONSerializer.java b/core/src/main/java/org/svip/serializers/serializer/CDX14JSONSerializer.java index 5e1078b9b..cab32250d 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; @@ -106,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(); @@ -149,7 +160,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 +190,75 @@ 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()) { + for (Component component : sbom.getComponents()) { + if (component == null) continue; + String uid = normalizeRef(component.getUID()); + if (uid == null) continue; + cleaned.put(uid, new LinkedHashSet<>()); + } + } + + 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(); @@ -376,6 +458,13 @@ private void writeExternalRefs(JsonGenerator jsonGenerator, Set 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 ca277c0d6..b92609958 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 @@ -193,48 +303,72 @@ 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")); - - // 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"); + statement.setStatementFirstIssued(firstIssued); + statement.setStatementLastUpdated(lastUpdated); + + // 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")); - - //Get all products and add all to the VEX Statement - // todo - hotfix for when supplier is null - String supplier = c.getSupplier() == null - ? c.getName() - : 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)); + Justification.NOT_APPLICABLE, details, "N/A")); + + // 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"); + 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/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) { diff --git a/osi/Dockerfile b/osi/Dockerfile index cc083bfcb..a3d85e4a6 100644 --- a/osi/Dockerfile +++ b/osi/Dockerfile @@ -28,10 +28,29 @@ 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 SYFT_VERSION=1.38.0 +ARG GRYPE_VERSION=0.104.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.67.2 +ARG OSV_SCANNER_VERSION=2.3.0 +ARG DEPENDENCY_CHECK_VERSION=12.1.0 +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 git unzip gnupg apt-transport-https software-properties-common 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/dependency-check.yml b/osi/osi/configs/tools/dependency-check.yml new file mode 100644 index 000000000..e86cf7ea5 --- /dev/null +++ b/osi/osi/configs/tools/dependency-check.yml @@ -0,0 +1,29 @@ +# 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: + - "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/configs/tools/grype.yml b/osi/osi/configs/tools/grype.yml new file mode 100644 index 000000000..d31bc6ea6 --- /dev/null +++ b/osi/osi/configs/tools/grype.yml @@ -0,0 +1,13 @@ +# 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" 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/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/configs/tools/trivy.yml b/osi/osi/configs/tools/trivy.yml new file mode 100644 index 000000000..bc17a2a8a --- /dev/null +++ b/osi/osi/configs/tools/trivy.yml @@ -0,0 +1,13 @@ +# 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" diff --git a/osi/osi/osi_server.py b/osi/osi/osi_server.py index 587ec1526..b45a54a47 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,192 @@ 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: + """ + 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: + 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) + + # 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", "--production", "--frozen-lockfile", "--ignore-scripts"], + base_dir, + f"yarn install --production ({_rel(base_dir)})", + ) + elif self._command_exists("pnpm") and os.path.exists(os.path.join(base_dir, "pnpm-lock.yaml")): + self._run_prepare_command( + ["pnpm", "install", "--prod", "--frozen-lockfile", "--ignore-scripts"], + base_dir, + f"pnpm install --prod ({_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: + 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", "install", "--no-dev", "--no-root"], + base_dir, + f"poetry install --no-dev ({_rel(base_dir)})", + ) + else: + self._app.logger.warning( + 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 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) + 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 +330,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}") 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" }