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