From 8decd2ecc712d77eec43d1065b7182c8986a20ea Mon Sep 17 00:00:00 2001 From: savacano28 Date: Mon, 16 Feb 2026 16:12:56 +0100 Subject: [PATCH 01/21] [backend] feat: wip --- .../AbstractOutputProcessorHandler.java | 74 ++++++++++++++++++ .../AssetOutputProcessorHandler.java | 27 +++++++ .../CVEOutputProcessorHandler.java | 64 ++++++++++++++++ .../CredentialsOutputProcessorHandler.java | 37 +++++++++ .../IPv4OutputProcessorHandler.java | 28 +++++++ .../IPv6OutputProcessorHandler.java | 28 +++++++ .../NumberOutputProcessorHandler.java | 25 +++++++ .../OutputProcessorFactory.java | 19 ++--- .../OutputProcessorHandler.java | 75 +++++++++++++++++++ .../PortOutputProcessorHandler.java | 25 +++++++ .../PortScanOutputProcessorHandler.java | 53 +++++++++++++ .../TextOutputProcessorHandler.java | 25 +++++++ .../openaev/rest/finding/FindingService.java | 9 ++- .../inject/service/StructuredOutputUtils.java | 7 +- ...OutputProcessorHandlerIntegrationTest.java | 47 ++++++++++++ 15 files changed, 523 insertions(+), 20 deletions(-) create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessorHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessorHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessorHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessorHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessorHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessorHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessorHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessorHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessorHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessorHandler.java create mode 100644 openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorHandlerIntegrationTest.java diff --git a/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessorHandler.java new file mode 100644 index 00000000000..3f606b8a336 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessorHandler.java @@ -0,0 +1,74 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.ContractOutputField; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.List; + +/** Abstract base class providing common functionality for structured output processor handlers. */ +public abstract class AbstractOutputProcessorHandler implements OutputProcessorHandler { + + protected final ContractOutputType type; + protected final ContractOutputTechnicalType technicalType; + protected final List fields; + protected final boolean isFindingCompatible; + + protected AbstractOutputProcessorHandler( + ContractOutputType type, + ContractOutputTechnicalType technicalType, + List fields, + boolean isFindingCompatible) { + this.type = type; + this.technicalType = technicalType; + this.fields = fields; + this.isFindingCompatible = isFindingCompatible; + } + + @Override + public ContractOutputType getType() { + return type; + } + + @Override + public ContractOutputTechnicalType getTechnicalType() { + return technicalType; + } + + @Override + public List getFields() { + return fields; + } + + @Override + public boolean isFindingCompatible() { + return isFindingCompatible; + } + + // Utility methods + protected String buildString(@NotNull final JsonNode jsonNode) { + if (jsonNode.isArray()) { + List values = new ArrayList<>(); + for (JsonNode element : jsonNode) { + values.add(trimQuotes(element.asText())); + } + return String.join(" ", values); + } + return trimQuotes(jsonNode.asText()); + } + + protected String buildString(@NotNull final JsonNode jsonNode, @NotBlank final String key) { + JsonNode valueNode = jsonNode.get(key); + if (valueNode == null || valueNode.isNull()) { + return ""; + } + return buildString(valueNode); + } + + protected String trimQuotes(@NotBlank final String value) { + return value.replaceAll("^\"|\"$", ""); + } +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessorHandler.java new file mode 100644 index 00000000000..2d87f1e6c77 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessorHandler.java @@ -0,0 +1,27 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.Asset; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class AssetOutputProcessorHandler extends AbstractOutputProcessorHandler { + + public AssetOutputProcessorHandler() { + super(ContractOutputType.Asset, ContractOutputTechnicalType.Object, List.of(), false); + } + + @Override + public boolean validate(JsonNode jsonNode) { + return jsonNode != null; + } + + @Override + public Asset toAsset(JsonNode jsonNode) { + // Creation asset + return new Asset(); + } +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessorHandler.java new file mode 100644 index 00000000000..676b1630d64 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessorHandler.java @@ -0,0 +1,64 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.ContractOutputField; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class CVEOutputProcessorHandler extends AbstractOutputProcessorHandler { + + private static final String ASSET_ID = "asset_id"; + private static final String ID = "id"; + private static final String HOST = "host"; + private static final String SEVERITY = "severity"; + + public CVEOutputProcessorHandler() { + super( + ContractOutputType.CVE, + ContractOutputTechnicalType.Object, + List.of( + new ContractOutputField(ASSET_ID, ContractOutputTechnicalType.Text, false), + new ContractOutputField(ID, ContractOutputTechnicalType.Text, true), + new ContractOutputField(HOST, ContractOutputTechnicalType.Text, true), + new ContractOutputField(SEVERITY, ContractOutputTechnicalType.Text, true)), + true); + } + + @Override + public boolean validate(JsonNode jsonNode) { + return jsonNode.hasNonNull(ID) && jsonNode.hasNonNull(HOST) && jsonNode.hasNonNull(SEVERITY); + } + + // Findings + @Override + public String toFindingValue(JsonNode jsonNode) { + return buildString(jsonNode, ID); + } + + @Override + public List toFindingAssets(JsonNode jsonNode) { + JsonNode assetIdNode = jsonNode.get(ASSET_ID); + if (assetIdNode == null) { + return Collections.emptyList(); + } + if (assetIdNode.isArray()) { + List result = new ArrayList<>(); + for (JsonNode idNode : assetIdNode) { + result.add(idNode.asText()); + } + return result; + } + return List.of(assetIdNode.asText()); + } + + // Expectations + @Override + public boolean matchesExpectation(JsonNode jsonNode, JsonNode expectation) { + return false; + } +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessorHandler.java new file mode 100644 index 00000000000..668fdda9525 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessorHandler.java @@ -0,0 +1,37 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.ContractOutputField; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class CredentialsOutputProcessorHandler extends AbstractOutputProcessorHandler { + + private static final String USERNAME = "username"; + private static final String PASSWORD = "password"; + + public CredentialsOutputProcessorHandler() { + super( + ContractOutputType.Credentials, + ContractOutputTechnicalType.Object, + List.of( + new ContractOutputField(USERNAME, ContractOutputTechnicalType.Text, true), + new ContractOutputField(PASSWORD, ContractOutputTechnicalType.Text, true)), + true); + } + + @Override + public boolean validate(JsonNode jsonNode) { + return jsonNode.hasNonNull(USERNAME) && jsonNode.hasNonNull(PASSWORD); + } + + @Override + public String toFindingValue(JsonNode jsonNode) { + String username = buildString(jsonNode, USERNAME); + String password = buildString(jsonNode, PASSWORD); + return username + ":" + password; + } +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessorHandler.java new file mode 100644 index 00000000000..26724e5d63d --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessorHandler.java @@ -0,0 +1,28 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import java.util.List; +import org.apache.commons.validator.routines.InetAddressValidator; +import org.springframework.stereotype.Component; + +@Component +public class IPv4OutputProcessorHandler extends AbstractOutputProcessorHandler { + + private static final InetAddressValidator VALIDATOR = InetAddressValidator.getInstance(); + + public IPv4OutputProcessorHandler() { + super(ContractOutputType.IPv4, ContractOutputTechnicalType.Text, List.of(), true); + } + + @Override + public boolean validate(JsonNode jsonNode) { + return VALIDATOR.isValidInet4Address(jsonNode.asText()); + } + + @Override + public String toFindingValue(JsonNode jsonNode) { + return buildString(jsonNode); + } +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessorHandler.java new file mode 100644 index 00000000000..bf16f373395 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessorHandler.java @@ -0,0 +1,28 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import java.util.List; +import org.apache.commons.validator.routines.InetAddressValidator; +import org.springframework.stereotype.Component; + +@Component +public class IPv6OutputProcessorHandler extends AbstractOutputProcessorHandler { + + private static final InetAddressValidator VALIDATOR = InetAddressValidator.getInstance(); + + public IPv6OutputProcessorHandler() { + super(ContractOutputType.IPv6, ContractOutputTechnicalType.Text, List.of(), true); + } + + @Override + public boolean validate(JsonNode jsonNode) { + return VALIDATOR.isValidInet6Address(jsonNode.asText()); + } + + @Override + public String toFindingValue(JsonNode jsonNode) { + return buildString(jsonNode); + } +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessorHandler.java new file mode 100644 index 00000000000..1087f63006e --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessorHandler.java @@ -0,0 +1,25 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class NumberOutputProcessorHandler extends AbstractOutputProcessorHandler { + + public NumberOutputProcessorHandler() { + super(ContractOutputType.Number, ContractOutputTechnicalType.Number, List.of(), true); + } + + @Override + public boolean validate(JsonNode jsonNode) { + return jsonNode != null; + } + + @Override + public String toFindingValue(JsonNode jsonNode) { + return buildString(jsonNode); + } +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java index 7a6d46e8bdc..80e6da52213 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java @@ -3,7 +3,6 @@ import io.openaev.database.model.ContractOutputType; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.stereotype.Component; @@ -11,21 +10,15 @@ @Component public class OutputProcessorFactory { - private final Map outputProcessorHandlerMap; + private final Map outputProcessorHandlerMap; - public OutputProcessorFactory(List handlers) { + public OutputProcessorFactory(List handlers) { this.outputProcessorHandlerMap = - handlers.stream().collect(Collectors.toMap(OutputProcessor::getType, Function.identity())); + handlers.stream() + .collect(Collectors.toMap(OutputProcessorHandler::getType, Function.identity())); } - public OutputProcessor getHandler(ContractOutputType type) { - return Optional.ofNullable(outputProcessorHandlerMap.get(type)) - .orElseThrow( - () -> - new IllegalArgumentException( - "No handler found for type: " - + type - + ". Available types: " - + outputProcessorHandlerMap.keySet())); + public OutputProcessorHandler getHandler(ContractOutputType type) { + return outputProcessorHandlerMap.get(type); } } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorHandler.java new file mode 100644 index 00000000000..a18fb6c43d4 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorHandler.java @@ -0,0 +1,75 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.Asset; +import io.openaev.database.model.ContractOutputField; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import java.util.Collections; +import java.util.List; + +/** + * Handler interface for processing structured outputs in different contexts. Implementations of + * this interface will define how to validate and process structured outputs based on their type and + * technical type, as well as the contexts they support. + */ +public interface OutputProcessorHandler { + + /** Get the type (matches ContractOutputType enum) */ + ContractOutputType getType(); + + /** Get the technical type (matches ContractOutputTechnicalType enum) */ + ContractOutputTechnicalType getTechnicalType(); + + /** Get fields */ + List getFields(); + + /** Is finding compatible */ + boolean isFindingCompatible(); + + /** Validate that the JSON node is correctly formatted for this type */ + boolean validate(JsonNode jsonNode); + + // Findings Processing + + /** Convert JSON node to finding value string */ + String toFindingValue(JsonNode jsonNode); + + /** + * Extract asset IDs from JSON node for finding linking. Default implementation returns empty + * list. + */ + default List toFindingAssets(JsonNode jsonNode) { + return Collections.emptyList(); + } + + /** + * Extract user IDs from JSON node for finding linking. Default implementation returns empty list. + */ + default List toFindingUsers(JsonNode jsonNode) { + return Collections.emptyList(); + } + + /** + * Extract team IDs from JSON node for finding linking. Default implementation returns empty list. + */ + default List toFindingTeams(JsonNode jsonNode) { + return Collections.emptyList(); + } + + // Asset Processing + + /** Find or Create Asset from jsonNode */ + Asset toAsset(JsonNode jsonNode); + + // Expectation Validation + + /** + * Check if actual JSON value matches the expectation + * + * @param jsonNode the actual value from output + * @param expectation the expected value/condition to match against + * @return true if expectation is met, false otherwise + */ + boolean matchesExpectation(JsonNode jsonNode, JsonNode expectation); +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessorHandler.java new file mode 100644 index 00000000000..dc1cadb6b1f --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessorHandler.java @@ -0,0 +1,25 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class PortOutputProcessorHandler extends AbstractOutputProcessorHandler { + + public PortOutputProcessorHandler() { + super(ContractOutputType.Port, ContractOutputTechnicalType.Number, List.of(), true); + } + + @Override + public boolean validate(JsonNode jsonNode) { + return jsonNode != null; + } + + @Override + public String toFindingValue(JsonNode jsonNode) { + return buildString(jsonNode); + } +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessorHandler.java new file mode 100644 index 00000000000..ffd41411795 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessorHandler.java @@ -0,0 +1,53 @@ +package io.openaev.output_processor; + +import static org.springframework.util.StringUtils.hasText; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.ContractOutputField; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class PortScanOutputProcessorHandler extends AbstractOutputProcessorHandler { + + private static final String ASSET_ID = "asset_id"; + private static final String HOST = "host"; + private static final String PORT = "port"; + private static final String SERVICE = "service"; + + public PortScanOutputProcessorHandler() { + super( + ContractOutputType.PortsScan, + ContractOutputTechnicalType.Object, + List.of( + new ContractOutputField(ASSET_ID, ContractOutputTechnicalType.Text, false), + new ContractOutputField(HOST, ContractOutputTechnicalType.Text, true), + new ContractOutputField(PORT, ContractOutputTechnicalType.Number, true), + new ContractOutputField(SERVICE, ContractOutputTechnicalType.Text, true)), + true); + } + + @Override + public boolean validate(JsonNode jsonNode) { + return jsonNode.hasNonNull(HOST) && jsonNode.hasNonNull(PORT) && jsonNode.hasNonNull(SERVICE); + } + + @Override + public String toFindingValue(JsonNode jsonNode) { + String host = buildString(jsonNode, HOST); + String port = buildString(jsonNode, PORT); + String service = buildString(jsonNode, SERVICE); + return host + ":" + port + (hasText(service) ? " (" + service + ")" : ""); + } + + @Override + public List toFindingAssets(JsonNode jsonNode) { + JsonNode assetIdNode = jsonNode.get(ASSET_ID); + if (assetIdNode != null) { + return List.of(assetIdNode.asText()); + } + return List.of(); + } +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessorHandler.java new file mode 100644 index 00000000000..a8fcd244411 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessorHandler.java @@ -0,0 +1,25 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class TextOutputProcessorHandler extends AbstractOutputProcessorHandler { + + public TextOutputProcessorHandler() { + super(ContractOutputType.Text, ContractOutputTechnicalType.Text, List.of(), true); + } + + @Override + public boolean validate(JsonNode jsonNode) { + return jsonNode != null; + } + + @Override + public String toFindingValue(JsonNode jsonNode) { + return buildString(jsonNode); + } +} diff --git a/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java index 5ca315c4d27..ab0a9daae4a 100644 --- a/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java +++ b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java @@ -12,8 +12,8 @@ import io.openaev.database.repository.TeamRepository; import io.openaev.database.repository.UserRepository; import io.openaev.injector_contract.outputs.InjectorContractContentOutputElement; -import io.openaev.output_processor.OutputProcessor; import io.openaev.output_processor.OutputProcessorFactory; +import io.openaev.output_processor.OutputProcessorHandler; import io.openaev.rest.inject.service.InjectService; import io.openaev.rest.injector_contract.InjectorContractContentUtils; import jakarta.annotation.Resource; @@ -151,7 +151,8 @@ List getFindingsFromInjectorContract( if (!contractOutput.isFindingCompatible()) { return; } - OutputProcessor handler = outputProcessorFactory.getHandler(contractOutput.getType()); + OutputProcessorHandler handler = + outputProcessorFactory.getHandler(contractOutput.getType()); if (contractOutput.isMultiple()) { JsonNode jsonNodes = structuredOutput.get(contractOutput.getField()); @@ -181,7 +182,7 @@ List getFindingsFromInjectorContract( return findings; } - private Finding linkFindings(JsonNode jsonNode, Finding finding, OutputProcessor handler) { + private Finding linkFindings(JsonNode jsonNode, Finding finding, OutputProcessorHandler handler) { // Create links with assets List assetsIds = handler.toFindingAssets(jsonNode); List> assets = assetsIds.stream().map(this.assetRepository::findById).toList(); @@ -255,7 +256,7 @@ public void extractFindingsFromOutputParsers( contractOutputElements.forEach( contractOutputElement -> { - OutputProcessor handler = + OutputProcessorHandler handler = outputProcessorFactory.getHandler(contractOutputElement.getType()); JsonNode jsonNodes = structuredOutput.get(contractOutputElement.getKey()); diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java index bebaaeac67d..722a5d7a732 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java @@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.*; import io.openaev.database.model.*; -import io.openaev.output_processor.OutputProcessor; import io.openaev.output_processor.OutputProcessorFactory; +import io.openaev.output_processor.OutputProcessorHandler; import jakarta.annotation.Resource; import java.util.*; import java.util.logging.Level; @@ -131,7 +131,8 @@ public Optional computeStructuredOutputUsingRegexRules( ArrayNode matchesArray = mapper.createArrayNode(); // Get handler once per contract output type - OutputProcessor handler = outputProcessorFactory.getHandler(contractOutputElement.getType()); + OutputProcessorHandler handler = + outputProcessorFactory.getHandler(contractOutputElement.getType()); while (matcher.find()) { buildStructuredJsonNode(contractOutputElement, matcher, handler) @@ -145,7 +146,7 @@ public Optional computeStructuredOutputUsingRegexRules( } public Optional buildStructuredJsonNode( - ContractOutputElement element, Matcher matcher, OutputProcessor handler) { + ContractOutputElement element, Matcher matcher, OutputProcessorHandler handler) { // Get metadata from handler instead of enum ContractOutputTechnicalType technicalType = handler.getTechnicalType(); diff --git a/openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorHandlerIntegrationTest.java b/openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorHandlerIntegrationTest.java new file mode 100644 index 00000000000..88e46ed24ad --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorHandlerIntegrationTest.java @@ -0,0 +1,47 @@ +package io.openaev.output_processor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; + +import io.openaev.IntegrationTest; +import io.openaev.database.model.ContractOutputType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; + +@TestInstance(PER_CLASS) +@DisplayName("Integration tests for OutputProcessorHandler loading and context support") +class OutputProcessorHandlerIntegrationTest extends IntegrationTest { + + @Autowired private OutputProcessorFactory registry; + + @Test + void shouldLoadAllHandlersFromSpring() { + for (ContractOutputType type : ContractOutputType.values()) { + OutputProcessorHandler handler = registry.getHandler(type); + + assertThat(handler).withFailMessage("Handler not found for type: " + type).isNotNull(); + } + } + + @Test + void shouldReturnCorrectHandlerForEachType() { + assertThat(registry.getHandler(ContractOutputType.Text)) + .isInstanceOf(TextOutputProcessorHandler.class); + + assertThat(registry.getHandler(ContractOutputType.PortsScan)) + .isInstanceOf(PortScanOutputProcessorHandler.class); + + assertThat(registry.getHandler(ContractOutputType.CVE)) + .isInstanceOf(CVEOutputProcessorHandler.class); + } + + @Test + void shouldReturnSameInstanceOnMultipleCalls() { + OutputProcessorHandler handler1 = registry.getHandler(ContractOutputType.Text); + OutputProcessorHandler handler2 = registry.getHandler(ContractOutputType.Text); + + assertThat(handler1).isSameAs(handler2); + } +} From 55866c8f1a8d64a7a79e64d99f812292bce75c8e Mon Sep 17 00:00:00 2001 From: savacano28 Date: Wed, 18 Feb 2026 11:59:17 +0100 Subject: [PATCH 02/21] [backend] feat: review --- .../AbstractOutputProcessorHandler.java | 74 ------------------ .../AssetOutputProcessorHandler.java | 27 ------- .../CVEOutputProcessorHandler.java | 64 ---------------- .../CredentialsOutputProcessorHandler.java | 37 --------- .../IPv4OutputProcessorHandler.java | 28 ------- .../IPv6OutputProcessorHandler.java | 28 ------- .../NumberOutputProcessorHandler.java | 25 ------- .../OutputProcessorFactory.java | 19 +++-- .../OutputProcessorHandler.java | 75 ------------------- .../PortOutputProcessorHandler.java | 25 ------- .../PortScanOutputProcessorHandler.java | 53 ------------- .../TextOutputProcessorHandler.java | 25 ------- .../openaev/rest/finding/FindingService.java | 9 +-- .../inject/service/StructuredOutputUtils.java | 7 +- .../AbstractOutputProcessorTest.java | 10 +-- ...OutputProcessorHandlerIntegrationTest.java | 47 ------------ 16 files changed, 25 insertions(+), 528 deletions(-) delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessorHandler.java delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessorHandler.java delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessorHandler.java delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessorHandler.java delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessorHandler.java delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessorHandler.java delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessorHandler.java delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorHandler.java delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessorHandler.java delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessorHandler.java delete mode 100644 openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessorHandler.java delete mode 100644 openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorHandlerIntegrationTest.java diff --git a/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessorHandler.java deleted file mode 100644 index 3f606b8a336..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessorHandler.java +++ /dev/null @@ -1,74 +0,0 @@ -package io.openaev.output_processor; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.ContractOutputField; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.util.ArrayList; -import java.util.List; - -/** Abstract base class providing common functionality for structured output processor handlers. */ -public abstract class AbstractOutputProcessorHandler implements OutputProcessorHandler { - - protected final ContractOutputType type; - protected final ContractOutputTechnicalType technicalType; - protected final List fields; - protected final boolean isFindingCompatible; - - protected AbstractOutputProcessorHandler( - ContractOutputType type, - ContractOutputTechnicalType technicalType, - List fields, - boolean isFindingCompatible) { - this.type = type; - this.technicalType = technicalType; - this.fields = fields; - this.isFindingCompatible = isFindingCompatible; - } - - @Override - public ContractOutputType getType() { - return type; - } - - @Override - public ContractOutputTechnicalType getTechnicalType() { - return technicalType; - } - - @Override - public List getFields() { - return fields; - } - - @Override - public boolean isFindingCompatible() { - return isFindingCompatible; - } - - // Utility methods - protected String buildString(@NotNull final JsonNode jsonNode) { - if (jsonNode.isArray()) { - List values = new ArrayList<>(); - for (JsonNode element : jsonNode) { - values.add(trimQuotes(element.asText())); - } - return String.join(" ", values); - } - return trimQuotes(jsonNode.asText()); - } - - protected String buildString(@NotNull final JsonNode jsonNode, @NotBlank final String key) { - JsonNode valueNode = jsonNode.get(key); - if (valueNode == null || valueNode.isNull()) { - return ""; - } - return buildString(valueNode); - } - - protected String trimQuotes(@NotBlank final String value) { - return value.replaceAll("^\"|\"$", ""); - } -} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessorHandler.java deleted file mode 100644 index 2d87f1e6c77..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessorHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.openaev.output_processor; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.Asset; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.List; -import org.springframework.stereotype.Component; - -@Component -public class AssetOutputProcessorHandler extends AbstractOutputProcessorHandler { - - public AssetOutputProcessorHandler() { - super(ContractOutputType.Asset, ContractOutputTechnicalType.Object, List.of(), false); - } - - @Override - public boolean validate(JsonNode jsonNode) { - return jsonNode != null; - } - - @Override - public Asset toAsset(JsonNode jsonNode) { - // Creation asset - return new Asset(); - } -} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessorHandler.java deleted file mode 100644 index 676b1630d64..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessorHandler.java +++ /dev/null @@ -1,64 +0,0 @@ -package io.openaev.output_processor; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.ContractOutputField; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import org.springframework.stereotype.Component; - -@Component -public class CVEOutputProcessorHandler extends AbstractOutputProcessorHandler { - - private static final String ASSET_ID = "asset_id"; - private static final String ID = "id"; - private static final String HOST = "host"; - private static final String SEVERITY = "severity"; - - public CVEOutputProcessorHandler() { - super( - ContractOutputType.CVE, - ContractOutputTechnicalType.Object, - List.of( - new ContractOutputField(ASSET_ID, ContractOutputTechnicalType.Text, false), - new ContractOutputField(ID, ContractOutputTechnicalType.Text, true), - new ContractOutputField(HOST, ContractOutputTechnicalType.Text, true), - new ContractOutputField(SEVERITY, ContractOutputTechnicalType.Text, true)), - true); - } - - @Override - public boolean validate(JsonNode jsonNode) { - return jsonNode.hasNonNull(ID) && jsonNode.hasNonNull(HOST) && jsonNode.hasNonNull(SEVERITY); - } - - // Findings - @Override - public String toFindingValue(JsonNode jsonNode) { - return buildString(jsonNode, ID); - } - - @Override - public List toFindingAssets(JsonNode jsonNode) { - JsonNode assetIdNode = jsonNode.get(ASSET_ID); - if (assetIdNode == null) { - return Collections.emptyList(); - } - if (assetIdNode.isArray()) { - List result = new ArrayList<>(); - for (JsonNode idNode : assetIdNode) { - result.add(idNode.asText()); - } - return result; - } - return List.of(assetIdNode.asText()); - } - - // Expectations - @Override - public boolean matchesExpectation(JsonNode jsonNode, JsonNode expectation) { - return false; - } -} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessorHandler.java deleted file mode 100644 index 668fdda9525..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessorHandler.java +++ /dev/null @@ -1,37 +0,0 @@ -package io.openaev.output_processor; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.ContractOutputField; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.List; -import org.springframework.stereotype.Component; - -@Component -public class CredentialsOutputProcessorHandler extends AbstractOutputProcessorHandler { - - private static final String USERNAME = "username"; - private static final String PASSWORD = "password"; - - public CredentialsOutputProcessorHandler() { - super( - ContractOutputType.Credentials, - ContractOutputTechnicalType.Object, - List.of( - new ContractOutputField(USERNAME, ContractOutputTechnicalType.Text, true), - new ContractOutputField(PASSWORD, ContractOutputTechnicalType.Text, true)), - true); - } - - @Override - public boolean validate(JsonNode jsonNode) { - return jsonNode.hasNonNull(USERNAME) && jsonNode.hasNonNull(PASSWORD); - } - - @Override - public String toFindingValue(JsonNode jsonNode) { - String username = buildString(jsonNode, USERNAME); - String password = buildString(jsonNode, PASSWORD); - return username + ":" + password; - } -} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessorHandler.java deleted file mode 100644 index 26724e5d63d..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessorHandler.java +++ /dev/null @@ -1,28 +0,0 @@ -package io.openaev.output_processor; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.List; -import org.apache.commons.validator.routines.InetAddressValidator; -import org.springframework.stereotype.Component; - -@Component -public class IPv4OutputProcessorHandler extends AbstractOutputProcessorHandler { - - private static final InetAddressValidator VALIDATOR = InetAddressValidator.getInstance(); - - public IPv4OutputProcessorHandler() { - super(ContractOutputType.IPv4, ContractOutputTechnicalType.Text, List.of(), true); - } - - @Override - public boolean validate(JsonNode jsonNode) { - return VALIDATOR.isValidInet4Address(jsonNode.asText()); - } - - @Override - public String toFindingValue(JsonNode jsonNode) { - return buildString(jsonNode); - } -} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessorHandler.java deleted file mode 100644 index bf16f373395..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessorHandler.java +++ /dev/null @@ -1,28 +0,0 @@ -package io.openaev.output_processor; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.List; -import org.apache.commons.validator.routines.InetAddressValidator; -import org.springframework.stereotype.Component; - -@Component -public class IPv6OutputProcessorHandler extends AbstractOutputProcessorHandler { - - private static final InetAddressValidator VALIDATOR = InetAddressValidator.getInstance(); - - public IPv6OutputProcessorHandler() { - super(ContractOutputType.IPv6, ContractOutputTechnicalType.Text, List.of(), true); - } - - @Override - public boolean validate(JsonNode jsonNode) { - return VALIDATOR.isValidInet6Address(jsonNode.asText()); - } - - @Override - public String toFindingValue(JsonNode jsonNode) { - return buildString(jsonNode); - } -} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessorHandler.java deleted file mode 100644 index 1087f63006e..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessorHandler.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.openaev.output_processor; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.List; -import org.springframework.stereotype.Component; - -@Component -public class NumberOutputProcessorHandler extends AbstractOutputProcessorHandler { - - public NumberOutputProcessorHandler() { - super(ContractOutputType.Number, ContractOutputTechnicalType.Number, List.of(), true); - } - - @Override - public boolean validate(JsonNode jsonNode) { - return jsonNode != null; - } - - @Override - public String toFindingValue(JsonNode jsonNode) { - return buildString(jsonNode); - } -} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java index 80e6da52213..7a6d46e8bdc 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java @@ -3,6 +3,7 @@ import io.openaev.database.model.ContractOutputType; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.stereotype.Component; @@ -10,15 +11,21 @@ @Component public class OutputProcessorFactory { - private final Map outputProcessorHandlerMap; + private final Map outputProcessorHandlerMap; - public OutputProcessorFactory(List handlers) { + public OutputProcessorFactory(List handlers) { this.outputProcessorHandlerMap = - handlers.stream() - .collect(Collectors.toMap(OutputProcessorHandler::getType, Function.identity())); + handlers.stream().collect(Collectors.toMap(OutputProcessor::getType, Function.identity())); } - public OutputProcessorHandler getHandler(ContractOutputType type) { - return outputProcessorHandlerMap.get(type); + public OutputProcessor getHandler(ContractOutputType type) { + return Optional.ofNullable(outputProcessorHandlerMap.get(type)) + .orElseThrow( + () -> + new IllegalArgumentException( + "No handler found for type: " + + type + + ". Available types: " + + outputProcessorHandlerMap.keySet())); } } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorHandler.java deleted file mode 100644 index a18fb6c43d4..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorHandler.java +++ /dev/null @@ -1,75 +0,0 @@ -package io.openaev.output_processor; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.Asset; -import io.openaev.database.model.ContractOutputField; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.Collections; -import java.util.List; - -/** - * Handler interface for processing structured outputs in different contexts. Implementations of - * this interface will define how to validate and process structured outputs based on their type and - * technical type, as well as the contexts they support. - */ -public interface OutputProcessorHandler { - - /** Get the type (matches ContractOutputType enum) */ - ContractOutputType getType(); - - /** Get the technical type (matches ContractOutputTechnicalType enum) */ - ContractOutputTechnicalType getTechnicalType(); - - /** Get fields */ - List getFields(); - - /** Is finding compatible */ - boolean isFindingCompatible(); - - /** Validate that the JSON node is correctly formatted for this type */ - boolean validate(JsonNode jsonNode); - - // Findings Processing - - /** Convert JSON node to finding value string */ - String toFindingValue(JsonNode jsonNode); - - /** - * Extract asset IDs from JSON node for finding linking. Default implementation returns empty - * list. - */ - default List toFindingAssets(JsonNode jsonNode) { - return Collections.emptyList(); - } - - /** - * Extract user IDs from JSON node for finding linking. Default implementation returns empty list. - */ - default List toFindingUsers(JsonNode jsonNode) { - return Collections.emptyList(); - } - - /** - * Extract team IDs from JSON node for finding linking. Default implementation returns empty list. - */ - default List toFindingTeams(JsonNode jsonNode) { - return Collections.emptyList(); - } - - // Asset Processing - - /** Find or Create Asset from jsonNode */ - Asset toAsset(JsonNode jsonNode); - - // Expectation Validation - - /** - * Check if actual JSON value matches the expectation - * - * @param jsonNode the actual value from output - * @param expectation the expected value/condition to match against - * @return true if expectation is met, false otherwise - */ - boolean matchesExpectation(JsonNode jsonNode, JsonNode expectation); -} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessorHandler.java deleted file mode 100644 index dc1cadb6b1f..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessorHandler.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.openaev.output_processor; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.List; -import org.springframework.stereotype.Component; - -@Component -public class PortOutputProcessorHandler extends AbstractOutputProcessorHandler { - - public PortOutputProcessorHandler() { - super(ContractOutputType.Port, ContractOutputTechnicalType.Number, List.of(), true); - } - - @Override - public boolean validate(JsonNode jsonNode) { - return jsonNode != null; - } - - @Override - public String toFindingValue(JsonNode jsonNode) { - return buildString(jsonNode); - } -} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessorHandler.java deleted file mode 100644 index ffd41411795..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessorHandler.java +++ /dev/null @@ -1,53 +0,0 @@ -package io.openaev.output_processor; - -import static org.springframework.util.StringUtils.hasText; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.ContractOutputField; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.List; -import org.springframework.stereotype.Component; - -@Component -public class PortScanOutputProcessorHandler extends AbstractOutputProcessorHandler { - - private static final String ASSET_ID = "asset_id"; - private static final String HOST = "host"; - private static final String PORT = "port"; - private static final String SERVICE = "service"; - - public PortScanOutputProcessorHandler() { - super( - ContractOutputType.PortsScan, - ContractOutputTechnicalType.Object, - List.of( - new ContractOutputField(ASSET_ID, ContractOutputTechnicalType.Text, false), - new ContractOutputField(HOST, ContractOutputTechnicalType.Text, true), - new ContractOutputField(PORT, ContractOutputTechnicalType.Number, true), - new ContractOutputField(SERVICE, ContractOutputTechnicalType.Text, true)), - true); - } - - @Override - public boolean validate(JsonNode jsonNode) { - return jsonNode.hasNonNull(HOST) && jsonNode.hasNonNull(PORT) && jsonNode.hasNonNull(SERVICE); - } - - @Override - public String toFindingValue(JsonNode jsonNode) { - String host = buildString(jsonNode, HOST); - String port = buildString(jsonNode, PORT); - String service = buildString(jsonNode, SERVICE); - return host + ":" + port + (hasText(service) ? " (" + service + ")" : ""); - } - - @Override - public List toFindingAssets(JsonNode jsonNode) { - JsonNode assetIdNode = jsonNode.get(ASSET_ID); - if (assetIdNode != null) { - return List.of(assetIdNode.asText()); - } - return List.of(); - } -} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessorHandler.java b/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessorHandler.java deleted file mode 100644 index a8fcd244411..00000000000 --- a/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessorHandler.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.openaev.output_processor; - -import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.List; -import org.springframework.stereotype.Component; - -@Component -public class TextOutputProcessorHandler extends AbstractOutputProcessorHandler { - - public TextOutputProcessorHandler() { - super(ContractOutputType.Text, ContractOutputTechnicalType.Text, List.of(), true); - } - - @Override - public boolean validate(JsonNode jsonNode) { - return jsonNode != null; - } - - @Override - public String toFindingValue(JsonNode jsonNode) { - return buildString(jsonNode); - } -} diff --git a/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java index ab0a9daae4a..5ca315c4d27 100644 --- a/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java +++ b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java @@ -12,8 +12,8 @@ import io.openaev.database.repository.TeamRepository; import io.openaev.database.repository.UserRepository; import io.openaev.injector_contract.outputs.InjectorContractContentOutputElement; +import io.openaev.output_processor.OutputProcessor; import io.openaev.output_processor.OutputProcessorFactory; -import io.openaev.output_processor.OutputProcessorHandler; import io.openaev.rest.inject.service.InjectService; import io.openaev.rest.injector_contract.InjectorContractContentUtils; import jakarta.annotation.Resource; @@ -151,8 +151,7 @@ List getFindingsFromInjectorContract( if (!contractOutput.isFindingCompatible()) { return; } - OutputProcessorHandler handler = - outputProcessorFactory.getHandler(contractOutput.getType()); + OutputProcessor handler = outputProcessorFactory.getHandler(contractOutput.getType()); if (contractOutput.isMultiple()) { JsonNode jsonNodes = structuredOutput.get(contractOutput.getField()); @@ -182,7 +181,7 @@ List getFindingsFromInjectorContract( return findings; } - private Finding linkFindings(JsonNode jsonNode, Finding finding, OutputProcessorHandler handler) { + private Finding linkFindings(JsonNode jsonNode, Finding finding, OutputProcessor handler) { // Create links with assets List assetsIds = handler.toFindingAssets(jsonNode); List> assets = assetsIds.stream().map(this.assetRepository::findById).toList(); @@ -256,7 +255,7 @@ public void extractFindingsFromOutputParsers( contractOutputElements.forEach( contractOutputElement -> { - OutputProcessorHandler handler = + OutputProcessor handler = outputProcessorFactory.getHandler(contractOutputElement.getType()); JsonNode jsonNodes = structuredOutput.get(contractOutputElement.getKey()); diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java index 722a5d7a732..bebaaeac67d 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java @@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.*; import io.openaev.database.model.*; +import io.openaev.output_processor.OutputProcessor; import io.openaev.output_processor.OutputProcessorFactory; -import io.openaev.output_processor.OutputProcessorHandler; import jakarta.annotation.Resource; import java.util.*; import java.util.logging.Level; @@ -131,8 +131,7 @@ public Optional computeStructuredOutputUsingRegexRules( ArrayNode matchesArray = mapper.createArrayNode(); // Get handler once per contract output type - OutputProcessorHandler handler = - outputProcessorFactory.getHandler(contractOutputElement.getType()); + OutputProcessor handler = outputProcessorFactory.getHandler(contractOutputElement.getType()); while (matcher.find()) { buildStructuredJsonNode(contractOutputElement, matcher, handler) @@ -146,7 +145,7 @@ public Optional computeStructuredOutputUsingRegexRules( } public Optional buildStructuredJsonNode( - ContractOutputElement element, Matcher matcher, OutputProcessorHandler handler) { + ContractOutputElement element, Matcher matcher, OutputProcessor handler) { // Get metadata from handler instead of enum ContractOutputTechnicalType technicalType = handler.getTechnicalType(); diff --git a/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java index 06ddab8c8a4..9f428b78cd6 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java @@ -1,14 +1,15 @@ package io.openaev.output_processor; -import static org.junit.jupiter.api.Assertions.assertEquals; - import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; + class AbstractOutputProcessorTest { private static class TestOutputProcessor extends AbstractOutputProcessor { @@ -28,8 +29,7 @@ void setUp() { } @Test - @DisplayName( - "should join array elements and trim quotes when buildString is called with an array node") + @DisplayName("should join array elements and trim quotes when buildString is called with an array node") void shouldJoinArrayElementsAndTrimQuotesWhenBuildStringCalledWithArrayNode() throws Exception { JsonNode node = objectMapper.readTree("[\"foo\", \"bar\"]"); String result = processor.buildString(node); diff --git a/openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorHandlerIntegrationTest.java b/openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorHandlerIntegrationTest.java deleted file mode 100644 index 88e46ed24ad..00000000000 --- a/openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorHandlerIntegrationTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package io.openaev.output_processor; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; - -import io.openaev.IntegrationTest; -import io.openaev.database.model.ContractOutputType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.springframework.beans.factory.annotation.Autowired; - -@TestInstance(PER_CLASS) -@DisplayName("Integration tests for OutputProcessorHandler loading and context support") -class OutputProcessorHandlerIntegrationTest extends IntegrationTest { - - @Autowired private OutputProcessorFactory registry; - - @Test - void shouldLoadAllHandlersFromSpring() { - for (ContractOutputType type : ContractOutputType.values()) { - OutputProcessorHandler handler = registry.getHandler(type); - - assertThat(handler).withFailMessage("Handler not found for type: " + type).isNotNull(); - } - } - - @Test - void shouldReturnCorrectHandlerForEachType() { - assertThat(registry.getHandler(ContractOutputType.Text)) - .isInstanceOf(TextOutputProcessorHandler.class); - - assertThat(registry.getHandler(ContractOutputType.PortsScan)) - .isInstanceOf(PortScanOutputProcessorHandler.class); - - assertThat(registry.getHandler(ContractOutputType.CVE)) - .isInstanceOf(CVEOutputProcessorHandler.class); - } - - @Test - void shouldReturnSameInstanceOnMultipleCalls() { - OutputProcessorHandler handler1 = registry.getHandler(ContractOutputType.Text); - OutputProcessorHandler handler2 = registry.getHandler(ContractOutputType.Text); - - assertThat(handler1).isSameAs(handler2); - } -} From 0075bc39a4ab17ed39a9c78f5b86c1b94e3d1a5b Mon Sep 17 00:00:00 2001 From: savacano28 Date: Wed, 18 Feb 2026 14:00:06 +0100 Subject: [PATCH 03/21] [backend] feat: add tests --- .../output_processor/AbstractOutputProcessorTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java index 9f428b78cd6..06ddab8c8a4 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java @@ -1,15 +1,14 @@ package io.openaev.output_processor; +import static org.junit.jupiter.api.Assertions.assertEquals; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.util.Collections; - -import static org.junit.jupiter.api.Assertions.assertEquals; - class AbstractOutputProcessorTest { private static class TestOutputProcessor extends AbstractOutputProcessor { @@ -29,7 +28,8 @@ void setUp() { } @Test - @DisplayName("should join array elements and trim quotes when buildString is called with an array node") + @DisplayName( + "should join array elements and trim quotes when buildString is called with an array node") void shouldJoinArrayElementsAndTrimQuotesWhenBuildStringCalledWithArrayNode() throws Exception { JsonNode node = objectMapper.readTree("[\"foo\", \"bar\"]"); String result = processor.buildString(node); From ec3a7448a1a8905d262716d9481dcc80fa7647a6 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Wed, 18 Feb 2026 19:50:02 +0100 Subject: [PATCH 04/21] [backend] feat: feedback review --- .../AbstractOutputProcessor.java | 36 +- .../AssetOutputProcessor.java | 13 + .../output_processor/CVEOutputProcessor.java | 31 +- .../CredentialsOutputProcessor.java | 24 +- .../output_processor/IPv4OutputProcessor.java | 24 +- .../output_processor/IPv6OutputProcessor.java | 24 +- .../NumberOutputProcessor.java | 24 +- .../output_processor/OutputProcessor.java | 17 +- .../OutputProcessorFactory.java | 40 +- .../output_processor/PortOutputProcessor.java | 24 +- .../PortScanOutputProcessor.java | 27 +- .../output_processor/TextOutputProcessor.java | 24 +- .../openaev/rest/finding/FindingService.java | 396 ++++++++++-------- .../io/openaev/rest/finding/FindingUtils.java | 12 +- .../inject/form/InjectExecutionAction.java | 2 + .../AgentExecutionProcessingHandler.java | 99 +++++ .../service/BatchingInjectStatusService.java | 12 +- .../inject/service/ContractOutputContext.java | 37 ++ .../service/ExecutionProcessingContext.java | 41 ++ .../service/ExecutionProcessingHandler.java | 33 ++ .../service/InjectExecutionService.java | 175 +++----- .../inject/service/InjectStatusService.java | 4 +- .../InjectorExecutionProcessingHandler.java | 118 ++++++ .../inject/service/StructuredOutputUtils.java | 19 +- .../service/InjectExpectationService.java | 87 +++- .../ExpectationResultBuilder.java | 2 +- .../AbstractOutputProcessorTest.java | 20 +- .../CVEOutputProcessorTest.java | 66 ++- .../CredentialsOutputProcessorTest.java | 38 +- .../IPv4OutputProcessorTest.java | 41 ++ .../IPv6OutputProcessorTest.java | 43 ++ .../NumberOutputProcessorTest.java | 41 ++ .../OutputProcessorIntegrationTest.java | 16 +- .../PortOutputProcessorTest.java | 41 ++ .../PortScanOutputProcessorTest.java | 70 +++- .../TextOutputProcessorTest.java | 41 ++ .../rest/finding/FindingServiceTest.java | 256 ++++++----- .../io/openaev/rest/inject/InjectApiTest.java | 18 +- .../AgentExecutionProcessingHandlerTest.java | 163 +++++++ .../ExecutionProcessingContextTest.java | 35 ++ .../service/InjectExecutionServiceTest.java | 99 +++++ .../inject/service/InjectServiceTest.java | 34 +- ...njectorExecutionProcessingHandlerTest.java | 119 ++++++ .../service/InjectExecutionServiceTest.java | 161 ------- .../service/InjectExpectationServiceTest.java | 346 +++++++++++++-- .../utils/fixtures/OutputParserFixture.java | 4 +- .../utils/fixtures/PayloadFixture.java | 2 - .../utils/helpers/InjectTestHelper.java | 15 +- .../java/io/openaev/schema/SchemaUtils.java | 12 +- 49 files changed, 2246 insertions(+), 780 deletions(-) create mode 100644 openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/rest/inject/service/ContractOutputContext.java create mode 100644 openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingContext.java create mode 100644 openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingHandler.java create mode 100644 openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java create mode 100644 openaev-api/src/test/java/io/openaev/output_processor/IPv4OutputProcessorTest.java create mode 100644 openaev-api/src/test/java/io/openaev/output_processor/IPv6OutputProcessorTest.java create mode 100644 openaev-api/src/test/java/io/openaev/output_processor/NumberOutputProcessorTest.java create mode 100644 openaev-api/src/test/java/io/openaev/output_processor/PortOutputProcessorTest.java create mode 100644 openaev-api/src/test/java/io/openaev/output_processor/TextOutputProcessorTest.java create mode 100644 openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java create mode 100644 openaev-api/src/test/java/io/openaev/rest/inject/service/ExecutionProcessingContextTest.java create mode 100644 openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java create mode 100644 openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java delete mode 100644 openaev-api/src/test/java/io/openaev/service/InjectExecutionServiceTest.java diff --git a/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessor.java index bd432b700e4..138422dfde2 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessor.java @@ -63,9 +63,8 @@ public boolean validate(JsonNode jsonNode) { * Convert JSON node to finding value string. Override this method if handler supports findings. * Default returns empty string with warning log. */ - @Override public String toFindingValue(JsonNode jsonNode) { - log.warn("Handler {} does not implement toFindingValue, returning empty string", type); + log.debug("Handler {} does not implement toFindingValue, returning empty string", type); return ""; } @@ -74,7 +73,7 @@ public String toFindingValue(JsonNode jsonNode) { * returns empty list. */ public List toFindingAssets(JsonNode jsonNode) { - log.warn("Handler {} does not implement toFindingAssets, returning an empty list", type); + log.debug("Handler {} does not implement toFindingAssets, returning an empty list", type); return Collections.emptyList(); } @@ -83,7 +82,7 @@ public List toFindingAssets(JsonNode jsonNode) { * returns empty list. */ public List toFindingUsers(JsonNode jsonNode) { - log.warn("Handler {} does not implement toFindingUsers, returning an empty list", type); + log.debug("Handler {} does not implement toFindingUsers, returning an empty list", type); return Collections.emptyList(); } @@ -92,11 +91,20 @@ public List toFindingUsers(JsonNode jsonNode) { * returns empty list. */ public List toFindingTeams(JsonNode jsonNode) { - log.warn("Handler {} does not implement toFindingTeams, returning an empty list", type); + log.debug("Handler {} does not implement toFindingTeams, returning an empty list", type); return Collections.emptyList(); } - // Utility methods + // UTILITY methods + /** + * Builds a string representation from a JSON node. + * + *

If the node is an array, concatenates all elements (with quotes trimmed) separated by + * spaces. Otherwise, returns the node's text value with quotes trimmed. + * + * @param jsonNode the JSON node to process + * @return a string representation of the node's value(s) + */ protected String buildString(@NotNull final JsonNode jsonNode) { if (jsonNode.isArray()) { List values = new ArrayList<>(); @@ -108,6 +116,16 @@ protected String buildString(@NotNull final JsonNode jsonNode) { return trimQuotes(jsonNode.asText()); } + /** + * Builds a string representation from a specific key in a JSON node. + * + *

If the key is missing or null, returns an empty string. Otherwise, delegates to {@link + * #buildString(JsonNode)}. + * + * @param jsonNode the JSON node to process + * @param key the key to extract + * @return a string representation of the value at the given key, or empty string if not present + */ protected String buildString(@NotNull final JsonNode jsonNode, @NotBlank final String key) { JsonNode valueNode = jsonNode.get(key); if (valueNode == null || valueNode.isNull()) { @@ -116,6 +134,12 @@ protected String buildString(@NotNull final JsonNode jsonNode, @NotBlank final S return buildString(valueNode); } + /** + * Removes leading and trailing quotes from a string value. + * + * @param value the string to trim + * @return the string without leading/trailing quotes + */ protected String trimQuotes(@NotBlank final String value) { return value.replaceAll("^\"|\"$", ""); } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessor.java index 3382f9d9661..735d756820b 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessor.java @@ -1,7 +1,10 @@ package io.openaev.output_processor; +import com.fasterxml.jackson.databind.JsonNode; import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.springframework.stereotype.Component; @@ -11,4 +14,14 @@ public class AssetOutputProcessor extends AbstractOutputProcessor { public AssetOutputProcessor() { super(ContractOutputType.Asset, ContractOutputTechnicalType.Object, List.of(), false); } + + @Override + public void process( + ExecutionProcessingContext ctx, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + // The current implementation of the AssetOutputProcessor does not generate findings, but it can + // be extended in the future to do so if needed. + // For now, it simply validates the input and does not perform any additional processing. + } } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessor.java index 079165d5267..a0b0102ab26 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessor.java @@ -4,6 +4,10 @@ import io.openaev.database.model.ContractOutputField; import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.finding.FindingService; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; +import io.openaev.service.InjectExpectationService; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -17,7 +21,11 @@ public class CVEOutputProcessor extends AbstractOutputProcessor { private static final String HOST = "host"; private static final String SEVERITY = "severity"; - public CVEOutputProcessor() { + private final FindingService findingService; + private final InjectExpectationService injectExpectationService; + + public CVEOutputProcessor( + FindingService findingService, InjectExpectationService injectExpectationService) { super( ContractOutputType.CVE, ContractOutputTechnicalType.Object, @@ -27,6 +35,8 @@ public CVEOutputProcessor() { new ContractOutputField(HOST, ContractOutputTechnicalType.Text, true), new ContractOutputField(SEVERITY, ContractOutputTechnicalType.Text, true)), true); + this.findingService = findingService; + this.injectExpectationService = injectExpectationService; } @Override @@ -34,7 +44,24 @@ public boolean validate(JsonNode jsonNode) { return jsonNode.hasNonNull(ID) && jsonNode.hasNonNull(HOST) && jsonNode.hasNonNull(SEVERITY); } - // Findings + @Override + public void process( + ExecutionProcessingContext executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + findingService.generateFindings( + executionContext, + contractOutputContext, + structuredOutputNode, + this::validate, + this::toFindingValue, + this::toFindingAssets, + this::toFindingTeams, + this::toFindingUsers); + injectExpectationService.matchesVulnerabilityExpectations( + executionContext, structuredOutputNode); + } + @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode, ID); diff --git a/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessor.java index edf700c9d33..48a1565e1f8 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessor.java @@ -4,6 +4,9 @@ import io.openaev.database.model.ContractOutputField; import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.finding.FindingService; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.springframework.stereotype.Component; @@ -13,7 +16,9 @@ public class CredentialsOutputProcessor extends AbstractOutputProcessor { private static final String USERNAME = "username"; private static final String PASSWORD = "password"; - public CredentialsOutputProcessor() { + private final FindingService findingService; + + public CredentialsOutputProcessor(FindingService findingService) { super( ContractOutputType.Credentials, ContractOutputTechnicalType.Object, @@ -21,6 +26,7 @@ public CredentialsOutputProcessor() { new ContractOutputField(USERNAME, ContractOutputTechnicalType.Text, true), new ContractOutputField(PASSWORD, ContractOutputTechnicalType.Text, true)), true); + this.findingService = findingService; } @Override @@ -28,6 +34,22 @@ public boolean validate(JsonNode jsonNode) { return jsonNode.hasNonNull(USERNAME) && jsonNode.hasNonNull(PASSWORD); } + @Override + public void process( + ExecutionProcessingContext executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + findingService.generateFindings( + executionContext, + contractOutputContext, + structuredOutputNode, + this::validate, + this::toFindingValue, + this::toFindingAssets, + this::toFindingTeams, + this::toFindingUsers); + } + @Override public String toFindingValue(JsonNode jsonNode) { String username = buildString(jsonNode, USERNAME); diff --git a/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessor.java index 4df44cb8b5f..ecaef7b5c98 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessor.java @@ -3,6 +3,9 @@ import com.fasterxml.jackson.databind.JsonNode; import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.finding.FindingService; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.apache.commons.validator.routines.InetAddressValidator; import org.springframework.stereotype.Component; @@ -12,8 +15,11 @@ public class IPv4OutputProcessor extends AbstractOutputProcessor { private static final InetAddressValidator VALIDATOR = InetAddressValidator.getInstance(); - public IPv4OutputProcessor() { + private final FindingService findingService; + + public IPv4OutputProcessor(FindingService findingService) { super(ContractOutputType.IPv4, ContractOutputTechnicalType.Text, List.of(), true); + this.findingService = findingService; } @Override @@ -21,6 +27,22 @@ public boolean validate(JsonNode jsonNode) { return VALIDATOR.isValidInet4Address(jsonNode.asText()); } + @Override + public void process( + ExecutionProcessingContext executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + findingService.generateFindings( + executionContext, + contractOutputContext, + structuredOutputNode, + this::validate, + this::toFindingValue, + this::toFindingAssets, + this::toFindingTeams, + this::toFindingUsers); + } + @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode); diff --git a/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessor.java index ee70934a153..be765730406 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessor.java @@ -3,6 +3,9 @@ import com.fasterxml.jackson.databind.JsonNode; import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.finding.FindingService; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.apache.commons.validator.routines.InetAddressValidator; import org.springframework.stereotype.Component; @@ -12,8 +15,11 @@ public class IPv6OutputProcessor extends AbstractOutputProcessor { private static final InetAddressValidator VALIDATOR = InetAddressValidator.getInstance(); - public IPv6OutputProcessor() { + private final FindingService findingService; + + public IPv6OutputProcessor(FindingService findingService) { super(ContractOutputType.IPv6, ContractOutputTechnicalType.Text, List.of(), true); + this.findingService = findingService; } @Override @@ -21,6 +27,22 @@ public boolean validate(JsonNode jsonNode) { return VALIDATOR.isValidInet6Address(jsonNode.asText()); } + @Override + public void process( + ExecutionProcessingContext executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + findingService.generateFindings( + executionContext, + contractOutputContext, + structuredOutputNode, + this::validate, + this::toFindingValue, + this::toFindingAssets, + this::toFindingTeams, + this::toFindingUsers); + } + @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode); diff --git a/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessor.java index 8090bc203c9..810e14dc010 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessor.java @@ -3,18 +3,40 @@ import com.fasterxml.jackson.databind.JsonNode; import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.finding.FindingService; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.springframework.stereotype.Component; @Component public class NumberOutputProcessor extends AbstractOutputProcessor { - public NumberOutputProcessor() { + private final FindingService findingService; + + public NumberOutputProcessor(FindingService findingService) { super(ContractOutputType.Number, ContractOutputTechnicalType.Number, List.of(), true); + this.findingService = findingService; } @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode); } + + @Override + public void process( + ExecutionProcessingContext executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + findingService.generateFindings( + executionContext, + contractOutputContext, + structuredOutputNode, + this::validate, + this::toFindingValue, + this::toFindingAssets, + this::toFindingTeams, + this::toFindingUsers); + } } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessor.java index b6b1d5f488c..01bdb5cb471 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessor.java @@ -4,6 +4,8 @@ import io.openaev.database.model.ContractOutputField; import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; /** @@ -28,12 +30,11 @@ public interface OutputProcessor { /** Validate that the JSON node is correctly formatted for this type */ boolean validate(JsonNode jsonNode); - // FINDING methods - String toFindingValue(JsonNode jsonNode); - - List toFindingAssets(JsonNode jsonNode); - - List toFindingUsers(JsonNode jsonNode); - - List toFindingTeams(JsonNode jsonNode); + /** + * Process a set of operations like generating findings, matching expectations and process assets. + */ + void process( + ExecutionProcessingContext ctx, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode); } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java index 7a6d46e8bdc..2684a82fd9f 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java @@ -6,26 +6,48 @@ import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +/** + * Factory for retrieving {@link OutputProcessor} instances by {@link ContractOutputType}. + * + *

This factory is initialized with all available OutputProcessor beans and provides a lookup + * method to retrieve the appropriate processor for a given output type. Throws an exception if no + * processor is found for the requested type. + */ +@Slf4j @Component public class OutputProcessorFactory { private final Map outputProcessorHandlerMap; + /** + * Constructs the factory and registers all available output processors by their type. + * + * @param handlers the list of available OutputProcessor beans + */ public OutputProcessorFactory(List handlers) { this.outputProcessorHandlerMap = handlers.stream().collect(Collectors.toMap(OutputProcessor::getType, Function.identity())); } - public OutputProcessor getHandler(ContractOutputType type) { - return Optional.ofNullable(outputProcessorHandlerMap.get(type)) - .orElseThrow( - () -> - new IllegalArgumentException( - "No handler found for type: " - + type - + ". Available types: " - + outputProcessorHandlerMap.keySet())); + /** + * Retrieves the {@link OutputProcessor} for the given output type. + * + * @param type the contract output type + * @return the corresponding OutputProcessor + * @throws IllegalArgumentException if no processor is found for the given type + */ + public Optional getProcessor(ContractOutputType type) { + OutputProcessor processor = outputProcessorHandlerMap.get(type); + if (processor == null) { + log.warn( + "No processor found for type: {}. Available types: {}", + type, + outputProcessorHandlerMap.keySet()); + return Optional.empty(); + } + return Optional.of(processor); } } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessor.java index 3e455800662..1cb467e4c7b 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessor.java @@ -3,18 +3,40 @@ import com.fasterxml.jackson.databind.JsonNode; import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.finding.FindingService; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.springframework.stereotype.Component; @Component public class PortOutputProcessor extends AbstractOutputProcessor { - public PortOutputProcessor() { + private final FindingService findingService; + + public PortOutputProcessor(FindingService findingService) { super(ContractOutputType.Port, ContractOutputTechnicalType.Number, List.of(), true); + this.findingService = findingService; } @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode); } + + @Override + public void process( + ExecutionProcessingContext executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + findingService.generateFindings( + executionContext, + contractOutputContext, + structuredOutputNode, + this::validate, + this::toFindingValue, + this::toFindingAssets, + this::toFindingTeams, + this::toFindingUsers); + } } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessor.java index 7646ed1e07f..c4359e279e8 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessor.java @@ -6,6 +6,10 @@ import io.openaev.database.model.ContractOutputField; import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.finding.FindingService; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; +import java.util.Collections; import java.util.List; import org.springframework.stereotype.Component; @@ -17,7 +21,9 @@ public class PortScanOutputProcessor extends AbstractOutputProcessor { private static final String PORT = "port"; private static final String SERVICE = "service"; - public PortScanOutputProcessor() { + private final FindingService findingService; + + public PortScanOutputProcessor(FindingService findingService) { super( ContractOutputType.PortsScan, ContractOutputTechnicalType.Object, @@ -27,6 +33,7 @@ public PortScanOutputProcessor() { new ContractOutputField(PORT, ContractOutputTechnicalType.Number, true), new ContractOutputField(SERVICE, ContractOutputTechnicalType.Text, true)), true); + this.findingService = findingService; } @Override @@ -34,6 +41,22 @@ public boolean validate(JsonNode jsonNode) { return jsonNode.hasNonNull(HOST) && jsonNode.hasNonNull(PORT) && jsonNode.hasNonNull(SERVICE); } + @Override + public void process( + ExecutionProcessingContext executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + findingService.generateFindings( + executionContext, + contractOutputContext, + structuredOutputNode, + this::validate, + this::toFindingValue, + this::toFindingAssets, + this::toFindingTeams, + this::toFindingUsers); + } + @Override public String toFindingValue(JsonNode jsonNode) { String host = buildString(jsonNode, HOST); @@ -48,6 +71,6 @@ public List toFindingAssets(JsonNode jsonNode) { if (assetIdNode != null) { return List.of(assetIdNode.asText()); } - return List.of(); + return Collections.emptyList(); } } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessor.java index 937b96b3c97..509f9554f02 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessor.java @@ -3,18 +3,40 @@ import com.fasterxml.jackson.databind.JsonNode; import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.finding.FindingService; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.springframework.stereotype.Component; @Component public class TextOutputProcessor extends AbstractOutputProcessor { - public TextOutputProcessor() { + private final FindingService findingService; + + public TextOutputProcessor(FindingService findingService) { super(ContractOutputType.Text, ContractOutputTechnicalType.Text, List.of(), true); + this.findingService = findingService; } @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode); } + + @Override + public void process( + ExecutionProcessingContext executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + findingService.generateFindings( + executionContext, + contractOutputContext, + structuredOutputNode, + this::validate, + this::toFindingValue, + this::toFindingAssets, + this::toFindingTeams, + this::toFindingUsers); + } } diff --git a/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java index 5ca315c4d27..3ff86ad4dc4 100644 --- a/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java +++ b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java @@ -3,23 +3,22 @@ import static io.openaev.helper.StreamHelper.fromIterable; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.annotations.VisibleForTesting; import io.openaev.database.model.*; import io.openaev.database.repository.AssetRepository; import io.openaev.database.repository.FindingRepository; import io.openaev.database.repository.TeamRepository; import io.openaev.database.repository.UserRepository; -import io.openaev.injector_contract.outputs.InjectorContractContentOutputElement; -import io.openaev.output_processor.OutputProcessor; -import io.openaev.output_processor.OutputProcessorFactory; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import io.openaev.rest.inject.service.InjectService; -import io.openaev.rest.injector_contract.InjectorContractContentUtils; -import jakarta.annotation.Resource; import jakarta.persistence.EntityNotFoundException; import jakarta.validation.constraints.NotBlank; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; @@ -32,6 +31,7 @@ @Transactional public class FindingService { + private static final String HOST = "host"; private final InjectService injectService; private final FindingRepository findingRepository; @@ -39,12 +39,6 @@ public class FindingService { private final TeamRepository teamRepository; private final UserRepository userRepository; - private final InjectorContractContentUtils injectorContractContentUtils; - - @Resource private ObjectMapper mapper; - - private final OutputProcessorFactory outputProcessorFactory; - // -- CRUD -- public List findings() { @@ -63,13 +57,6 @@ public Finding createFinding(@NotNull final Finding finding, @NotBlank final Str return this.findingRepository.save(finding); } - public Iterable createFindings( - @NotNull final List findings, @NotBlank final String injectId) { - Inject inject = this.injectService.inject(injectId); - findings.forEach(finding -> finding.setInject(inject)); - return this.findingRepository.saveAll(findings); - } - public Finding updateFinding(@NotNull final Finding finding, @NotNull final String injectId) { if (!finding.getInject().getId().equals(injectId)) { throw new IllegalArgumentException("Inject id cannot be changed: " + injectId); @@ -85,197 +72,236 @@ public void deleteFinding(@NotNull final String id) { } /** - * Builds a Finding based on the provided parameters. If a Finding with the same inject ID, value, - * type, and key already exists, it will update the assets associated with it. + * Generates findings based on the provided JSON node and context. It determines whether the + * execution is agent-based or injector-based and processes the findings accordingly. * - * @param inject the Inject object associated with the Finding - * @param asset the Asset to be linked to the Finding - * @param contractOutputElement the ContractOutputElement defining the type and key of the Finding - * @param finalValue the value of the Finding to be stored + * @param executionContext The context of the execution, containing information about whether it's + * an agent execution and relevant data for processing. + * @param contractOutputContext The context of the contract output, providing details about the + * expected findings format and metadata. + * @param structuredOutputNode The JSON node containing the raw data from which findings will be + * generated. + * @param validator A predicate function to validate the format of each finding in the JSON node. + * @param valueExtractor A function to extract the value for each finding from the JSON node. + * @param assetExtractor A function to extract associated asset IDs for each finding from the JSON + * node (used for injector findings). + * @param userExtractor A function to extract associated user IDs for each finding from the JSON + * node (used for injector findings). + * @param teamExtractor A function to extract associated team IDs for each finding from the JSON + * node (used for injector findings). */ - public void buildFinding( - Inject inject, Asset asset, ContractOutputElement contractOutputElement, String finalValue) { - String[] tagIds = - contractOutputElement.getTags().isEmpty() - ? new String[0] - : contractOutputElement.getTags().stream().map(Tag::getId).toArray(String[]::new); - - // Save or update the finding and add or update the list of assets and/or tags - findingRepository.saveCompleteFinding( - contractOutputElement.getKey(), - contractOutputElement.getType().name(), - finalValue, - new String[0], - inject.getId(), - contractOutputElement.getName(), - asset.getId(), - tagIds); + public void generateFindings( + ExecutionProcessingContext executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode, + Predicate validator, + Function valueExtractor, + Function> assetExtractor, + Function> userExtractor, + Function> teamExtractor) { + + if (executionContext.isAgentExecution()) { + processAgentFindings( + structuredOutputNode, + executionContext.inject(), + executionContext.agent(), + contractOutputContext, + executionContext.valueTargetedAssetsMap(), + validator, + valueExtractor); + } else { + processInjectorFindings( + structuredOutputNode, + executionContext.inject(), + contractOutputContext, + validator, + valueExtractor, + assetExtractor, + userExtractor, + teamExtractor); + } } - // -- Extract findings from structured output : Here we compute the findings from structured - // output - // from ExecutionInjectInput sent by injectors - // This structured output is generated based on injectorcontract where we can find the node - // Outputs and with that the injector generate this structure output-- - public void extractFindingsFromInjectorContract(Inject inject, ObjectNode structuredOutput) { - if (structuredOutput == null) { + public void processAgentFindings( + JsonNode structuredOutputNode, + Inject inject, + Agent agent, + ContractOutputContext contractOutputContext, + Map valueTargetedAssetsMap, + Predicate validator, + Function valueExtractor) { + + if (structuredOutputNode == null || !structuredOutputNode.isArray()) { + log.debug("Skipping agent findings: structuredOutputNode is null or not an array"); return; } - // Get the contract - InjectorContract injectorContract = inject.getInjectorContract().orElseThrow(); - List contractOutputs = - injectorContractContentUtils.getContractOutputs( - injectorContract.getConvertedContent(), mapper); - - if (contractOutputs.isEmpty()) { - log.warn("No contract outputs found for inject: " + inject.getId()); - return; + log.debug("Processing {} nodes for agent finding", structuredOutputNode.size()); + for (JsonNode jsonNode : structuredOutputNode) { + if (!validator.test(jsonNode)) { + log.error("Validation failed for node: {}", jsonNode); + continue; + } + + resolveAssetFromStructuredOutput(jsonNode, valueTargetedAssetsMap, agent) + .ifPresentOrElse( + asset -> + saveAgentFinding( + inject, asset, contractOutputContext, valueExtractor.apply(jsonNode)), + () -> log.warn("Finding dropped: No asset match for host in {}", jsonNode)); } + } - List findings = getFindingsFromInjectorContract(contractOutputs, structuredOutput); - if (findings == null) { - return; - } + public void saveAgentFinding( + Inject inject, Asset asset, ContractOutputContext contractOutputContext, String value) { - this.createFindings(findings, inject.getId()); + findingRepository.saveCompleteFinding( + contractOutputContext.key(), + contractOutputContext.type().name(), + value, + new String[0], + inject.getId(), + contractOutputContext.name(), + asset.getId(), + contractOutputContext.tagIds()); } - @VisibleForTesting - List getFindingsFromInjectorContract( - List contractOutputs, ObjectNode structuredOutput) { - - List findings = new ArrayList<>(); - contractOutputs.forEach( - contractOutput -> { - if (!contractOutput.isFindingCompatible()) { - return; - } - OutputProcessor handler = outputProcessorFactory.getHandler(contractOutput.getType()); - - if (contractOutput.isMultiple()) { - JsonNode jsonNodes = structuredOutput.get(contractOutput.getField()); - if (jsonNodes != null && jsonNodes.isArray()) { - for (JsonNode jsonNode : jsonNodes) { - if (!handler.validate(jsonNode)) { - throw new IllegalArgumentException("Finding not correctly formatted"); - } - Finding finding = FindingUtils.createFinding(contractOutput); - finding.setValue(handler.toFindingValue(jsonNode)); - Finding linkedFinding = linkFindings(jsonNode, finding, handler); - findings.add(linkedFinding); - } - } - } else { - JsonNode jsonNode = structuredOutput.get(contractOutput.getField()); - if (!handler.validate(jsonNode)) { - throw new IllegalArgumentException("Finding not correctly formatted"); - } - Finding finding = FindingUtils.createFinding(contractOutput); - finding.setValue(handler.toFindingValue(jsonNode)); - Finding linkedFinding = linkFindings(jsonNode, finding, handler); - findings.add(linkedFinding); - } - }); - - return findings; + private Optional resolveAssetFromStructuredOutput( + JsonNode structuredOutput, Map valueTargetedAssetsMap, Agent sourceAgent) { + if (valueTargetedAssetsMap.isEmpty() || !structuredOutput.has(HOST)) { + return Optional.of(sourceAgent.getAsset()); + } + + String host = structuredOutput.get(HOST).asText(); + return valueTargetedAssetsMap.keySet().stream() + .filter(host::contains) + .findFirst() + .map(valueTargetedAssetsMap::get); } - private Finding linkFindings(JsonNode jsonNode, Finding finding, OutputProcessor handler) { - // Create links with assets - List assetsIds = handler.toFindingAssets(jsonNode); - List> assets = assetsIds.stream().map(this.assetRepository::findById).toList(); - if (!assets.isEmpty()) { - finding.setAssets(assets.stream().filter(Optional::isPresent).map(Optional::get).toList()); - } - // Create links with teams - List teamsIds = handler.toFindingTeams(jsonNode); - List> teams = teamsIds.stream().map(this.teamRepository::findById).toList(); - if (!teams.isEmpty()) { - finding.setTeams(teams.stream().filter(Optional::isPresent).map(Optional::get).toList()); - } - // Create links with users - List usersIds = handler.toFindingUsers(jsonNode); - List> users = usersIds.stream().map(this.userRepository::findById).toList(); - if (!users.isEmpty()) { - finding.setUsers(users.stream().filter(Optional::isPresent).map(Optional::get).toList()); + public void processInjectorFindings( + JsonNode structuredOutputNode, + Inject inject, + ContractOutputContext contractOutputContext, + Predicate validator, + Function valueExtractor, + Function> assetExtractor, + Function> userExtractor, + Function> teamExtractor) { + + if (structuredOutputNode == null) { + log.debug("Skipping injector findings: structuredOutputNode is null"); + return; } - return finding; + + List findings = + buildFindings( + structuredOutputNode, + contractOutputContext, + validator, + valueExtractor, + assetExtractor, + userExtractor, + teamExtractor); + + createFindings(findings, inject.getId()); } /** - * Function used to get Finding Contract Output Element from OutputParser + * Persists a list of findings in the database, associating them with a specific inject. * - * @param outputParsers OutputParser - * @return list of contractOutputElement of OutputParser + * @param findings The list of findings to be created and persisted. + * @param injectId The identifier of to inject to which the findings will be associated. Must not + * be blank. */ - private List getAllIsFindingContractOutputElementsOfOutputParser( - Set outputParsers) { - return outputParsers.stream() - .flatMap(outputParser -> outputParser.getContractOutputElements().stream()) - .filter(io.openaev.database.model.ContractOutputElement::isFinding) - .toList(); + public void createFindings( + @NotNull final List findings, @NotBlank final String injectId) { + Inject inject = injectService.inject(injectId); + findings.forEach(finding -> finding.setInject(inject)); + findingRepository.saveAll(findings); } - /** - * Function used to get the asset associated with a given structured output. - * - * @param struturedOutput The structured output to analyze. - * @param valueTargetedAssetsMap a map where the key is the value of the targeted asset (e.g., - * hostname, seen_ip) and the value is the Endpoint object representing the targeted asset. - * @param sourceAgent The agent where the execution occurred. - * @return The linked Asset. - */ - private Asset getAssetLinkedToStructuredOutput( - JsonNode struturedOutput, Map valueTargetedAssetsMap, Agent sourceAgent) { - if (valueTargetedAssetsMap.isEmpty() || !struturedOutput.has("host")) { - return sourceAgent.getAsset(); + public List buildFindings( + JsonNode structuredOutputNode, + ContractOutputContext contractOutputContext, + Predicate validator, + Function valueExtractor, + Function> assetExtractor, + Function> userExtractor, + Function> teamExtractor) { + + if (contractOutputContext.isMultiple() && structuredOutputNode.isArray()) { + List findings = new ArrayList<>(); + for (JsonNode node : structuredOutputNode) { + findings.add( + buildSingleFinding( + node, + contractOutputContext, + validator, + valueExtractor, + assetExtractor, + userExtractor, + teamExtractor)); + } + return findings; } - String host = struturedOutput.get("host").asText(); - return valueTargetedAssetsMap.keySet().stream() - .filter(host::contains) - .findFirst() - .map(valueTargetedAssetsMap::get) - .orElse(null); + return List.of( + buildSingleFinding( + structuredOutputNode, + contractOutputContext, + validator, + valueExtractor, + assetExtractor, + userExtractor, + teamExtractor)); } - /** Extracts findings from structured output that was generated using output parsers. */ - public void extractFindingsFromOutputParsers( - Inject inject, Agent agent, Set outputParsers, JsonNode structuredOutput) { + private Finding buildSingleFinding( + JsonNode structuredOutputNode, + ContractOutputContext contractOutputContext, + Predicate validator, + Function valueExtractor, + Function> assetExtractor, + Function> userExtractor, + Function> teamExtractor) { + + if (!validator.test(structuredOutputNode)) { + throw new IllegalArgumentException( + "Finding not correctly formatted: " + structuredOutputNode); + } - if (structuredOutput == null) { - return; + Finding finding = FindingUtils.createFinding(contractOutputContext); + finding.setValue(valueExtractor.apply(structuredOutputNode)); + return linkFinding(structuredOutputNode, finding, assetExtractor, userExtractor, teamExtractor); + } + + private Finding linkFinding( + JsonNode structuredOutputNode, + Finding finding, + Function> assetExtractor, + Function> userExtractor, + Function> teamExtractor) { + + List assetIds = assetExtractor.apply(structuredOutputNode); + if (!assetIds.isEmpty()) { + finding.setAssets(fetchEntities(assetIds, assetRepository::findById)); } - List contractOutputElements = - this.getAllIsFindingContractOutputElementsOfOutputParser(outputParsers); - - Map valueTargetedAssetsMap = injectService.getValueTargetedAssetMap(inject); - - contractOutputElements.forEach( - contractOutputElement -> { - OutputProcessor handler = - outputProcessorFactory.getHandler(contractOutputElement.getType()); - - JsonNode jsonNodes = structuredOutput.get(contractOutputElement.getKey()); - if (jsonNodes == null || !jsonNodes.isArray()) { - return; - } - - for (JsonNode jsonNode : jsonNodes) { - // Validate finding format - if (!handler.validate(jsonNode)) { - throw new IllegalArgumentException("Finding not correctly formatted"); - } - - // Build and save the finding - this.buildFinding( - inject, - getAssetLinkedToStructuredOutput(jsonNode, valueTargetedAssetsMap, agent), - contractOutputElement, - handler.toFindingValue(jsonNode)); - } - }); + List teamIds = teamExtractor.apply(structuredOutputNode); + if (!teamIds.isEmpty()) { + finding.setTeams(fetchEntities(teamIds, teamRepository::findById)); + } + + List userIds = userExtractor.apply(structuredOutputNode); + if (!userIds.isEmpty()) { + finding.setUsers(fetchEntities(userIds, userRepository::findById)); + } + + return finding; + } + + private List fetchEntities(List ids, Function> finder) { + return ids.stream().map(finder).filter(Optional::isPresent).map(Optional::get).toList(); } } diff --git a/openaev-api/src/main/java/io/openaev/rest/finding/FindingUtils.java b/openaev-api/src/main/java/io/openaev/rest/finding/FindingUtils.java index a54336e11d1..6cea0d7fd55 100644 --- a/openaev-api/src/main/java/io/openaev/rest/finding/FindingUtils.java +++ b/openaev-api/src/main/java/io/openaev/rest/finding/FindingUtils.java @@ -1,18 +1,18 @@ package io.openaev.rest.finding; -import io.openaev.database.model.*; -import io.openaev.injector_contract.outputs.InjectorContractContentOutputElement; +import io.openaev.database.model.Finding; +import io.openaev.rest.inject.service.ContractOutputContext; import org.jetbrains.annotations.NotNull; public final class FindingUtils { private FindingUtils() {} - public static Finding createFinding(@NotNull final InjectorContractContentOutputElement element) { + public static Finding createFinding(@NotNull final ContractOutputContext element) { Finding finding = new Finding(); - finding.setType(element.getType()); - finding.setField(element.getField()); - finding.setLabels(element.getLabels()); + finding.setType(element.type()); + finding.setField(element.key()); + finding.setLabels(element.labels()); // TODO: Set tags return finding; } } diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/form/InjectExecutionAction.java b/openaev-api/src/main/java/io/openaev/rest/inject/form/InjectExecutionAction.java index 95d0bd52d84..4d6ed64308c 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/form/InjectExecutionAction.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/form/InjectExecutionAction.java @@ -4,9 +4,11 @@ public enum InjectExecutionAction { prerequisite_check, prerequisite_execution, cleanup_execution, + command_execution, dns_resolution, file_execution, file_drop, + complete, } diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java new file mode 100644 index 00000000000..aa72a866f9d --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java @@ -0,0 +1,99 @@ +package io.openaev.rest.inject.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.openaev.database.model.ContractOutputElement; +import io.openaev.database.model.ExecutionTraceAction; +import io.openaev.database.model.OutputParser; +import io.openaev.output_processor.OutputProcessorFactory; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * Handler for processing inject executions triggered by an agent. + * + *

This handler generates structured output from the raw execution input and processes additional + * capabilities such as findings extraction, expectation matching, or asset creation if applicable. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AgentExecutionProcessingHandler implements ExecutionProcessingHandler { + + private final StructuredOutputUtils structuredOutputUtils; + private final OutputProcessorFactory outputProcessorFactory; + + /** + * Determines if this handler supports the given execution context (agent execution). + * + * @param executionContext the execution context to check + * @return true if the context is for an agent execution, false otherwise + */ + @Override + public boolean supports(ExecutionProcessingContext executionContext) { + return executionContext.isAgentExecution(); + } + + /** + * Processes the execution context, generating structured output and handling additional + * capabilities such as findings extraction, expectation matching, or asset creation. + * + * @param executionContext the execution context to process + * @return an optional ObjectNode result, if processing produces output + * @throws JsonProcessingException if JSON serialization fails during processing + */ + public Optional processContext(ExecutionProcessingContext executionContext) + throws JsonProcessingException { + if (!executionContext.isSuccess() + || !ExecutionTraceAction.EXECUTION.equals(executionContext.getAction())) { + return Optional.empty(); + } + + Set outputParsers = + structuredOutputUtils.extractOutputParsers(executionContext.inject()); + + // Attempt to compute structured output from the raw message + return structuredOutputUtils + .computeStructuredOutputFromOutputParsers( + outputParsers, executionContext.input().getMessage()) + .map( + structuredOutput -> { + // Process findings for each compatible output parser + getAllIsFindingCompatibleContractOutputs(outputParsers).stream() + .map(ContractOutputContext::from) + .forEach( + contractOutputCtx -> { + outputProcessorFactory + .getProcessor(contractOutputCtx.type()) + .ifPresent( + processor -> { + JsonNode node = structuredOutput.path(contractOutputCtx.key()); + if (!node.isMissingNode()) { + processor.process(executionContext, contractOutputCtx, node); + } + }); + }); + return structuredOutput; + }); + } + + /** + * Retrieves all contract output elements from the output parsers that are compatible with + * findings. + * + * @param outputParsers the set of output parsers to inspect + * @return list of finding-compatible contract output elements + */ + private List getAllIsFindingCompatibleContractOutputs( + Set outputParsers) { + return outputParsers.stream() + .flatMap(outputParser -> outputParser.getContractOutputElements().stream()) + .filter(ContractOutputElement::isFinding) + .toList(); + } +} diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/BatchingInjectStatusService.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/BatchingInjectStatusService.java index 0147f8a394f..8fa0c721b6f 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/BatchingInjectStatusService.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/BatchingInjectStatusService.java @@ -2,8 +2,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.openaev.aop.LogExecutionTime; -import io.openaev.database.model.*; -import io.openaev.database.repository.*; +import io.openaev.database.model.Agent; +import io.openaev.database.model.ExecutionStatus; +import io.openaev.database.model.Inject; +import io.openaev.database.repository.AgentRepository; +import io.openaev.database.repository.InjectRepository; import io.openaev.rest.exception.ElementNotFoundException; import io.openaev.rest.inject.form.InjectExecutionAction; import io.openaev.rest.inject.form.InjectExecutionCallback; @@ -127,12 +130,9 @@ public List handleInjectExecutionCallback( "Agent not found: " + callback.getAgentId()))) .orElse(null); - // Extract the output parsers - Set outputParsers = structuredOutputUtils.extractOutputParsers(inject); - // Process the execution trace injectExecutionService.processInjectExecution( - inject, agent, callback.getInjectExecutionInput(), outputParsers); + inject, agent, callback.getInjectExecutionInput()); successfullyProcessedCallbacks.add(callback); } catch (ElementNotFoundException e) { injectExecutionService.handleInjectExecutionError(inject, e); diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/ContractOutputContext.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/ContractOutputContext.java new file mode 100644 index 00000000000..c39cbcb9c5c --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/ContractOutputContext.java @@ -0,0 +1,37 @@ +package io.openaev.rest.inject.service; + +import io.openaev.database.model.ContractOutputElement; +import io.openaev.database.model.ContractOutputType; +import io.openaev.database.model.Tag; +import io.openaev.injector_contract.outputs.InjectorContractContentOutputElement; + +public record ContractOutputContext( + String key, // maps to contractOutputElement.getKey() / contentOutputElement.getField() + String name, // display name / label + ContractOutputType type, + boolean isMultiple, + String[] tagIds, + String[] labels) { + + public static ContractOutputContext from(ContractOutputElement element) { + return new ContractOutputContext( + element.getKey(), + element.getName(), + element.getType(), + true, + element.getTags().isEmpty() + ? new String[0] + : element.getTags().stream().map(Tag::getId).toArray(String[]::new), + new String[0]); + } + + public static ContractOutputContext from(InjectorContractContentOutputElement element) { + return new ContractOutputContext( + element.getField(), + element.getField(), // or derive name differently + element.getType(), + element.isMultiple(), + new String[0], // tags not available here yet + element.getLabels()); + } +} diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingContext.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingContext.java new file mode 100644 index 00000000000..dbebcc5dfef --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingContext.java @@ -0,0 +1,41 @@ +package io.openaev.rest.inject.service; + +import static io.openaev.utils.ExecutionTraceUtils.convertExecutionAction; + +import io.openaev.database.model.*; +import io.openaev.rest.inject.form.InjectExecutionInput; +import jakarta.annotation.Nullable; +import java.util.Map; + +/** + * Context object for processing an inject execution. + * + *

Holds references to the inject, agent, input, and targeted assets map. Provides utility + * methods to determine execution status and type. + */ +public record ExecutionProcessingContext( + Inject inject, + @Nullable Agent agent, + InjectExecutionInput input, + Map valueTargetedAssetsMap) { + + /** Returns true if the execution status is SUCCESS. */ + public boolean isSuccess() { + return ExecutionTraceStatus.SUCCESS.toString().equals(input.getStatus()); + } + + /** Returns true if the execution is for an injector (not agent). */ + public boolean isInjectorExecution() { + return !isAgentExecution(); + } + + /** Returns true if the execution is for an agent. */ + public boolean isAgentExecution() { + return agent != null; + } + + /** Returns the execution action for this context. */ + public ExecutionTraceAction getAction() { + return convertExecutionAction(input.getAction()); + } +} diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingHandler.java new file mode 100644 index 00000000000..7026e4c09ca --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingHandler.java @@ -0,0 +1,33 @@ +package io.openaev.rest.inject.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Optional; + +/** + * Handler interface for processing inject execution contexts. + * + *

Implementations determine if they support a given context and process it, typically generating + * structured output and then processing additional capabilities (findings, expectation matching, + * asset creation). + */ +public interface ExecutionProcessingHandler { + /** + * Determines if this handler supports the given execution context. + * + * @param executionContext the execution context to check + * @return true if supported, false otherwise + */ + boolean supports(ExecutionProcessingContext executionContext); + + /** + * Processes the execution context, generating structured output and handling additional + * capabilities. + * + * @param executionContext the execution context to process + * @return an optional ObjectNode result, if processing produces output + * @throws JsonProcessingException if JSON serialization fails during processing + */ + Optional processContext(ExecutionProcessingContext executionContext) + throws JsonProcessingException; +} diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectExecutionService.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectExecutionService.java index e2ce7794fed..285c720c878 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectExecutionService.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectExecutionService.java @@ -1,9 +1,6 @@ package io.openaev.rest.inject.service; -import static io.openaev.expectation.ExpectationType.VULNERABILITY; import static io.openaev.utils.ExecutionTraceUtils.convertExecutionAction; -import static io.openaev.utils.ExpectationUtils.*; -import static io.openaev.utils.inject_expectation_result.ExpectationResultBuilder.buildForVulnerabilityManagerInFailed; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -11,19 +8,16 @@ import com.google.common.annotations.VisibleForTesting; import io.openaev.database.model.*; import io.openaev.database.repository.AgentRepository; -import io.openaev.database.repository.InjectExpectationRepository; import io.openaev.database.repository.InjectRepository; import io.openaev.rest.exception.ElementNotFoundException; -import io.openaev.rest.finding.FindingService; import io.openaev.rest.inject.form.InjectExecutionAction; import io.openaev.rest.inject.form.InjectExecutionInput; -import io.openaev.rest.inject.form.InjectExpectationUpdateInput; import io.openaev.service.InjectExpectationService; import jakarta.annotation.Nullable; import jakarta.annotation.Resource; import java.time.Instant; import java.util.List; -import java.util.Set; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -36,12 +30,11 @@ public class InjectExecutionService { private final InjectRepository injectRepository; - private final InjectExpectationRepository injectExpectationRepository; private final InjectExpectationService injectExpectationService; private final AgentRepository agentRepository; private final InjectStatusService injectStatusService; - private final FindingService findingService; - private final StructuredOutputUtils structuredOutputUtils; + private final InjectService injectService; + public final List executionProcessingHandlers; @Resource protected ObjectMapper mapper; @@ -75,54 +68,66 @@ public void handleInjectExecutionCallback( "Cannot complete inject that is not in PENDING state"); } Agent agent = loadAgentIfPresent(agentId); - - Set outputParsers = structuredOutputUtils.extractOutputParsers(inject); - - processInjectExecution(inject, agent, input, outputParsers); + processInjectExecution(inject, agent, input); } catch (ElementNotFoundException e) { handleInjectExecutionError(inject, e); } } - /** Processes the execution of an inject by updating its status and extracting findings. */ + /** + * Processes the execution of an inject by resolving the appropriate handler based on the + * execution source (injector or agent), extracting findings, matching expectations and updating + * the inject status. + * + * @param inject the inject being executed + * @param agent the agent executing to inject, or {@code null} if triggered by an injector + * @param input the execution input containing action, status, and output data + * @throws RuntimeException if the output structured cannot be parsed + */ public void processInjectExecution( - Inject inject, - @Nullable Agent agent, - InjectExecutionInput input, - Set outputParsers) { - ObjectNode structured = null; + Inject inject, @Nullable Agent agent, InjectExecutionInput input) { try { - if (input.getOutputStructured() != null) { - structured = mapper.readValue(input.getOutputStructured(), ObjectNode.class); - } - // Only compute if the action is actual execution - if (ExecutionTraceAction.EXECUTION.equals(convertExecutionAction(input.getAction()))) { - // validate vulnerability expectations - structured = - structuredOutputUtils - .computeStructuredOutputFromOutputParsers(outputParsers, input.getMessage()) - .orElse(null); - if (ExecutionTraceStatus.SUCCESS.toString().equals(input.getStatus())) { - checkCveExpectation(outputParsers, structured, inject, agent); - } - } - - injectStatusService.updateInjectStatus(agent, inject, input, structured); + Map valueTargetedAssetsMap = injectService.getValueTargetedAssetMap(inject); + // Build the context encapsulating all execution data and conditions (success, action, source) + ExecutionProcessingContext executionContext = + new ExecutionProcessingContext(inject, agent, input, valueTargetedAssetsMap); + // Delegate to the appropriate handler (injector or agent) to process output execution + ObjectNode resolvedStructured = + resolveExecutionContext(executionContext).processContext(executionContext).orElse(null); + + injectStatusService.updateInjectStatus(inject, agent, input, resolvedStructured); addEndDateInjectExpectationTimeSignatureIfNeeded(inject, agent, input); - if (agent != null) { - // Extract findings from structured outputs generated by the output parsers specified in the - // payload, typically derived from the raw output of the implant execution. - findingService.extractFindingsFromOutputParsers(inject, agent, outputParsers, structured); - } else { - // Structured output directly provided (e.g., from injectors) - findingService.extractFindingsFromInjectorContract(inject, structured); - } } catch (JsonProcessingException e) { - throw new RuntimeException(e); + throw new RuntimeException("Failed to process inject execution for inject", e); } } + /** + * Resolves the appropriate execution processing handler based on the execution context. Expects + * exactly one handler to support the given context, otherwise throws an exception. + * + * @param executionContext + * @return + */ + public ExecutionProcessingHandler resolveExecutionContext( + ExecutionProcessingContext executionContext) { + List matchingHandlers = + executionProcessingHandlers.stream().filter(h -> h.supports(executionContext)).toList(); + + if (matchingHandlers.isEmpty()) { + throw new IllegalStateException( + "No handler found for execution context: " + executionContext); + } + + if (matchingHandlers.size() > 1) { + throw new IllegalStateException( + "Multiple handlers matched execution context: " + matchingHandlers); + } + + return matchingHandlers.getFirst(); + } + /** * Adds an end date signature to inject expectations if the action is COMPLETE. * @@ -144,86 +149,6 @@ public void addEndDateInjectExpectationTimeSignatureIfNeeded( } } - /** - * Checks output parsers of an agent and updates the scores of vulnerability expectations - * accordingly - * - * @param outputParsers - * @param structuredOutput - * @param inject - * @param agent - */ - public void checkCveExpectation( - Set outputParsers, ObjectNode structuredOutput, Inject inject, Agent agent) { - - List injectExpectations = - inject.getExpectations().stream() - .filter(exp -> exp.getAgent() != null && exp.getAgent().getId().equals(agent.getId())) - .filter(exp -> InjectExpectation.EXPECTATION_TYPE.VULNERABILITY == exp.getType()) - .toList(); - - if (injectExpectations.isEmpty()) { - return; - } - - InjectExpectationResult injectExpectationResult = buildForVulnerabilityManagerInFailed(); - boolean vulnerable; - - // Determine vulnerability - if (outputParsers.isEmpty()) { - vulnerable = false; - } else { - boolean hasCveType = - outputParsers.stream() - .flatMap(parser -> parser.getContractOutputElements().stream()) - .anyMatch(element -> ContractOutputType.CVE == element.getType()); - - if (!hasCveType) { - vulnerable = false; - } else { - boolean hasCveData = false; - - if (structuredOutput != null) { - hasCveData = - outputParsers.stream() - .flatMap(parser -> parser.getContractOutputElements().stream()) - .filter(element -> ContractOutputType.CVE == element.getType()) - .map(element -> structuredOutput.get(element.getKey())) - .anyMatch(jsonNode -> jsonNode != null && !jsonNode.isEmpty()); - } - - vulnerable = hasCveData; - } - } - - // Set expectations based on result - if (vulnerable) { - setResultExpectationVulnerable( - injectExpectations, injectExpectationResult, VULNERABILITY.failureLabel); - } else { // Not vulnerable - setResultExpectationVulnerable( - injectExpectations, injectExpectationResult, VULNERABILITY.successLabel); - } - - // Validate and save once - validateResultForAsset(injectExpectations, injectExpectationResult); - injectExpectationRepository.saveAll(injectExpectations); - } - - public void validateResultForAsset( - List injectExpectations, InjectExpectationResult injectExpectationResult) { - injectExpectations.forEach( - injectExpectation -> { - injectExpectationService.updateInjectExpectation( - injectExpectation.getId(), - InjectExpectationUpdateInput.builder() - .collectorId(injectExpectationResult.getSourceId()) - .result(injectExpectationResult.getResult()) - .isSuccess(injectExpectationResult.getScore() != 0.0) - .build()); - }); - } - private Agent loadAgentIfPresent(String agentId) { return (agentId == null) ? null diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectStatusService.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectStatusService.java index 8c2cbd48d3e..ad006bf72ec 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectStatusService.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectStatusService.java @@ -194,7 +194,7 @@ protected void computeExecutionTraceStatusIfNeeded( } public void updateInjectStatus( - Agent agent, Inject inject, InjectExecutionInput input, ObjectNode structuredOutput) { + Inject inject, Agent agent, InjectExecutionInput input, ObjectNode structuredOutput) { InjectStatus injectStatus = inject.getStatus().orElseThrow(ElementNotFoundException::new); // Creating the Execution Trace @@ -342,7 +342,7 @@ public void setImplantErrorTrace(String injectId, String agentId, String message input.setMessage("Execution done"); input.setStatus(ExecutionTraceStatus.INFO.name()); input.setAction(InjectExecutionAction.complete); - this.updateInjectStatus(agent, inject, input, null); + this.updateInjectStatus(inject, agent, input, null); } throw new IllegalArgumentException(message); } diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java new file mode 100644 index 00000000000..b3fd241ac52 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java @@ -0,0 +1,118 @@ +package io.openaev.rest.inject.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.openaev.database.model.ExecutionTraceAction; +import io.openaev.database.model.InjectorContract; +import io.openaev.injector_contract.outputs.InjectorContractContentOutputElement; +import io.openaev.output_processor.OutputProcessorFactory; +import io.openaev.rest.injector_contract.InjectorContractContentUtils; +import jakarta.annotation.Resource; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * Handler for processing inject executions triggered by an injector (not an agent). + * + *

This handler generates structured output from the raw execution input and processes additional + * capabilities such as findings extraction, expectation matching, or asset creation if applicable. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class InjectorExecutionProcessingHandler implements ExecutionProcessingHandler { + + @Resource protected ObjectMapper mapper; + private final OutputProcessorFactory outputProcessorFactory; + private final InjectorContractContentUtils injectorContractContentUtils; + + /** + * Determines if this handler supports the given execution context (injector execution). + * + * @param executionContext the execution context to check + * @return true if the context is for an injector execution, false otherwise + */ + @Override + public boolean supports(ExecutionProcessingContext executionContext) { + return executionContext.isInjectorExecution(); + } + + /** + * Processes the execution context, generating structured output and handling additional + * capabilities such as findings extraction, expectation matching, or asset creation. + * + * @param executionContext the execution context to process + * @return an optional ObjectNode result, if processing produces output + * @throws JsonProcessingException if JSON serialization fails during processing + */ + public Optional processContext(ExecutionProcessingContext executionContext) + throws JsonProcessingException { + if (!executionContext.isSuccess() + || !ExecutionTraceAction.COMPLETE.equals(executionContext.getAction())) { + return Optional.empty(); + } + + String outputStructured = executionContext.input().getOutputStructured(); + if (outputStructured == null || outputStructured.isBlank()) { + log.debug("No structured output provided; skipping injector execution post-processing."); + return Optional.empty(); + } + + ObjectNode structuredOutput; + try { + structuredOutput = mapper.readValue(outputStructured, ObjectNode.class); + } catch (JsonProcessingException e) { + log.warn( + "Failed to parse structured output as JSON; skipping injector execution post-processing.", + e); + return Optional.empty(); + } + + if (structuredOutput == null || structuredOutput.isMissingNode()) { + return Optional.empty(); + } + + InjectorContract injectorContract = + executionContext.inject().getInjectorContract().orElseThrow(); + List contractOutputElements = + getAllIsFindingCompatibleContractOutputs(injectorContract); + + contractOutputElements.stream() + .map(ContractOutputContext::from) + .forEach( + contractOutputCtx -> { + outputProcessorFactory + .getProcessor(contractOutputCtx.type()) + .ifPresent( + processor -> { + JsonNode node = structuredOutput.path(contractOutputCtx.key()); + if (!node.isMissingNode()) { + processor.process(executionContext, contractOutputCtx, node); + } + }); + }); + + return Optional.of(structuredOutput); + } + + /** + * Retrieves all contract output elements from the injector contract that are compatible with + * findings. + * + * @param injectorContract the injector contract to inspect + * @return list of finding-compatible contract output elements + */ + private List getAllIsFindingCompatibleContractOutputs( + InjectorContract injectorContract) { + return injectorContractContentUtils + .getContractOutputs(injectorContract.getConvertedContent(), mapper) + .stream() + .filter(InjectorContractContentOutputElement::isFindingCompatible) + .toList(); + } +} diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java index bebaaeac67d..a0031f1e6e9 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/StructuredOutputUtils.java @@ -130,14 +130,17 @@ public Optional computeStructuredOutputUsingRegexRules( Matcher matcher = pattern.matcher(cleanOutput); ArrayNode matchesArray = mapper.createArrayNode(); - // Get handler once per contract output type - OutputProcessor handler = outputProcessorFactory.getHandler(contractOutputElement.getType()); - - while (matcher.find()) { - buildStructuredJsonNode(contractOutputElement, matcher, handler) - .filter(handler::validate) - .ifPresent(matchesArray::add); - } + // Get processor once per contract output type + outputProcessorFactory + .getProcessor(contractOutputElement.getType()) + .ifPresent( + processor -> { + while (matcher.find()) { + buildStructuredJsonNode(contractOutputElement, matcher, processor) + .filter(processor::validate) + .ifPresent(matchesArray::add); + } + }); resultRoot.set(contractOutputElement.getKey(), matchesArray); } diff --git a/openaev-api/src/main/java/io/openaev/service/InjectExpectationService.java b/openaev-api/src/main/java/io/openaev/service/InjectExpectationService.java index d71acaf9eb2..27e2d77ff1e 100644 --- a/openaev-api/src/main/java/io/openaev/service/InjectExpectationService.java +++ b/openaev-api/src/main/java/io/openaev/service/InjectExpectationService.java @@ -3,14 +3,16 @@ import static io.openaev.database.model.InjectExpectation.EXPECTATION_TYPE.*; import static io.openaev.database.model.InjectExpectationSignature.EXPECTATION_SIGNATURE_TYPE_END_DATE; import static io.openaev.database.model.InjectExpectationSignature.EXPECTATION_SIGNATURE_TYPE_START_DATE; +import static io.openaev.expectation.ExpectationType.VULNERABILITY; import static io.openaev.helper.StreamHelper.fromIterable; -import static io.openaev.service.InjectExpectationUtils.*; +import static io.openaev.service.InjectExpectationUtils.computeScores; +import static io.openaev.service.InjectExpectationUtils.expectationConverter; import static io.openaev.utils.AgentUtils.getPrimaryAgents; import static io.openaev.utils.ExpectationUtils.*; import static io.openaev.utils.inject_expectation_result.ExpectationResultBuilder.*; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import io.openaev.database.helper.InjectExpectationRepositoryHelper; import io.openaev.database.model.*; import io.openaev.database.repository.InjectExpectationRepository; import io.openaev.database.specification.InjectExpectationSpecification; @@ -23,6 +25,7 @@ import io.openaev.rest.exception.ElementNotFoundException; import io.openaev.rest.exercise.form.ExpectationUpdateInput; import io.openaev.rest.inject.form.InjectExpectationUpdateInput; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import io.openaev.utils.ExpectationUtils; import io.openaev.utils.TargetType; import jakarta.annotation.Nullable; @@ -52,11 +55,9 @@ public class InjectExpectationService { public static final String SUCCESS = "Success"; - public static final String FAILED = "Failed"; public static final String PENDING = "Pending"; public static final String COLLECTOR = "collector"; private final InjectExpectationRepository injectExpectationRepository; - private final InjectExpectationRepositoryHelper injectExpectationRepositoryHelper; private final CollectorService collectorService; @Resource private ExpectationPropertiesConfig expectationPropertiesConfig; private final SecurityCoverageSendJobService securityCoverageSendJobService; @@ -873,4 +874,82 @@ private void setupDefaultExpectationResults( } }); } + + /** + * Function used to check if the output contains vulnerabilities and update the related inject + * expectations with the result. + * + * @param ctx the execution processing context containing the inject and agent information + * @param jsonNode the JSON node containing the output to check for vulnerabilities + */ + public void matchesVulnerabilityExpectations(ExecutionProcessingContext ctx, JsonNode jsonNode) { + boolean vulnerable = + jsonNode != null + && !jsonNode.isMissingNode() + && jsonNode.isContainerNode() + && !jsonNode.isEmpty(); + + Inject inject = ctx.inject(); + Agent agent = ctx.agent(); + + List expectations = fetchVulnerabilityExpectations(inject, agent); + + if (expectations.isEmpty()) { + return; + } + + InjectExpectationResult result = buildForVulnerabilityManagerInFailed(); + + String label = vulnerable ? VULNERABILITY.failureLabel : VULNERABILITY.successLabel; + + setResultExpectationVulnerable(expectations, result, label); + + validateResultForAsset(expectations, result); + injectExpectationRepository.saveAll(expectations); + } + + /** + * Function used to fetch inject expectations of type VULNERABILITY for a given inject and agent. + * + * @param inject the inject for which to fetch the expectations + * @param agent the agent for which to fetch the expectations + * @return the list of inject expectations of type VULNERABILITY for the given inject and agent + */ + private static List fetchVulnerabilityExpectations( + Inject inject, Agent agent) { + String agentId = agent != null ? agent.getId() : null; + return inject.getExpectations().stream() + .filter(exp -> InjectExpectation.EXPECTATION_TYPE.VULNERABILITY == exp.getType()) + .filter( + exp -> { + Agent expAgent = exp.getAgent(); + if (agentId == null) { + // For injector executions (agent == null), match expectations not bound to any + // agent + return expAgent == null; + } + return expAgent != null && agentId.equals(expAgent.getId()); + }) + .toList(); + } + + /** + * Function used to set the result of inject expectations of type VULNERABILITY with a label and a + * score. + * + * @param injectExpectations the list of inject expectations to update + * @param injectExpectationResult the result to set for the inject expectations + */ + public void validateResultForAsset( + List injectExpectations, InjectExpectationResult injectExpectationResult) { + injectExpectations.forEach( + injectExpectation -> + updateInjectExpectation( + injectExpectation.getId(), + InjectExpectationUpdateInput.builder() + .collectorId(injectExpectationResult.getSourceId()) + .result(injectExpectationResult.getResult()) + .isSuccess(injectExpectationResult.getScore() != 0.0) + .build())); + } } diff --git a/openaev-api/src/main/java/io/openaev/utils/inject_expectation_result/ExpectationResultBuilder.java b/openaev-api/src/main/java/io/openaev/utils/inject_expectation_result/ExpectationResultBuilder.java index 56042d1e0b7..6b404c58bd2 100644 --- a/openaev-api/src/main/java/io/openaev/utils/inject_expectation_result/ExpectationResultBuilder.java +++ b/openaev-api/src/main/java/io/openaev/utils/inject_expectation_result/ExpectationResultBuilder.java @@ -200,7 +200,7 @@ public static InjectExpectationResult buildDefaultForMediaPressure() { return buildForMediaPressure(NO_RESULT, NO_SCORE); } - private static InjectExpectationResult buildForVulnerabilityManager( + public static InjectExpectationResult buildForVulnerabilityManager( @Nullable final String result, @Nullable final Double score) { return InjectExpectationResult.builder() .sourceId(EXPECTATIONS_VULNERABILITY_COLLECTOR_ID) diff --git a/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java index 06ddab8c8a4..28b32872a5b 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -16,6 +18,14 @@ private static class TestOutputProcessor extends AbstractOutputProcessor { TestOutputProcessor() { super(null, null, Collections.emptyList(), false); } + + @Override + public void process( + ExecutionProcessingContext ctx, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + // No-op for testing purposes + } } private TestOutputProcessor processor; @@ -29,7 +39,7 @@ void setUp() { @Test @DisplayName( - "should join array elements and trim quotes when buildString is called with an array node") + "Should join array elements and trim quotes when buildString is called with an array node") void shouldJoinArrayElementsAndTrimQuotesWhenBuildStringCalledWithArrayNode() throws Exception { JsonNode node = objectMapper.readTree("[\"foo\", \"bar\"]"); String result = processor.buildString(node); @@ -37,7 +47,7 @@ void shouldJoinArrayElementsAndTrimQuotesWhenBuildStringCalledWithArrayNode() th } @Test - @DisplayName("should trim quotes when buildString is called with a string node") + @DisplayName("Should trim quotes when buildString is called with a string node") void shouldTrimQuotesWhenBuildStringCalledWithStringNode() throws Exception { JsonNode node = objectMapper.readTree("\"baz\""); String result = processor.buildString(node); @@ -45,7 +55,7 @@ void shouldTrimQuotesWhenBuildStringCalledWithStringNode() throws Exception { } @Test - @DisplayName("should extract and process value when buildString is called with a key") + @DisplayName("Should extract and process value when buildString is called with a key") void shouldExtractAndProcessValueWhenBuildStringCalledWithKey() throws Exception { JsonNode node = objectMapper.readTree("{\"key\": [\"a\", \"b\"]}"); String result = processor.buildString(node, "key"); @@ -53,7 +63,7 @@ void shouldExtractAndProcessValueWhenBuildStringCalledWithKey() throws Exception } @Test - @DisplayName("should return empty string when buildString is called with a missing or null key") + @DisplayName("Should return empty string when buildString is called with a missing or null key") void shouldReturnEmptyStringWhenBuildStringCalledWithMissingOrNullKey() throws Exception { JsonNode node = objectMapper.readTree("{}"); assertEquals("", processor.buildString(node, "missing")); @@ -62,7 +72,7 @@ void shouldReturnEmptyStringWhenBuildStringCalledWithMissingOrNullKey() throws E } @Test - @DisplayName("should remove leading and trailing quotes when trimQuotes is called") + @DisplayName("Should remove leading and trailing quotes when trimQuotes is called") void shouldRemoveLeadingAndTrailingQuotesWhenTrimQuotesCalled() { assertEquals("foo", processor.trimQuotes("\"foo\"")); assertEquals("bar", processor.trimQuotes("bar")); diff --git a/openaev-api/src/test/java/io/openaev/output_processor/CVEOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/CVEOutputProcessorTest.java index 6d5a1d53891..22619e781a9 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/CVEOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/CVEOutputProcessorTest.java @@ -1,21 +1,27 @@ package io.openaev.output_processor; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.rest.finding.FindingService; +import io.openaev.service.InjectExpectationService; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class CVEOutputProcessorTest { - private final CVEOutputProcessor processor = new CVEOutputProcessor(); + private final FindingService findingService = mock(FindingService.class); + private final InjectExpectationService injectExpectationService = + mock(InjectExpectationService.class); + private final CVEOutputProcessor processor = + new CVEOutputProcessor(findingService, injectExpectationService); private final ObjectMapper objectMapper = new ObjectMapper(); @Test - @DisplayName("should return empty list when asset_id is missing") + @DisplayName("Should return empty list when asset_id is missing") void shouldReturnEmptyListWhenAssetIdMissing() throws Exception { JsonNode node = objectMapper.readTree("{\"id\": \"CVE-123\", \"host\": \"host1\", \"severity\": \"high\"}"); @@ -24,7 +30,7 @@ void shouldReturnEmptyListWhenAssetIdMissing() throws Exception { } @Test - @DisplayName("should return single asset id when asset_id is present as string") + @DisplayName("Should return single asset id when asset_id is present as string") void shouldReturnSingleAssetIdWhenAssetIdPresentAsString() throws Exception { JsonNode node = objectMapper.readTree( @@ -34,7 +40,7 @@ void shouldReturnSingleAssetIdWhenAssetIdPresentAsString() throws Exception { } @Test - @DisplayName("should return multiple asset ids when asset_id is array") + @DisplayName("Should return multiple asset ids when asset_id is array") void shouldReturnMultipleAssetIdsWhenAssetIdIsArray() throws Exception { JsonNode node = objectMapper.readTree( @@ -42,4 +48,52 @@ void shouldReturnMultipleAssetIdsWhenAssetIdIsArray() throws Exception { List result = processor.toFindingAssets(node); assertEquals(List.of("asset1", "asset2"), result); } + + @Test + @DisplayName("Should return finding value as CVE id") + void shouldReturnFindingValueAsCveId() throws Exception { + JsonNode node = + objectMapper.readTree( + "{\"id\": \"CVE-2026-1234\", \"host\": \"host1\", \"severity\": \"high\"}"); + String result = processor.toFindingValue(node); + assertEquals("CVE-2026-1234", result); + } + + @Test + @DisplayName("Should return empty string when id is missing") + void shouldReturnEmptyStringWhenIdMissing() throws Exception { + JsonNode node = objectMapper.readTree("{\"host\": \"host1\", \"severity\": \"high\"}"); + String result = processor.toFindingValue(node); + assertEquals("", result); + } + + @Test + @DisplayName("Should return true for valid node in validate") + void shouldReturnTrueForValidNodeInValidate() throws Exception { + JsonNode node = + objectMapper.readTree( + "{\"id\": \"CVE-2026-1234\", \"host\": \"host1\", \"severity\": \"high\"}"); + assertTrue(processor.validate(node)); + } + + @Test + @DisplayName("Should return false for invalid node in validate (missing id)") + void shouldReturnFalseForInvalidNodeInValidateMissingId() throws Exception { + JsonNode node = objectMapper.readTree("{\"host\": \"host1\", \"severity\": \"high\"}"); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("Should return false for invalid node in validate (missing host)") + void shouldReturnFalseForInvalidNodeInValidateMissingHost() throws Exception { + JsonNode node = objectMapper.readTree("{\"id\": \"CVE-2026-1234\", \"severity\": \"high\"}"); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("Should return false for invalid node in validate (missing severity)") + void shouldReturnFalseForInvalidNodeInValidateMissingSeverity() throws Exception { + JsonNode node = objectMapper.readTree("{\"id\": \"CVE-2026-1234\", \"host\": \"host1\"}"); + assertFalse(processor.validate(node)); + } } diff --git a/openaev-api/src/test/java/io/openaev/output_processor/CredentialsOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/CredentialsOutputProcessorTest.java index 47245f1b774..c64d1aa5012 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/CredentialsOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/CredentialsOutputProcessorTest.java @@ -1,43 +1,71 @@ package io.openaev.output_processor; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.rest.finding.FindingService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class CredentialsOutputProcessorTest { - private final CredentialsOutputProcessor processor = new CredentialsOutputProcessor(); + private final FindingService findingService = mock(FindingService.class); + private final CredentialsOutputProcessor processor = + new CredentialsOutputProcessor(findingService); private final ObjectMapper objectMapper = new ObjectMapper(); @Test - @DisplayName("should return true when both username and password are present") + @DisplayName("Should return true when both username and password are present") void shouldReturnTrueWhenBothUsernameAndPasswordPresent() throws Exception { JsonNode node = objectMapper.readTree("{\"username\": \"alice\", \"password\": \"pass1\"}"); assertTrue(processor.validate(node)); } @Test - @DisplayName("should return false when username is missing") + @DisplayName("Should return false when username is missing") void shouldReturnFalseWhenUsernameMissing() throws Exception { JsonNode node = objectMapper.readTree("{\"password\": \"pass1\"}"); assertFalse(processor.validate(node)); } @Test - @DisplayName("should return false when password is missing") + @DisplayName("Should return false when password is missing") void shouldReturnFalseWhenPasswordMissing() throws Exception { JsonNode node = objectMapper.readTree("{\"username\": \"bob\"}"); assertFalse(processor.validate(node)); } @Test - @DisplayName("should return finding value as username:password") + @DisplayName("Should return finding value as username:password") void shouldReturnFindingValueAsUsernamePassword() throws Exception { JsonNode node = objectMapper.readTree("{\"username\": \"charles\", \"password\": \"pass1\"}"); String result = processor.toFindingValue(node); assertEquals("charles:pass1", result); } + + @Test + @DisplayName("Should return empty string when username and password are missing") + void shouldReturnEmptyStringWhenUsernameAndPasswordMissing() throws Exception { + JsonNode node = objectMapper.readTree("{}"); + String result = processor.toFindingValue(node); + assertEquals(":", result); + } + + @Test + @DisplayName("Should return empty string when username is empty") + void shouldReturnEmptyStringWhenUsernameIsEmpty() throws Exception { + JsonNode node = objectMapper.readTree("{\"username\": \"\", \"password\": \"pass1\"}"); + String result = processor.toFindingValue(node); + assertEquals(":pass1", result); + } + + @Test + @DisplayName("Should return empty string when password is empty") + void shouldReturnEmptyStringWhenPasswordIsEmpty() throws Exception { + JsonNode node = objectMapper.readTree("{\"username\": \"charles\", \"password\": \"\"}"); + String result = processor.toFindingValue(node); + assertEquals("charles:", result); + } } diff --git a/openaev-api/src/test/java/io/openaev/output_processor/IPv4OutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/IPv4OutputProcessorTest.java new file mode 100644 index 00000000000..9a6e60c1c91 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/output_processor/IPv4OutputProcessorTest.java @@ -0,0 +1,41 @@ +package io.openaev.output_processor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.rest.finding.FindingService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class IPv4OutputProcessorTest { + + private final FindingService findingService = mock(FindingService.class); + private final IPv4OutputProcessor processor = new IPv4OutputProcessor(findingService); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("Should return IPv4 value for simple node") + void shouldReturnIPv4ValueForSimpleNode() throws Exception { + JsonNode node = objectMapper.readTree("\"192.168.1.1\""); + String result = processor.toFindingValue(node); + assertEquals("192.168.1.1", result); + } + + @Test + @DisplayName("Should return concatenated values for array node") + void shouldReturnConcatenatedValuesForArrayNode() throws Exception { + JsonNode node = objectMapper.readTree("[\"192.168.1.1\", \"10.0.0.1\"]"); + String result = processor.toFindingValue(node); + assertEquals("192.168.1.1 10.0.0.1", result); + } + + @Test + @DisplayName("Should return empty string for empty node") + void shouldReturnEmptyStringForEmptyNode() throws Exception { + JsonNode node = objectMapper.readTree("\"\""); + String result = processor.toFindingValue(node); + assertEquals("", result); + } +} diff --git a/openaev-api/src/test/java/io/openaev/output_processor/IPv6OutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/IPv6OutputProcessorTest.java new file mode 100644 index 00000000000..0324cf1a7b4 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/output_processor/IPv6OutputProcessorTest.java @@ -0,0 +1,43 @@ +package io.openaev.output_processor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.rest.finding.FindingService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class IPv6OutputProcessorTest { + + private final FindingService findingService = mock(FindingService.class); + private final IPv6OutputProcessor processor = new IPv6OutputProcessor(findingService); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("Should return IPv6 value for simple node") + void shouldReturnIPv6ValueForSimpleNode() throws Exception { + JsonNode node = objectMapper.readTree("\"2001:0db8:85a3:0000:0000:8a2e:0370:7334\""); + String result = processor.toFindingValue(node); + assertEquals("2001:0db8:85a3:0000:0000:8a2e:0370:7334", result); + } + + @Test + @DisplayName("Should return concatenated values for array node") + void shouldReturnConcatenatedValuesForArrayNode() throws Exception { + JsonNode node = + objectMapper.readTree( + "[\"2001:0db8:85a3:0000:0000:8a2e:0370:7334\", \"fe80::1ff:fe23:4567:890a\"]"); + String result = processor.toFindingValue(node); + assertEquals("2001:0db8:85a3:0000:0000:8a2e:0370:7334 fe80::1ff:fe23:4567:890a", result); + } + + @Test + @DisplayName("Should return empty string for empty node") + void shouldReturnEmptyStringForEmptyNode() throws Exception { + JsonNode node = objectMapper.readTree("\"\""); + String result = processor.toFindingValue(node); + assertEquals("", result); + } +} diff --git a/openaev-api/src/test/java/io/openaev/output_processor/NumberOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/NumberOutputProcessorTest.java new file mode 100644 index 00000000000..fb75ff8b5f5 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/output_processor/NumberOutputProcessorTest.java @@ -0,0 +1,41 @@ +package io.openaev.output_processor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.rest.finding.FindingService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class NumberOutputProcessorTest { + + private final FindingService findingService = mock(FindingService.class); + private final NumberOutputProcessor processor = new NumberOutputProcessor(findingService); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("Should return number value for simple node") + void shouldReturnNumberValueForSimpleNode() throws Exception { + JsonNode node = objectMapper.readTree("42"); + String result = processor.toFindingValue(node); + assertEquals("42", result); + } + + @Test + @DisplayName("Should return concatenated values for array node") + void shouldReturnConcatenatedValuesForArrayNode() throws Exception { + JsonNode node = objectMapper.readTree("[1, 2, 3]"); + String result = processor.toFindingValue(node); + assertEquals("1 2 3", result); + } + + @Test + @DisplayName("Should return empty string for empty node") + void shouldReturnEmptyStringForEmptyNode() throws Exception { + JsonNode node = objectMapper.readTree("\"\""); + String result = processor.toFindingValue(node); + assertEquals("", result); + } +} diff --git a/openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorIntegrationTest.java b/openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorIntegrationTest.java index 0c0bbf1b523..12036e39382 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorIntegrationTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/OutputProcessorIntegrationTest.java @@ -17,29 +17,33 @@ class OutputProcessorIntegrationTest extends IntegrationTest { @Autowired private OutputProcessorFactory registry; @Test + @DisplayName("Should load all handlers from Spring context") void shouldLoadAllHandlersFromSpring() { for (ContractOutputType type : ContractOutputType.values()) { - OutputProcessor handler = registry.getHandler(type); + OutputProcessor handler = registry.getProcessor(type).get(); assertThat(handler).withFailMessage("Handler not found for type: " + type).isNotNull(); } } @Test + @DisplayName("Should return correct handler for each contract output type") void shouldReturnCorrectHandlerForEachType() { - assertThat(registry.getHandler(ContractOutputType.Text)) + assertThat(registry.getProcessor(ContractOutputType.Text).get()) .isInstanceOf(TextOutputProcessor.class); - assertThat(registry.getHandler(ContractOutputType.PortsScan)) + assertThat(registry.getProcessor(ContractOutputType.PortsScan).get()) .isInstanceOf(PortScanOutputProcessor.class); - assertThat(registry.getHandler(ContractOutputType.CVE)).isInstanceOf(CVEOutputProcessor.class); + assertThat(registry.getProcessor(ContractOutputType.CVE).get()) + .isInstanceOf(CVEOutputProcessor.class); } @Test + @DisplayName("Should return same instance on multiple calls to getProcessor") void shouldReturnSameInstanceOnMultipleCalls() { - OutputProcessor handler1 = registry.getHandler(ContractOutputType.Text); - OutputProcessor handler2 = registry.getHandler(ContractOutputType.Text); + OutputProcessor handler1 = registry.getProcessor(ContractOutputType.Text).get(); + OutputProcessor handler2 = registry.getProcessor(ContractOutputType.Text).get(); assertThat(handler1).isSameAs(handler2); } diff --git a/openaev-api/src/test/java/io/openaev/output_processor/PortOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/PortOutputProcessorTest.java new file mode 100644 index 00000000000..2fa242cec0d --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/output_processor/PortOutputProcessorTest.java @@ -0,0 +1,41 @@ +package io.openaev.output_processor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.rest.finding.FindingService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PortOutputProcessorTest { + + private final FindingService findingService = mock(FindingService.class); + private final PortOutputProcessor processor = new PortOutputProcessor(findingService); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("Should return port value for simple node") + void shouldReturnPortValueForSimpleNode() throws Exception { + JsonNode node = objectMapper.readTree("8080"); + String result = processor.toFindingValue(node); + assertEquals("8080", result); + } + + @Test + @DisplayName("Should return concatenated values for array node") + void shouldReturnConcatenatedValuesForArrayNode() throws Exception { + JsonNode node = objectMapper.readTree("[80, 443, 22]"); + String result = processor.toFindingValue(node); + assertEquals("80 443 22", result); + } + + @Test + @DisplayName("Should return empty string for empty node") + void shouldReturnEmptyStringForEmptyNode() throws Exception { + JsonNode node = objectMapper.readTree("\"\""); + String result = processor.toFindingValue(node); + assertEquals("", result); + } +} diff --git a/openaev-api/src/test/java/io/openaev/output_processor/PortScanOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/PortScanOutputProcessorTest.java index 9a2722ff137..797adfd4a9f 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/PortScanOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/PortScanOutputProcessorTest.java @@ -1,21 +1,23 @@ package io.openaev.output_processor; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.rest.finding.FindingService; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class PortScanOutputProcessorTest { - private final PortScanOutputProcessor processor = new PortScanOutputProcessor(); + private final FindingService findingService = mock(FindingService.class); + private final PortScanOutputProcessor processor = new PortScanOutputProcessor(findingService); private final ObjectMapper objectMapper = new ObjectMapper(); @Test - @DisplayName("should return correct finding value for valid port scan output") + @DisplayName("Should return correct finding value for valid port scan output") void shouldReturnCorrectFindingValueForValidPortScanOutput() throws Exception { JsonNode node = objectMapper.readTree( @@ -25,7 +27,7 @@ void shouldReturnCorrectFindingValueForValidPortScanOutput() throws Exception { } @Test - @DisplayName("should return finding value without service if service is empty") + @DisplayName("Should return finding value without service if service is empty") void shouldReturnFindingValueWithoutServiceIfServiceIsEmpty() throws Exception { JsonNode node = objectMapper.readTree( @@ -35,7 +37,7 @@ void shouldReturnFindingValueWithoutServiceIfServiceIsEmpty() throws Exception { } @Test - @DisplayName("should return single asset id when asset_id is present") + @DisplayName("Should return single asset id when asset_id is present") void shouldReturnSingleAssetIdWhenAssetIdPresent() throws Exception { JsonNode node = objectMapper.readTree( @@ -49,7 +51,7 @@ void shouldReturnSingleAssetIdWhenAssetIdPresent() throws Exception { } @Test - @DisplayName("should return empty list when asset_id is missing") + @DisplayName("Should return empty list when asset_id is missing") void shouldReturnEmptyListWhenAssetIdMissing() throws Exception { JsonNode node = objectMapper.readTree( @@ -57,4 +59,58 @@ void shouldReturnEmptyListWhenAssetIdMissing() throws Exception { List result = processor.toFindingAssets(node); assertTrue(result.isEmpty()); } + + @Test + @DisplayName("Should return empty string when host is missing") + void shouldReturnEmptyStringWhenHostIsMissing() throws Exception { + JsonNode node = objectMapper.readTree("{\"port\": \"22\", \"service\": \"ssh\"}"); + String result = processor.toFindingValue(node); + assertEquals(":22 (ssh)", result); + } + + @Test + @DisplayName("Should return empty string when port is missing") + void shouldReturnEmptyStringWhenPortIsMissing() throws Exception { + JsonNode node = objectMapper.readTree("{\"host\": \"192.168.1.1\", \"service\": \"ssh\"}"); + String result = processor.toFindingValue(node); + assertEquals("192.168.1.1: (ssh)", result); + } + + @Test + @DisplayName("Should return empty string when service is missing") + void shouldReturnEmptyStringWhenServiceIsMissing() throws Exception { + JsonNode node = objectMapper.readTree("{\"host\": \"192.168.1.1\", \"port\": \"22\"}"); + String result = processor.toFindingValue(node); + assertEquals("192.168.1.1:22", result); + } + + @Test + @DisplayName("Should return false for invalid node in validate (missing host)") + void shouldReturnFalseForInvalidNodeInValidateMissingHost() throws Exception { + JsonNode node = objectMapper.readTree("{\"port\": \"22\", \"service\": \"ssh\"}"); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("Should return false for invalid node in validate (missing port)") + void shouldReturnFalseForInvalidNodeInValidateMissingPort() throws Exception { + JsonNode node = objectMapper.readTree("{\"host\": \"192.168.1.1\", \"service\": \"ssh\"}"); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("Should return false for invalid node in validate (missing service)") + void shouldReturnFalseForInvalidNodeInValidateMissingService() throws Exception { + JsonNode node = objectMapper.readTree("{\"host\": \"192.168.1.1\", \"port\": \"22\"}"); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("Should return true for valid node in validate") + void shouldReturnTrueForValidNodeInValidate() throws Exception { + JsonNode node = + objectMapper.readTree( + "{\"host\": \"192.168.1.1\", \"port\": \"22\", \"service\": \"ssh\"}"); + assertTrue(processor.validate(node)); + } } diff --git a/openaev-api/src/test/java/io/openaev/output_processor/TextOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/TextOutputProcessorTest.java new file mode 100644 index 00000000000..f2bc9acee0c --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/output_processor/TextOutputProcessorTest.java @@ -0,0 +1,41 @@ +package io.openaev.output_processor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.rest.finding.FindingService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class TextOutputProcessorTest { + + private final FindingService findingService = mock(FindingService.class); + private final TextOutputProcessor processor = new TextOutputProcessor(findingService); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("Should return text value for simple node") + void shouldReturnTextValueForSimpleNode() throws Exception { + JsonNode node = objectMapper.readTree("\"hello world\""); + String result = processor.toFindingValue(node); + assertEquals("hello world", result); + } + + @Test + @DisplayName("Should return concatenated values for array node") + void shouldReturnConcatenatedValuesForArrayNode() throws Exception { + JsonNode node = objectMapper.readTree("[\"foo\", \"bar\", \"baz\"]"); + String result = processor.toFindingValue(node); + assertEquals("foo bar baz", result); + } + + @Test + @DisplayName("Should return empty string for empty node") + void shouldReturnEmptyStringForEmptyNode() throws Exception { + JsonNode node = objectMapper.readTree("\"\""); + String result = processor.toFindingValue(node); + assertEquals("", result); + } +} diff --git a/openaev-api/src/test/java/io/openaev/rest/finding/FindingServiceTest.java b/openaev-api/src/test/java/io/openaev/rest/finding/FindingServiceTest.java index 8d578d801a0..447f8a8c722 100644 --- a/openaev-api/src/test/java/io/openaev/rest/finding/FindingServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/finding/FindingServiceTest.java @@ -2,21 +2,20 @@ import static io.openaev.utils.fixtures.AssetFixture.createDefaultAsset; import static io.openaev.utils.fixtures.InjectFixture.getDefaultInject; -import static io.openaev.utils.fixtures.OutputParserFixture.getDefaultContractOutputElement; +import static io.openaev.utils.fixtures.OutputParserFixture.getContractOutputElementTypeIPv6; import static org.junit.jupiter.api.Assertions.*; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import io.openaev.IntegrationTest; import io.openaev.database.model.*; import io.openaev.database.repository.FindingRepository; import io.openaev.injector_contract.outputs.InjectorContractContentOutputElement; +import io.openaev.rest.inject.service.ContractOutputContext; import io.openaev.rest.injector_contract.InjectorContractContentUtils; import io.openaev.utils.helpers.InjectTestHelper; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,200 +34,183 @@ class FindingServiceTest extends IntegrationTest { @Autowired private InjectorContractContentUtils injectorContractContentUtils; @Test - @DisplayName("Should have two assets for a finding") + @DisplayName("Should have two assets when finding already exists with one asset") void given_a_finding_already_existent_with_one_asset_should_have_two_assets() { Inject inject = getDefaultInject(); - Asset asset1 = createDefaultAsset(ASSET_1); - asset1 = injectTestHelper.forceSaveAsset(asset1); - Asset asset2 = createDefaultAsset(ASSET_2); - asset2 = injectTestHelper.forceSaveAsset(asset2); + Asset asset1 = injectTestHelper.forceSaveAsset(createDefaultAsset(ASSET_1)); + Asset asset2 = injectTestHelper.forceSaveAsset(createDefaultAsset(ASSET_2)); String value = "value-already-existent"; - ContractOutputElement contractOutputElement = getDefaultContractOutputElement(); + ContractOutputElement contractOutputElement = getContractOutputElementTypeIPv6(); + ContractOutputContext contractOutputContext = ContractOutputContext.from(contractOutputElement); - Finding finding1 = new Finding(); - finding1.setValue(value); - finding1.setInject(inject); - finding1.setField(contractOutputElement.getKey()); - finding1.setType(contractOutputElement.getType()); - finding1.setAssets(new ArrayList<>(Arrays.asList(asset1))); + Finding existing = new Finding(); + existing.setValue(value); + existing.setInject(inject); + existing.setField(contractOutputElement.getKey()); + existing.setType(contractOutputElement.getType()); + existing.setAssets(new ArrayList<>(List.of(asset1))); injectTestHelper.forceSaveInject(inject); - injectTestHelper.forceSaveFinding(finding1); + injectTestHelper.forceSaveFinding(existing); - findingService.buildFinding(inject, asset2, contractOutputElement, value); + findingService.saveAgentFinding(inject, asset2, contractOutputContext, value); - Finding capturedFinding = + Finding result = findingRepository .findByInjectIdAndValueAndTypeAndKey( - finding1.getInject().getId(), - finding1.getValue(), - finding1.getType(), - finding1.getField()) + inject.getId(), + value, + contractOutputElement.getType(), + contractOutputElement.getKey()) .orElseThrow(); - assertEquals(2, capturedFinding.getAssets().size()); + assertEquals(2, result.getAssets().size()); Set assetIds = - capturedFinding.getAssets().stream().map(Asset::getId).collect(Collectors.toSet()); + result.getAssets().stream().map(Asset::getId).collect(Collectors.toSet()); assertTrue(assetIds.contains(asset1.getId())); assertTrue(assetIds.contains(asset2.getId())); } @Test - @DisplayName("Should have one asset for a finding") - void given_a_finding_already_existent_with_same_asset_should_have_one_assets() { + @DisplayName("Should have one asset when finding already exists with the same asset") + void given_a_finding_already_existent_with_same_asset_should_have_one_asset() { Inject inject = getDefaultInject(); - Asset asset1 = createDefaultAsset(ASSET_1); - asset1 = injectTestHelper.forceSaveAsset(asset1); + Asset asset1 = injectTestHelper.forceSaveAsset(createDefaultAsset(ASSET_1)); String value = "value-already-existent"; - ContractOutputElement contractOutputElement = getDefaultContractOutputElement(); + ContractOutputElement contractOutputElement = getContractOutputElementTypeIPv6(); + ContractOutputContext contractOutputContext = ContractOutputContext.from(contractOutputElement); - Finding finding1 = new Finding(); - finding1.setValue(value); - finding1.setInject(inject); - finding1.setField(contractOutputElement.getKey()); - finding1.setType(contractOutputElement.getType()); - finding1.setAssets(new ArrayList<>(Arrays.asList(asset1))); + Finding existing = new Finding(); + existing.setValue(value); + existing.setInject(inject); + existing.setField(contractOutputElement.getKey()); + existing.setType(contractOutputElement.getType()); + existing.setAssets(new ArrayList<>(List.of(asset1))); injectTestHelper.forceSaveInject(inject); - injectTestHelper.forceSaveFinding(finding1); + injectTestHelper.forceSaveFinding(existing); - findingService.buildFinding(inject, asset1, contractOutputElement, value); + findingService.saveAgentFinding(inject, asset1, contractOutputContext, value); - Finding capturedFinding = + Finding result = findingRepository .findByInjectIdAndValueAndTypeAndKey( - finding1.getInject().getId(), - finding1.getValue(), - finding1.getType(), - finding1.getField()) + inject.getId(), + value, + contractOutputElement.getType(), + contractOutputElement.getKey()) .orElseThrow(); - assertEquals(1, capturedFinding.getAssets().size()); - Set assetIds = - capturedFinding.getAssets().stream().map(Asset::getId).collect(Collectors.toSet()); - assertTrue(assetIds.contains(asset1.getId())); + assertEquals(1, result.getAssets().size()); + assertTrue( + result.getAssets().stream() + .map(Asset::getId) + .collect(Collectors.toSet()) + .contains(asset1.getId())); } @Test - @DisplayName("Should return empty findings when contract output is not finding compatible") - void shouldReturnEmptyFindingsWhenContractOutputIsNotFindingCompatible() throws Exception { - + @DisplayName("Should return two findings for multiple finding-compatible CVE contract outputs") + void shouldReturnFindingsForMultipleFindingCompatibleContractOutputs() throws Exception { ObjectMapper mapper = new ObjectMapper(); - - // Simulate a contract with a non-finding-compatible output - String contractJson = - """ - { - "outputs": [ - { - "field": "found_assets", - "isFindingCompatible": false, - "isMultiple": true, - "labels": ["shodan"], - "type": "asset" - } - ] - } - """; - - ObjectNode convertedContent = (ObjectNode) mapper.readTree(contractJson); - - // Simulate structured output - ObjectNode structuredOutput = + ObjectNode convertedContent = (ObjectNode) mapper.readTree( """ - { - "found_assets": [ - { "name": "Asset A" }, - { "name": "Asset B" } - ] - } - """); - - // Convert JSON outputs to InjectorContractContentOutputElement - List contractOutputs = - injectorContractContentUtils.getContractOutputs(convertedContent, mapper); - - // Call the method to check behavior when isFindingCompatible=false - List findings = - findingService.getFindingsFromInjectorContract(contractOutputs, structuredOutput); - - // Assert that findings is empty because isFindingCompatible=false - assertNotNull(findings); - assertTrue(findings.isEmpty()); - } - - @Test - @DisplayName("should return findings for multiple finding-compatible contract outputs") - void shouldReturnFindingsForMultipleFindingCompatibleContractOutputs() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - String contractJson = - """ + { + "outputs": [ { - "outputs": [ - { - "field": "cves", - "isFindingCompatible": true, - "isMultiple": true, - "labels": ["nuclei"], - "type": "cve" - } - ] + "field": "cves", + "isFindingCompatible": true, + "isMultiple": true, + "labels": ["nuclei"], + "type": "cve" } - """; - ObjectNode convertedContent = (ObjectNode) mapper.readTree(contractJson); + ] + } + """); ObjectNode structuredOutput = (ObjectNode) mapper.readTree( """ - { - "cves": [ - { "id": "cve A", "host": "host A", "severity": "high" }, - { "id": "cve B", "host": "host B", "severity": "medium" } - ] - } - """); + { + "cves": [ + { "id": "cve A", "host": "host A", "severity": "high" }, + { "id": "cve B", "host": "host B", "severity": "medium" } + ] + } + """); + List contractOutputs = injectorContractContentUtils.getContractOutputs(convertedContent, mapper); + ContractOutputContext ctx = ContractOutputContext.from(contractOutputs.getFirst()); + JsonNode elementNode = structuredOutput.path("cves"); + List findings = - findingService.getFindingsFromInjectorContract(contractOutputs, structuredOutput); + findingService.buildFindings( + elementNode, + ctx, + node -> node.hasNonNull("id") && node.hasNonNull("host") && node.hasNonNull("severity"), + node -> node.get("id").asText(), + node -> Collections.emptyList(), + node -> Collections.emptyList(), + node -> Collections.emptyList()); + assertNotNull(findings); assertEquals(2, findings.size()); assertTrue(findings.stream().allMatch(f -> f.getType().equals(ContractOutputType.CVE))); + Set values = findings.stream().map(Finding::getValue).collect(Collectors.toSet()); + assertTrue(values.contains("cve A")); + assertTrue(values.contains("cve B")); } @Test - @DisplayName("should throw exception when finding is not correctly formatted") + @DisplayName("Should throw exception when finding node is not correctly formatted") void shouldThrowExceptionWhenFindingNotCorrectlyFormatted() throws Exception { ObjectMapper mapper = new ObjectMapper(); - String contractJson = - """ + ObjectNode convertedContent = + (ObjectNode) + mapper.readTree( + """ + { + "outputs": [ { - "outputs": [ - { - "field": "port_scans", - "isFindingCompatible": true, - "isMultiple": true, - "labels": ["nuclei"], - "type": "portscan" - } - ] + "field": "port_scans", + "isFindingCompatible": true, + "isMultiple": true, + "labels": ["nuclei"], + "type": "portscan" } - """; - ObjectNode convertedContent = (ObjectNode) mapper.readTree(contractJson); + ] + } + """); ObjectNode structuredOutput = (ObjectNode) mapper.readTree( """ - { - "port_scans": [ null ] - } - """); + { + "port_scans": [ null ] + } + """); + List contractOutputs = injectorContractContentUtils.getContractOutputs(convertedContent, mapper); + ContractOutputContext ctx = ContractOutputContext.from(contractOutputs.getFirst()); + JsonNode elementNode = structuredOutput.path("port_scans"); + assertThrows( IllegalArgumentException.class, - () -> findingService.getFindingsFromInjectorContract(contractOutputs, structuredOutput)); + () -> + findingService.buildFindings( + elementNode, + ctx, + node -> + node.hasNonNull("host") + && node.hasNonNull("port") + && node.hasNonNull("service"), + node -> node.get("port").asText(), + node -> Collections.emptyList(), + node -> Collections.emptyList(), + node -> Collections.emptyList())); } } diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java index 83411bda47d..5b64663b142 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java @@ -41,7 +41,6 @@ import io.openaev.rest.exercise.service.ExerciseService; import io.openaev.rest.inject.form.*; import io.openaev.rest.inject.service.InjectStatusService; -import io.openaev.scheduler.jobs.InjectsExecutionJob; import io.openaev.service.scenario.ScenarioService; import io.openaev.utils.TargetType; import io.openaev.utils.fixtures.*; @@ -63,7 +62,10 @@ import java.util.concurrent.TimeUnit; import net.javacrumbs.jsonunit.core.Option; import org.awaitility.Awaitility; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; @@ -97,8 +99,6 @@ class InjectApiTest extends IntegrationTest { @Autowired private ExerciseService exerciseService; @SpyBean private InjectStatusService injectStatusService; - @Autowired private InjectsExecutionJob injectsExecutionJob; - @Autowired private AgentComposer agentComposer; @Autowired private EndpointComposer endpointComposer; @Autowired private InjectComposer injectComposer; @@ -123,10 +123,7 @@ class InjectApiTest extends IntegrationTest { @Autowired private CommunicationRepository communicationRepository; @Autowired private InjectExpectationRepository injectExpectationRepository; @Autowired private TeamRepository teamRepository; - @Autowired private PayloadRepository payloadRepository; - @Autowired private InjectorRepository injectorRepository; @Autowired private FindingRepository findingRepository; - @Autowired private InjectorContractRepository injectorContractRepository; @Autowired private UserRepository userRepository; @Resource private ObjectMapper objectMapper; @MockBean private JavaMailSender javaMailSender; @@ -134,7 +131,6 @@ class InjectApiTest extends IntegrationTest { @Autowired private InjectTestHelper injectTestHelper; @Autowired private InjectExpectationComposer injectExpectationComposer; @Autowired private InjectorContractFixture injectorContractFixture; - @Autowired private InjectorFixture injectorFixture; @Autowired private EmailInjectorIntegrationFactory emailInjectorIntegrationFactory; @Autowired private OpenaevInjectorIntegrationFactory openaevInjectorIntegrationFactory; @@ -936,7 +932,11 @@ class handleInjectExecutionCallback { private Inject getPendingInjectWithAssets() { return injectTestHelper.getPendingInjectWithAssets( - injectComposer, endpointComposer, agentComposer, injectStatusComposer); + injectComposer, + injectorContractComposer, + endpointComposer, + agentComposer, + injectStatusComposer); } private void performAgentlessCallbackRequest(String injectId, InjectExecutionInput input) diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java new file mode 100644 index 00000000000..013cdf5dc2f --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java @@ -0,0 +1,163 @@ +package io.openaev.rest.inject.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.openaev.database.model.*; +import io.openaev.output_processor.OutputProcessor; +import io.openaev.output_processor.OutputProcessorFactory; +import io.openaev.rest.inject.form.InjectExecutionAction; +import io.openaev.rest.inject.form.InjectExecutionInput; +import io.openaev.utils.fixtures.AgentFixture; +import io.openaev.utils.fixtures.InjectFixture; +import io.openaev.utils.fixtures.OutputParserFixture; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AgentExecutionProcessingHandlerTest { + + @Mock private StructuredOutputUtils structuredOutputUtils; + @Mock private OutputProcessorFactory outputProcessorFactory; + @Mock private OutputProcessor mockProcessor; + + @InjectMocks private AgentExecutionProcessingHandler handler; + + private final ObjectMapper mapper = new ObjectMapper(); + private Inject inject; + private Agent agent; + + @BeforeEach + void setUp() { + this.inject = InjectFixture.getDefaultInject(); + this.agent = AgentFixture.createDefaultAgentService(); + } + + @Test + @DisplayName("Should support only agent execution contexts") + void shouldSupportOnlyAgentExecutionContexts() { + ExecutionProcessingContext agentCtx = createValidCtx(); + ExecutionProcessingContext injectorCtx = + new ExecutionProcessingContext(inject, null, new InjectExecutionInput(), Map.of()); + + assertTrue(handler.supports(agentCtx)); + assertFalse(handler.supports(injectorCtx)); + } + + @Test + @DisplayName("Should return empty when status is not SUCCESS or action is not command execution") + void shouldReturnEmptyWhenStatusNotSuccessOrActionNotExecution() throws Exception { + InjectExecutionInput inputError = + buildInput(ExecutionTraceStatus.ERROR, InjectExecutionAction.command_execution); + assertTrue( + handler + .processContext(new ExecutionProcessingContext(inject, agent, inputError, Map.of())) + .isEmpty()); + + InjectExecutionInput inputComplete = + buildInput(ExecutionTraceStatus.SUCCESS, InjectExecutionAction.complete); + assertTrue( + handler + .processContext(new ExecutionProcessingContext(inject, agent, inputComplete, Map.of())) + .isEmpty()); + + verifyNoInteractions(structuredOutputUtils); + } + + @Test + @DisplayName("Should return empty when computeStructuredOutput returns no result") + void shouldReturnEmptyWhenComputeStructuredOutputReturnsEmpty() throws Exception { + ExecutionProcessingContext ctx = createValidCtx(); + when(structuredOutputUtils.extractOutputParsers(any())) + .thenReturn(Set.of(mock(OutputParser.class))); + when(structuredOutputUtils.computeStructuredOutputFromOutputParsers(any(), anyString())) + .thenReturn(Optional.empty()); + + Optional result = handler.processContext(ctx); + + assertTrue(result.isEmpty()); + verifyNoInteractions(outputProcessorFactory); + } + + @Test + @DisplayName("Should skip processor when contract element key is missing in produced JSON") + void shouldSkipProcessorWhenKeyInContractIsMissingInProducedJson() throws Exception { + ExecutionProcessingContext ctx = createValidCtx(); + + String uniqueMissingKey = "totally_absent_key_" + System.currentTimeMillis(); + ContractOutputElement element = + OutputParserFixture.getContractOutputElement( + ContractOutputType.Text, uniqueMissingKey, Set.of(), true); + + OutputParser parser = OutputParserFixture.getOutputParser(Set.of(element)); + when(structuredOutputUtils.extractOutputParsers(any())).thenReturn(Set.of(parser)); + + ObjectNode json = mapper.createObjectNode(); + json.put("other_key", "val"); + ObjectNode spyJson = spy(json); + doReturn(com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.missingNode()) + .when(spyJson) + .path(anyString()); + when(structuredOutputUtils.computeStructuredOutputFromOutputParsers(any(), anyString())) + .thenReturn(Optional.of(spyJson)); + when(outputProcessorFactory.getProcessor(ContractOutputType.Text)) + .thenReturn(Optional.of(mockProcessor)); + + handler.processContext(ctx); + + // Should NOT be called for missing key + verify(mockProcessor, never()).process(any(), any(), any()); + } + + @Test + @DisplayName("Should process correctly when multiple parser elements match") + void shouldProcessCorrectlyWhenMultipleParsersElementsMatch() throws Exception { + ExecutionProcessingContext ctx = createValidCtx(); + + ContractOutputElement element = + OutputParserFixture.getContractOutputElement(ContractOutputType.CVE, "cve", Set.of(), true); + OutputParser parser = OutputParserFixture.getOutputParser(Set.of(element)); + when(structuredOutputUtils.extractOutputParsers(any())).thenReturn(Set.of(parser)); + + ObjectNode json = mapper.createObjectNode(); + json.put("cve-key", "cve-data"); + + when(structuredOutputUtils.computeStructuredOutputFromOutputParsers(any(), anyString())) + .thenReturn(Optional.of(json)); + when(outputProcessorFactory.getProcessor(any())).thenReturn(Optional.of(mockProcessor)); + + handler.processContext(ctx); + + // Should be called once for the matching element + verify(mockProcessor, times(1)).process(eq(ctx), any(), any(JsonNode.class)); + } + + private ExecutionProcessingContext createValidCtx() { + return new ExecutionProcessingContext( + inject, + agent, + buildInput(ExecutionTraceStatus.SUCCESS, InjectExecutionAction.command_execution), + Map.of()); + } + + private InjectExecutionInput buildInput( + ExecutionTraceStatus status, InjectExecutionAction action) { + InjectExecutionInput input = new InjectExecutionInput(); + input.setStatus(status.toString()); + input.setAction(action); + input.setMessage("raw-output"); + return input; + } +} diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/ExecutionProcessingContextTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/ExecutionProcessingContextTest.java new file mode 100644 index 00000000000..46cf5969747 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/ExecutionProcessingContextTest.java @@ -0,0 +1,35 @@ +package io.openaev.rest.inject.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import io.openaev.database.model.Agent; +import io.openaev.database.model.Inject; +import io.openaev.rest.inject.form.InjectExecutionInput; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ExecutionProcessingContextTest { + + @Test + @DisplayName("Should identify agent execution context") + void shouldIdentifyAgentExecutionContext() { + ExecutionProcessingContext context = + new ExecutionProcessingContext( + mock(Inject.class), mock(Agent.class), mock(InjectExecutionInput.class), Map.of()); + assertTrue(context.isAgentExecution()); + assertFalse(context.isInjectorExecution()); + } + + @Test + @DisplayName("Should identify injector execution context") + void shouldIdentifyInjectorExecutionContext() { + ExecutionProcessingContext context = + new ExecutionProcessingContext( + mock(Inject.class), null, mock(InjectExecutionInput.class), Map.of()); + assertFalse(context.isAgentExecution()); + assertTrue(context.isInjectorExecution()); + } +} diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java new file mode 100644 index 00000000000..6fe10302ca6 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java @@ -0,0 +1,99 @@ +package io.openaev.rest.inject.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.openaev.database.model.Agent; +import io.openaev.database.model.Inject; +import io.openaev.rest.inject.form.InjectExecutionAction; +import io.openaev.rest.inject.form.InjectExecutionInput; +import io.openaev.service.InjectExpectationService; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class InjectExecutionServiceTest { + + private InjectExecutionService service; + private ExecutionProcessingHandler handler; + + @BeforeEach + void setUp() { + handler = mock(ExecutionProcessingHandler.class); + InjectService injectService = mock(InjectService.class); + InjectStatusService injectStatusService = mock(InjectStatusService.class); + InjectExpectationService injectExpectationService = mock(InjectExpectationService.class); + service = + new InjectExecutionService( + null, + injectExpectationService, + null, + injectStatusService, + injectService, + List.of(handler)); + } + + @Test + @DisplayName("Should resolve handler for agent context") + void shouldResolveExecutionContextForAgentContext() { + ExecutionProcessingContext agentContext = + new ExecutionProcessingContext( + mock(Inject.class), mock(Agent.class), mock(InjectExecutionInput.class), Map.of()); + when(handler.supports(agentContext)).thenReturn(true); + ExecutionProcessingHandler resolved = service.resolveExecutionContext(agentContext); + assertEquals(handler, resolved); + } + + @Test + @DisplayName("Should resolve handler for injector context") + void shouldResolveExecutionContextForInjectorContext() { + ExecutionProcessingContext injectorContext = + new ExecutionProcessingContext( + mock(Inject.class), null, mock(InjectExecutionInput.class), Map.of()); + when(handler.supports(injectorContext)).thenReturn(true); + ExecutionProcessingHandler resolved = service.resolveExecutionContext(injectorContext); + assertEquals(handler, resolved); + } + + @Test + @DisplayName("Should call processContext on handler in processInjectExecution") + void shouldCallProcessContextOnHandlerInProcessInjectExecution() throws Exception { + Inject inject = mock(Inject.class); + Agent agent = mock(Agent.class); + + InjectExecutionInput input = new InjectExecutionInput(); + String logMessage = + "{\"stdout\":\"[CVE-2025-25241] [http] [critical] http://seen-ip-endpoint/\\n[CVE-2025-25002] [http] [critical] http://seen-ip-endpoint/\\n\"}"; + input.setMessage(logMessage); + input.setAction(InjectExecutionAction.command_execution); + input.setStatus("SUCCESS"); + + when(handler.supports(any())).thenReturn(true); + when(handler.processContext(any())).thenReturn(Optional.of(mock(ObjectNode.class))); + InjectExecutionService spyService = spy(service); + doReturn(handler).when(spyService).resolveExecutionContext(any()); + spyService.processInjectExecution(inject, agent, input); + verify(handler).processContext(any()); + } + + @Test + @DisplayName("Should throw exception if no handler supports context") + void shouldThrowExceptionIfNoHandlerSupportsContext() { + ExecutionProcessingHandler nonSupportingHandler = mock(ExecutionProcessingHandler.class); + ExecutionProcessingContext context = + new ExecutionProcessingContext( + mock(Inject.class), null, mock(InjectExecutionInput.class), Map.of()); + when(nonSupportingHandler.supports(context)).thenReturn(false); + InjectExecutionService serviceWithNonSupportingHandler = + new InjectExecutionService(null, null, null, null, null, List.of(nonSupportingHandler)); + Exception ex = + assertThrows( + IllegalStateException.class, + () -> serviceWithNonSupportingHandler.resolveExecutionContext(context)); + assertTrue(ex.getMessage().contains("No handler found")); + } +} diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java index 0b8fcee629d..7f1d6c274a6 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java @@ -565,23 +565,23 @@ void given_inject_without_injectcontent_SHOULD_take_default() throws JsonProcess String injectorContractId = "injectorContractId"; String injectorContractString = """ - { - "fields": [ - { - "type": "defaultValue1", - "key": "value1", - "defaultValue": ["defaultValue1"], - "cardinality":"1" - }, - { - "type": "asset", - "key": "value2", - "defaultValue": ["defaultValue2"], - "cardinality":"1" - } - ] - } -"""; + { + "fields": [ + { + "type": "defaultValue1", + "key": "value1", + "defaultValue": ["defaultValue1"], + "cardinality":"1" + }, + { + "type": "asset", + "key": "value2", + "defaultValue": ["defaultValue2"], + "cardinality":"1" + } + ] + } + """; InjectorContract injectorContract = new InjectorContract(); injectorContract.setId(injectorContractId); injectorContract.setContent(injectorContractString); diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java new file mode 100644 index 00000000000..587f33b8a6e --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java @@ -0,0 +1,119 @@ +package io.openaev.rest.inject.service; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.database.model.ExecutionTraceStatus; +import io.openaev.database.model.Inject; +import io.openaev.database.model.InjectorContract; +import io.openaev.injector_contract.outputs.InjectorContractContentOutputElement; +import io.openaev.output_processor.OutputProcessor; +import io.openaev.output_processor.OutputProcessorFactory; +import io.openaev.rest.inject.form.InjectExecutionAction; +import io.openaev.rest.inject.form.InjectExecutionInput; +import io.openaev.rest.injector_contract.InjectorContractContentUtils; +import io.openaev.utils.fixtures.InjectFixture; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class InjectorExecutionProcessingHandlerTest { + + @Mock private OutputProcessorFactory outputProcessorFactory; + @Mock private InjectorContractContentUtils injectorContractContentUtils; + @Mock private OutputProcessor mockProcessor; + + @InjectMocks private InjectorExecutionProcessingHandler handler; + + private final ObjectMapper mapper = new ObjectMapper(); + private Inject inject; + private InjectorContract injectorContract; + + @BeforeEach + void setUp() { + this.inject = InjectFixture.getDefaultInject(); + this.injectorContract = mock(InjectorContract.class); + inject.setInjectorContract(injectorContract); + handler.mapper = mapper; + } + + @Test + @DisplayName("Should support only injector execution contexts") + void testSupports() { + ExecutionProcessingContext injectorCtx = + new ExecutionProcessingContext(inject, null, new InjectExecutionInput(), Map.of()); + + assertTrue(handler.supports(injectorCtx)); + } + + @Test + @DisplayName("Should return empty if status is not success or action is not COMPLETE") + void testEarlyExitConditions() throws Exception { + // Case 1: Status is ERROR + InjectExecutionInput inputError = + buildInput(ExecutionTraceStatus.ERROR, InjectExecutionAction.complete, "{}"); + assertTrue( + handler + .processContext(new ExecutionProcessingContext(inject, null, inputError, Map.of())) + .isEmpty()); + + verifyNoInteractions(outputProcessorFactory); + + // Case 2: Action is NOT complete + InjectExecutionInput inputWrongAction = + buildInput(ExecutionTraceStatus.SUCCESS, InjectExecutionAction.command_execution, "{}"); + assertTrue( + handler + .processContext( + new ExecutionProcessingContext(inject, null, inputWrongAction, Map.of())) + .isEmpty()); + + verifyNoInteractions(outputProcessorFactory); + } + + @Test + @DisplayName("Should skip processor if the key is missing from the structured output JSON") + void testSkipWhenKeyIsMissing() throws Exception { + // JSON exists but does not contain "missing_key" + ExecutionProcessingContext ctx = createValidCtx("{\"unrelated_key\": \"value\"}"); + + InjectorContractContentOutputElement element = new InjectorContractContentOutputElement(); + element.setField("missing_key"); + element.setFindingCompatible(true); + + when(injectorContract.getConvertedContent()).thenReturn(mapper.createObjectNode()); + when(injectorContractContentUtils.getContractOutputs(any(), any())) + .thenReturn(List.of(element)); + + handler.processContext(ctx); + + verify(outputProcessorFactory).getProcessor(any()); + verify(mockProcessor, never()).process(any(), any(), any()); + } + + private ExecutionProcessingContext createValidCtx(String json) { + return new ExecutionProcessingContext( + inject, + null, + buildInput(ExecutionTraceStatus.SUCCESS, InjectExecutionAction.complete, json), + Map.of()); + } + + private InjectExecutionInput buildInput( + ExecutionTraceStatus status, InjectExecutionAction action, String jsonContent) { + InjectExecutionInput input = new InjectExecutionInput(); + input.setStatus(status.toString()); + input.setAction(action); + input.setOutputStructured(jsonContent); + return input; + } +} diff --git a/openaev-api/src/test/java/io/openaev/service/InjectExecutionServiceTest.java b/openaev-api/src/test/java/io/openaev/service/InjectExecutionServiceTest.java deleted file mode 100644 index af130d2e565..00000000000 --- a/openaev-api/src/test/java/io/openaev/service/InjectExecutionServiceTest.java +++ /dev/null @@ -1,161 +0,0 @@ -package io.openaev.service; - -import static io.openaev.utils.fixtures.InjectExpectationFixture.createVulnerabilityInjectExpectation; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import io.openaev.IntegrationTest; -import io.openaev.database.model.*; -import io.openaev.database.repository.InjectExpectationRepository; -import io.openaev.rest.finding.FindingService; -import io.openaev.rest.inject.form.InjectExecutionAction; -import io.openaev.rest.inject.form.InjectExecutionInput; -import io.openaev.rest.inject.service.InjectExecutionService; -import io.openaev.rest.inject.service.InjectStatusService; -import io.openaev.rest.inject.service.StructuredOutputUtils; -import io.openaev.utils.ExpectationUtils; -import io.openaev.utils.fixtures.AgentFixture; -import io.openaev.utils.fixtures.InjectFixture; -import io.openaev.utils.fixtures.OutputParserFixture; -import java.util.List; -import java.util.Set; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.*; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class InjectExecutionServiceTest extends IntegrationTest { - - @Spy @InjectMocks private InjectExecutionService testInjectExecutionService; - - private Inject inject; - private InjectExpectation injectExpectation; - private Agent agent; - - @Spy private ObjectMapper mapper = new ObjectMapper(); - @Mock private InjectExpectationService injectExpectationService; - @Mock private InjectExpectationRepository injectExpectationRepository; - @Mock private StructuredOutputUtils structuredOutputUtils; - @Mock private InjectStatusService injectStatusService; - @Mock private FindingService findingService; - - @BeforeEach - void setUp() { - agent = AgentFixture.createDefaultAgentService(); - inject = InjectFixture.getDefaultInject(); - injectExpectation = createVulnerabilityInjectExpectation(inject, agent); - inject.setExpectations(List.of(injectExpectation)); - } - - @Test - void checkCveExpectation_NoOutputParsers_ShouldSetNotVulnerable() { - Set outputParsers = Set.of(); - ObjectNode structuredOutput = null; - try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { - testInjectExecutionService.checkCveExpectation( - outputParsers, structuredOutput, inject, agent); - mocked.verify( - () -> ExpectationUtils.setResultExpectationVulnerable(any(), any(), any()), times(1)); - } - } - - @Test - void trigger_checkCveExpectation_When_ExecutionTraceStatus_Is_Success() { - InjectExecutionInput input = new InjectExecutionInput(); - input.setMessage("message"); - input.setOutputStructured(null); - input.setOutputRaw("outputRaw"); - input.setStatus(ExecutionTraceStatus.SUCCESS.toString()); - input.setDuration(10); - input.setAction(InjectExecutionAction.command_execution); - - InjectStatus injectStatus = new InjectStatus(); - injectStatus.setName(ExecutionStatus.SUCCESS); - inject.setStatus(injectStatus); - OutputParser outputParser = - OutputParserFixture.getOutputParser( - Set.of(OutputParserFixture.getDefaultContractOutputElement())); - Set outputParsers = Set.of(outputParser); - doNothing().when(testInjectExecutionService).checkCveExpectation(any(), any(), any(), any()); - testInjectExecutionService.processInjectExecution(inject, agent, input, outputParsers); - verify(testInjectExecutionService, times(1)).checkCveExpectation(any(), any(), any(), any()); - } - - @Test - void dont_Trigger_checkCveExpectation_When_ExecutionTraceStatus_Is_Not_Success() { - InjectExecutionInput input = new InjectExecutionInput(); - input.setMessage("message"); - input.setOutputStructured(null); - input.setOutputRaw("outputRaw"); - input.setStatus(ExecutionTraceStatus.ERROR.toString()); - input.setDuration(10); - input.setAction(InjectExecutionAction.command_execution); - - InjectStatus injectStatus = new InjectStatus(); - injectStatus.setName(ExecutionStatus.SUCCESS); - inject.setStatus(injectStatus); - OutputParser outputParser = - OutputParserFixture.getOutputParser( - Set.of(OutputParserFixture.getDefaultContractOutputElement())); - Set outputParsers = Set.of(outputParser); - doNothing().when(testInjectExecutionService).checkCveExpectation(any(), any(), any(), any()); - testInjectExecutionService.processInjectExecution(inject, agent, input, outputParsers); - verify(testInjectExecutionService, times(0)).checkCveExpectation(any(), any(), any(), any()); - } - - @Test - void checkCveExpectation_NullStructuredOutput_ShouldSetNotVulnerable() { - Set outputParsers = Set.of(OutputParserFixture.getDefaultOutputParser()); - ObjectNode structuredOutput = null; - try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { - testInjectExecutionService.checkCveExpectation( - outputParsers, structuredOutput, inject, agent); - mocked.verify( - () -> ExpectationUtils.setResultExpectationVulnerable(any(), any(), any()), times(1)); - } - } - - @Test - void checkCveExpectation_NoCveType_ShouldSetNotVulnerable() { - Set outputParsers = Set.of(OutputParserFixture.getDefaultOutputParser()); - ObjectNode structuredOutput = mapper.createObjectNode(); - structuredOutput - .putArray("cve-key") - .addObject() - .put("id", "CVE-2025-0234") - .put("host", "savacano28") - .put("severity", "7.1"); - try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { - testInjectExecutionService.checkCveExpectation( - outputParsers, structuredOutput, inject, agent); - mocked.verify( - () -> ExpectationUtils.setResultExpectationVulnerable(any(), any(), any()), times(1)); - } - } - - @Test - void checkCveExpectation_HasCveTypeAndCveData_ShouldSetVulnerable() { - ContractOutputElement CVEOutputElement = OutputParserFixture.getCVEOutputElement(); - Set outputParsers = - Set.of(OutputParserFixture.getOutputParser(Set.of(CVEOutputElement))); - ObjectNode structuredOutput = mapper.createObjectNode(); - structuredOutput - .putArray("cve-key") - .addObject() - .put("id", "CVE-2025-0234") - .put("host", "savacano28") - .put("severity", "7.1"); - - try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { - testInjectExecutionService.checkCveExpectation( - outputParsers, structuredOutput, inject, agent); - - mocked.verify( - () -> ExpectationUtils.setResultExpectationVulnerable(any(), any(), any()), times(1)); - } - } -} diff --git a/openaev-api/src/test/java/io/openaev/service/InjectExpectationServiceTest.java b/openaev-api/src/test/java/io/openaev/service/InjectExpectationServiceTest.java index 4820e022076..5f5bceb161e 100644 --- a/openaev-api/src/test/java/io/openaev/service/InjectExpectationServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/service/InjectExpectationServiceTest.java @@ -1,94 +1,352 @@ package io.openaev.service; +import static io.openaev.utils.fixtures.InjectExpectationFixture.createVulnerabilityInjectExpectation; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import io.openaev.database.model.*; -import io.openaev.database.repository.*; +import io.openaev.database.repository.InjectExpectationRepository; +import io.openaev.rest.inject.form.InjectExecutionAction; +import io.openaev.rest.inject.form.InjectExecutionInput; +import io.openaev.rest.inject.form.InjectExpectationUpdateInput; +import io.openaev.rest.inject.service.ExecutionProcessingContext; +import io.openaev.utils.ExpectationUtils; import io.openaev.utils.fixtures.*; import java.util.List; -import org.junit.jupiter.api.*; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; +import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class InjectExpectationServiceTest { - static Long EXPIRATION_TIME_SIX_HOURS = 21600L; + static final Long EXPIRATION_TIME_SIX_HOURS = 21600L; - @Mock private InjectExpectationRepository mockedInjectExpectationRepository; - @InjectMocks private InjectExpectationService testInjectExpectationService; + @Mock private InjectExpectationRepository injectExpectationRepository; + @Spy @InjectMocks private InjectExpectationService injectExpectationService; + @Spy private ObjectMapper mapper = new ObjectMapper(); + + private Inject inject; + private Agent agent; + + @BeforeEach + void setUp() { + agent = AgentFixture.createDefaultAgentService(); + inject = InjectFixture.getDefaultInject(); + inject.setExpectations(List.of(createVulnerabilityInjectExpectation(inject, agent))); + } + + private void mockExpectation(InjectExpectation expectation) { + doReturn(expectation) + .when(injectExpectationService) + .updateInjectExpectation(any(), any(InjectExpectationUpdateInput.class)); + when(injectExpectationRepository.saveAll(any())).thenReturn(List.of(expectation)); + } + + private ExecutionProcessingContext createContext(InjectExecutionInput input) { + return new ExecutionProcessingContext(inject, agent, input, Map.of()); + } + + private InjectExecutionInput buildDefaultInput(ObjectNode structuredOutput) { + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("message"); + input.setOutputStructured(structuredOutput != null ? String.valueOf(structuredOutput) : null); + input.setOutputRaw("outputRaw"); + input.setStatus(ExecutionTraceStatus.SUCCESS.toString()); + input.setDuration(10); + input.setAction(InjectExecutionAction.command_execution); + return input; + } + + private void setupInjectWithOutputParser(OutputParser outputParser) + throws JsonProcessingException { + Injector injector = InjectorFixture.createDefaultInjector("InjectorName"); + Payload payload = PayloadFixture.createDefaultCommand(); + payload.setOutputParsers(outputParser != null ? Set.of(outputParser) : Set.of()); + InjectorContract contract = + InjectorContractFixture.createPayloadInjectorContract(injector, payload); + inject.setInjectorContract(contract); + } + + private void setupVulnerabilityExpectation() { + InjectExpectation expectation = createVulnerabilityInjectExpectation(inject, agent); + inject.setExpectations(List.of(expectation)); + mockExpectation(expectation); + } + + private void verifySetResultExpectationVulnerableCalledOnce( + MockedStatic mocked) { + mocked.verify( + () -> ExpectationUtils.setResultExpectationVulnerable(any(), any(), any()), times(1)); + } @Test - void preventionExpectationsNotExpired_NoneExpired() { - // Arrange - Inject inject = InjectFixture.getDefaultInject(); - InjectExpectation preventionExpectation = + void shouldReturnAllPreventionExpectationsWhenNoneExpired() { + InjectExpectation expectation1 = InjectExpectationFixture.createPreventionInjectExpectation(inject, null); - InjectExpectation preventionExpectation2 = + InjectExpectation expectation2 = InjectExpectationFixture.createPreventionInjectExpectation(inject, null); + when(injectExpectationRepository.findAll(any())) + .thenReturn(List.of(expectation1, expectation2)); - when(mockedInjectExpectationRepository.findAll(any())) - .thenReturn(List.of(preventionExpectation, preventionExpectation2)); - - // Act List result = - testInjectExpectationService.preventionExpectationsNotExpired( + injectExpectationService.preventionExpectationsNotExpired( EXPIRATION_TIME_SIX_HOURS.intValue() * 2); - // Assert assertNotNull(result); assertEquals(2, result.size()); - assertEquals(preventionExpectation.getId(), result.get(0).getId()); + assertEquals(expectation1.getId(), result.getFirst().getId()); } @Test - void detectionExpectationsNotExpired_NoneExpired() { - // Arrange - Inject inject = InjectFixture.getDefaultInject(); - InjectExpectation detectionExpectation = + void shouldReturnAllDetectionExpectationsWhenNoneExpired() { + InjectExpectation expectation1 = InjectExpectationFixture.createDetectionInjectExpectation(inject, null); - InjectExpectation detectionExpectation2 = + InjectExpectation expectation2 = InjectExpectationFixture.createDetectionInjectExpectation(inject, null); + when(injectExpectationRepository.findAll(any())) + .thenReturn(List.of(expectation1, expectation2)); - when(mockedInjectExpectationRepository.findAll(any())) - .thenReturn(List.of(detectionExpectation, detectionExpectation2)); - - // Act List result = - testInjectExpectationService.detectionExpectationsNotExpired( + injectExpectationService.detectionExpectationsNotExpired( EXPIRATION_TIME_SIX_HOURS.intValue() * 2); - // Assert assertNotNull(result); assertEquals(2, result.size()); - assertEquals(detectionExpectation.getId(), result.get(0).getId()); + assertEquals(expectation1.getId(), result.getFirst().getId()); } @Test - void manualExpectationsNotExpired_NoneExpired() { - // Arrange - Inject inject = InjectFixture.getDefaultInject(); - InjectExpectation manualExpectation = + void shouldReturnAllManualExpectationsWhenNoneExpired() { + InjectExpectation expectation1 = InjectExpectationFixture.createManualInjectExpectation(null, inject); - InjectExpectation manualExpectation2 = + InjectExpectation expectation2 = InjectExpectationFixture.createManualInjectExpectation(null, inject); + when(injectExpectationRepository.findAll(any())) + .thenReturn(List.of(expectation1, expectation2)); - when(mockedInjectExpectationRepository.findAll(any())) - .thenReturn(List.of(manualExpectation, manualExpectation2)); - - // Act List result = - testInjectExpectationService.manualExpectationsNotExpired( + injectExpectationService.manualExpectationsNotExpired( EXPIRATION_TIME_SIX_HOURS.intValue() * 2); - // Assert assertNotNull(result); assertEquals(2, result.size()); - assertEquals(manualExpectation.getId(), result.get(0).getId()); + assertEquals(expectation1.getId(), result.getFirst().getId()); + } + + @Test + void shouldSetNotVulnerableWhenNoOutputParsers() throws JsonProcessingException { + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + setupInjectWithOutputParser(null); + setupVulnerabilityExpectation(); + + injectExpectationService.matchesVulnerabilityExpectations( + createContext(new InjectExecutionInput()), mapper.createObjectNode()); + + verifySetResultExpectationVulnerableCalledOnce(mocked); + } + } + + @Test + void shouldSetNotVulnerableWhenEmptyStructuredOutput() { + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + setupVulnerabilityExpectation(); + + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), mapper.createObjectNode()); + + verifySetResultExpectationVulnerableCalledOnce(mocked); + } + } + + @Test + void shouldSetNotVulnerableWhenNoCveType() throws JsonProcessingException { + ObjectNode structuredOutput = mapper.createObjectNode(); + structuredOutput + .putArray("no-cve-key") + .addObject() + .put("id", "no-cve-id") + .put("host", "savanna28") + .put("severity", "7.1"); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + setupInjectWithOutputParser( + OutputParserFixture.getOutputParser( + Set.of(OutputParserFixture.getContractOutputElementTypeIPv6()))); + setupVulnerabilityExpectation(); + + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(structuredOutput)), structuredOutput); + + verifySetResultExpectationVulnerableCalledOnce(mocked); + } + } + + @Test + void shouldSetVulnerableWhenHasCveTypeAndCveData() { + ObjectNode structuredOutput = mapper.createObjectNode(); + structuredOutput + .putArray("cve-key") + .addObject() + .put("id", "CVE-2025-0234") + .put("host", "savacano28") + .put("severity", "7.1"); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + setupInjectWithOutputParser( + OutputParserFixture.getOutputParser( + Set.of(OutputParserFixture.getContractOutputElementTypeIPv6()))); + setupVulnerabilityExpectation(); + + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(structuredOutput)), structuredOutput); + + verifySetResultExpectationVulnerableCalledOnce(mocked); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + @Test + void shouldSetNotVulnerableWhenStructuredOutputIsEmptyArray() { + // isArray()=true but size()=0 -> not vulnerable + ArrayNode structuredOutput = mapper.createArrayNode(); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + setupVulnerabilityExpectation(); + + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), structuredOutput); + + verifySetResultExpectationVulnerableCalledOnce(mocked); + } + } + + @Test + void shouldSetVulnerableWhenStructuredOutputIsNonEmptyArray() { + // isArray()=true and size()>0 -> vulnerable + ArrayNode structuredOutput = mapper.createArrayNode(); + structuredOutput.addObject().put("id", "CVE-2025-9999"); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + setupVulnerabilityExpectation(); + + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), structuredOutput); + + verifySetResultExpectationVulnerableCalledOnce(mocked); + } + } + + @Test + void shouldDoNothingWhenNoVulnerabilityExpectationsForAgent() { + // Expectation belongs to a different agent -> filtered out -> early return + Agent otherAgent = AgentFixture.createDefaultAgentService(); + InjectExpectation expectationForOtherAgent = + createVulnerabilityInjectExpectation(inject, otherAgent); + inject.setExpectations(List.of(expectationForOtherAgent)); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), mapper.createObjectNode()); + + // early return: nothing should be called + mocked.verify( + () -> ExpectationUtils.setResultExpectationVulnerable(any(), any(), any()), never()); + verify(injectExpectationRepository, never()).saveAll(any()); + } + } + + @Test + void shouldDoNothingWhenExpectationsAreNotVulnerabilityType() { + // Only non-VULNERABILITY expectations -> filtered out -> early return + InjectExpectation prevention = + InjectExpectationFixture.createPreventionInjectExpectation(inject, null); + InjectExpectation detection = + InjectExpectationFixture.createDetectionInjectExpectation(inject, null); + inject.setExpectations(List.of(prevention, detection)); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), mapper.createObjectNode()); + + mocked.verify( + () -> ExpectationUtils.setResultExpectationVulnerable(any(), any(), any()), never()); + verify(injectExpectationRepository, never()).saveAll(any()); + } + } + + @Test + void shouldDoNothingWhenExpectationHasNullAgent() { + // exp.getAgent() == null -> filtered out -> early return + InjectExpectation expectationWithNullAgent = createVulnerabilityInjectExpectation(inject, null); + inject.setExpectations(List.of(expectationWithNullAgent)); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), mapper.createObjectNode()); + + mocked.verify( + () -> ExpectationUtils.setResultExpectationVulnerable(any(), any(), any()), never()); + verify(injectExpectationRepository, never()).saveAll(any()); + } + } + + @Test + void shouldDoNothingWhenInjectHasNoExpectations() { + inject.setExpectations(List.of()); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), mapper.createObjectNode()); + + mocked.verify( + () -> ExpectationUtils.setResultExpectationVulnerable(any(), any(), any()), never()); + verify(injectExpectationRepository, never()).saveAll(any()); + } + } + + @Test + void shouldSaveAllExpectationsAfterProcessing() { + setupVulnerabilityExpectation(); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), mapper.createObjectNode()); + + verify(injectExpectationRepository, times(1)).saveAll(any()); + } + } + + @Test + void shouldCallUpdateForEachVulnerabilityExpectation() { + // Two vulnerability expectations for the same agent + InjectExpectation exp1 = createVulnerabilityInjectExpectation(inject, agent); + InjectExpectation exp2 = createVulnerabilityInjectExpectation(inject, agent); + inject.setExpectations(List.of(exp1, exp2)); + doReturn(exp1) + .when(injectExpectationService) + .updateInjectExpectation(any(), any(InjectExpectationUpdateInput.class)); + when(injectExpectationRepository.saveAll(any())).thenReturn(List.of(exp1, exp2)); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), mapper.createObjectNode()); + + // updateInjectExpectation called once per expectation + verify(injectExpectationService, times(2)) + .updateInjectExpectation(any(), any(InjectExpectationUpdateInput.class)); + verify(injectExpectationRepository, times(1)).saveAll(any()); + } } } diff --git a/openaev-api/src/test/java/io/openaev/utils/fixtures/OutputParserFixture.java b/openaev-api/src/test/java/io/openaev/utils/fixtures/OutputParserFixture.java index 2dc6616de93..3ce5018fae6 100644 --- a/openaev-api/src/test/java/io/openaev/utils/fixtures/OutputParserFixture.java +++ b/openaev-api/src/test/java/io/openaev/utils/fixtures/OutputParserFixture.java @@ -14,7 +14,7 @@ public static OutputParser getOutputParser(Set contractOu } public static OutputParser getDefaultOutputParser() { - ContractOutputElement contractOutputElement = getDefaultContractOutputElement(); + ContractOutputElement contractOutputElement = getContractOutputElementTypeIPv6(); return getOutputParser(Set.of(contractOutputElement)); } @@ -41,7 +41,7 @@ public static ContractOutputElement getContractOutputElement( return contractOutputElement; } - public static ContractOutputElement getDefaultContractOutputElement() { + public static ContractOutputElement getContractOutputElementTypeIPv6() { return getContractOutputElement( ContractOutputType.IPv6, "/d+", Set.of(getDefaultRegexGroup()), false); } diff --git a/openaev-api/src/test/java/io/openaev/utils/fixtures/PayloadFixture.java b/openaev-api/src/test/java/io/openaev/utils/fixtures/PayloadFixture.java index 9a180632b97..47593374427 100644 --- a/openaev-api/src/test/java/io/openaev/utils/fixtures/PayloadFixture.java +++ b/openaev-api/src/test/java/io/openaev/utils/fixtures/PayloadFixture.java @@ -7,7 +7,6 @@ import io.openaev.database.model.*; import io.openaev.injector_contract.fields.ContractFieldType; -import io.openaev.utils.fixtures.composers.DomainComposer; import jakarta.annotation.Nullable; import java.util.*; @@ -17,7 +16,6 @@ public class PayloadFixture { private static final Endpoint.PLATFORM_TYPE[] MACOS_PLATFORM = {Endpoint.PLATFORM_TYPE.MacOS}; private static final Endpoint.PLATFORM_TYPE[] WINDOWS_PLATFORM = {Endpoint.PLATFORM_TYPE.Windows}; public static final String COMMAND_PAYLOAD_NAME = "command payload"; - private DomainComposer domainComposer; private static void initializeDefaultPayload( final Payload payload, final Endpoint.PLATFORM_TYPE[] platforms, Set domains) { diff --git a/openaev-api/src/test/java/io/openaev/utils/helpers/InjectTestHelper.java b/openaev-api/src/test/java/io/openaev/utils/helpers/InjectTestHelper.java index 726ee1488a6..57a1098a8d4 100644 --- a/openaev-api/src/test/java/io/openaev/utils/helpers/InjectTestHelper.java +++ b/openaev-api/src/test/java/io/openaev/utils/helpers/InjectTestHelper.java @@ -2,14 +2,8 @@ import io.openaev.database.model.*; import io.openaev.database.repository.*; -import io.openaev.utils.fixtures.AgentFixture; -import io.openaev.utils.fixtures.EndpointFixture; -import io.openaev.utils.fixtures.InjectFixture; -import io.openaev.utils.fixtures.InjectStatusFixture; -import io.openaev.utils.fixtures.composers.AgentComposer; -import io.openaev.utils.fixtures.composers.EndpointComposer; -import io.openaev.utils.fixtures.composers.InjectComposer; -import io.openaev.utils.fixtures.composers.InjectStatusComposer; +import io.openaev.utils.fixtures.*; +import io.openaev.utils.fixtures.composers.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; @@ -33,11 +27,16 @@ public class InjectTestHelper { @Transactional(propagation = Propagation.REQUIRES_NEW) public Inject getPendingInjectWithAssets( InjectComposer injectComposer, + InjectorContractComposer injectorContractComposer, EndpointComposer endpointComposer, AgentComposer agentComposer, InjectStatusComposer injectStatusComposer) { return injectComposer .forInject(InjectFixture.getDefaultInject()) + .withInjectorContract( + injectorContractComposer + .forInjectorContract(InjectorContractFixture.createDefaultInjectorContract()) + .withInjector(InjectorFixture.createDefaultPayloadInjector())) .withEndpoint( endpointComposer .forEndpoint(EndpointFixture.createEndpoint()) diff --git a/openaev-model/src/main/java/io/openaev/schema/SchemaUtils.java b/openaev-model/src/main/java/io/openaev/schema/SchemaUtils.java index 6edd9f36bde..2cb9501a156 100644 --- a/openaev-model/src/main/java/io/openaev/schema/SchemaUtils.java +++ b/openaev-model/src/main/java/io/openaev/schema/SchemaUtils.java @@ -6,6 +6,7 @@ import io.openaev.annotation.EsQueryable; import io.openaev.annotation.Indexable; import io.openaev.annotation.Queryable; +import io.swagger.v3.oas.annotations.Hidden; import jakarta.persistence.Column; import jakarta.persistence.JoinTable; import jakarta.validation.constraints.Email; @@ -149,8 +150,17 @@ private static PropertySchema buildPropertySchemaFromMethod(Class clazz, Meth private static List getEnumNames(Class enumType) { return Arrays.stream(enumType.getEnumConstants()) + .filter( + constant -> { + try { + Field enumField = enumType.getField(((Enum) constant).name()); + return !enumField.isAnnotationPresent(Hidden.class); + } catch (NoSuchFieldException e) { + return true; + } + }) .map(Object::toString) - .collect(Collectors.toList()); + .toList(); } private static void processAnnotations( From fb1e6b7713f2ff87b76c0d3b0cc64f540315791e Mon Sep 17 00:00:00 2001 From: savacano28 Date: Thu, 5 Mar 2026 10:52:08 +0100 Subject: [PATCH 05/21] [backend] feat: review feedback --- .../io/openaev/rest/inject/InjectApiTest.java | 600 ++++++++++++++++++ 1 file changed, 600 insertions(+) diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java index 5b64663b142..1af1997ab38 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java @@ -1361,6 +1361,606 @@ void given_targetedAsset_should_linkFindingToIt() throws Exception { assertEquals(1, findings.getLast().getAssets().size()); assertEquals(endpointSaved.getId(), findings.getLast().getAssets().getFirst().getId()); } + + /** + * Builds a pending inject backed by a Command payload that carries the given output parser, + * and returns {@code [inject, agentId]}. + */ + private Object[] buildInjectWithOutputParser(OutputParser outputParser) throws Exception { + Domain domain = injectTestHelper.forceSaveDomain(DomainFixture.getRandomDomain()); + Command command = + PayloadFixture.createCommand( + "bash", "echo test", null, null, new HashSet<>(Set.of(domain))); + command.setOutputParsers(Set.of(outputParser)); + Payload payloadSaved = injectTestHelper.forceSavePayload(command); + + Injector injector = InjectorFixture.createDefaultPayloadInjector(); + injectTestHelper.forceSaveInjector(injector); + + InjectorContract injectorContract = + InjectorContractFixture.createPayloadInjectorContractWithFieldsContent( + injector, payloadSaved, List.of()); + injectorContract.setContent(injectorContract.getConvertedContent().toString()); + InjectorContract injectorContractSaved = + injectTestHelper.forceSaveInjectorContract(injectorContract); + + Inject inject = getPendingInjectWithAssets(); + inject.setInjectorContract(injectorContractSaved); + injectTestHelper.forceSaveInject(inject); + + String agentId = ((Endpoint) inject.getAssets().getFirst()).getAgents().getFirst().getId(); + return new Object[] {inject, agentId}; + } + + /** Wraps stdout content in the expected JSON envelope used by the implant callback. */ + private InjectExecutionInput buildStdoutInput(String stdoutContent) { + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("{\"stdout\":\"" + stdoutContent.replace("\\", "\\\\") + "\"}"); + input.setAction(InjectExecutionAction.command_execution); + input.setStatus("SUCCESS"); + return input; + } + + // CVE + + @Test + @DisplayName("Should create findings for each CVE extracted from raw output") + void shouldCreateFindingsForEachCveExtractedFromRawOutput() throws Exception { + // -- PREPARE -- + ContractOutputElement cveElement = OutputParserFixture.getCVEOutputElement(); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(cveElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject cveInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + String rawOutput = + "[CVE-2025-25241] [http] [critical] http://192.168.1.10/\\n" + + "[CVE-2025-99999] [http] [high] http://192.168.1.20/\\n"; + InjectExecutionInput input = buildStdoutInput(rawOutput); + + // -- EXECUTE -- + performCallbackRequest(agentId, cveInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> findingRepository.findAllByInjectId(cveInject.getId()).size() >= 2); + + List cveFindings = findingRepository.findAllByInjectId(cveInject.getId()); + assertEquals(2, cveFindings.size()); + assertTrue( + cveFindings.stream().anyMatch(f -> f.getValue().contains("CVE-2025-25241")), + "Expected finding for CVE-2025-25241"); + assertTrue( + cveFindings.stream().anyMatch(f -> f.getValue().contains("CVE-2025-99999")), + "Expected finding for CVE-2025-99999"); + cveFindings.forEach(f -> assertEquals(ContractOutputType.CVE, f.getType())); + } + + @Test + @DisplayName("Should not create CVE findings when raw output contains no CVE matches") + void shouldNotCreateCveFindingsWhenRawOutputContainsNoCveMatches() throws Exception { + // -- PREPARE -- + ContractOutputElement cveElement = OutputParserFixture.getCVEOutputElement(); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(cveElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject cveInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + InjectExecutionInput input = buildStdoutInput("no vulnerabilities found"); + + // -- EXECUTE -- + performCallbackRequest(agentId, cveInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(8, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> true); + assertTrue( + findingRepository.findAllByInjectId(cveInject.getId()).isEmpty(), + "No findings expected when output has no CVE match"); + } + + // Credentials + + @Test + @DisplayName("Should create a finding for each credential pair extracted from raw output") + void shouldCreateFindingForEachCredentialPairExtractedFromRawOutput() throws Exception { + // -- PREPARE -- + RegexGroup usernameGroup = OutputParserFixture.getRegexGroup("username", "$2"); + RegexGroup passwordGroup = OutputParserFixture.getRegexGroup("password", "$3"); + ContractOutputElement credElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.Credentials, + "(\\S+)\\\\(\\S+):(\\S+)", + Set.of(usernameGroup, passwordGroup), + true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(credElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject credInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + // domain\\user:pass format — each line produces one finding + String rawOutput = + "SMB 192.168.11.23 445 SERVER [+] WORKGROUP\\\\alice:secret123 (Pwn3d!)\\n" + + "SMB 192.168.11.23 445 SERVER [+] WORKGROUP\\\\bob:hunter2 (Pwn3d!)\\n"; + InjectExecutionInput input = buildStdoutInput(rawOutput); + + // -- EXECUTE -- + performCallbackRequest(agentId, credInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> findingRepository.findAllByInjectId(credInject.getId()).size() >= 2); + + List credFindings = findingRepository.findAllByInjectId(credInject.getId()); + assertEquals(2, credFindings.size()); + assertTrue( + credFindings.stream().anyMatch(f -> f.getValue().contains("alice:secret123")), + "Expected finding for alice:secret123"); + assertTrue( + credFindings.stream().anyMatch(f -> f.getValue().contains("bob:hunter2")), + "Expected finding for bob:hunter2"); + credFindings.forEach(f -> assertEquals(ContractOutputType.Credentials, f.getType())); + } + + @Test + @DisplayName("Should not create credentials findings when raw output contains no credentials") + void shouldNotCreateCredentialsFindingsWhenRawOutputContainsNoCredentials() throws Exception { + // -- PREPARE -- + RegexGroup usernameGroup = OutputParserFixture.getRegexGroup("username", "$2"); + RegexGroup passwordGroup = OutputParserFixture.getRegexGroup("password", "$3"); + ContractOutputElement credElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.Credentials, + "(\\S+)\\\\(\\S+):(\\S+)", + Set.of(usernameGroup, passwordGroup), + true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(credElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject credInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + InjectExecutionInput input = buildStdoutInput("no credentials here"); + + // -- EXECUTE -- + performCallbackRequest(agentId, credInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(8, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> true); + assertTrue(findingRepository.findAllByInjectId(credInject.getId()).isEmpty()); + } + + // PortScan + + @Test + @DisplayName("Should create a finding for each open port/service extracted from raw output") + void shouldCreateFindingForEachOpenPortServiceExtractedFromRawOutput() throws Exception { + // -- PREPARE -- + RegexGroup hostGroup = OutputParserFixture.getRegexGroup("host", "$2"); + RegexGroup portGroup = OutputParserFixture.getRegexGroup("port", "$3"); + RegexGroup serviceGroup = OutputParserFixture.getRegexGroup("service", "$4"); + ContractOutputElement portScanElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.PortsScan, + "^\\s*(TCP|UDP)\\s+([\\d\\.]+|\\*)?:?(\\d+)\\s+\\S+\\s+(\\S+)", + Set.of(hostGroup, portGroup, serviceGroup), + true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(portScanElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject portScanInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + String rawOutput = + " TCP 192.168.1.10:135 0.0.0.0:0 LISTENING\\n" + + " TCP 10.0.0.5:443 0.0.0.0:0 LISTENING\\n"; + InjectExecutionInput input = buildStdoutInput(rawOutput); + + // -- EXECUTE -- + performCallbackRequest(agentId, portScanInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> findingRepository.findAllByInjectId(portScanInject.getId()).size() >= 2); + + List portScanFindings = + findingRepository.findAllByInjectId(portScanInject.getId()); + assertEquals(2, portScanFindings.size()); + assertTrue( + portScanFindings.stream().anyMatch(f -> f.getValue().contains("192.168.1.10")), + "Expected finding for 192.168.1.10:135"); + assertTrue( + portScanFindings.stream().anyMatch(f -> f.getValue().contains("10.0.0.5")), + "Expected finding for 10.0.0.5:443"); + portScanFindings.forEach(f -> assertEquals(ContractOutputType.PortsScan, f.getType())); + } + + @Test + @DisplayName("Should not create PortScan findings when raw output has no port scan matches") + void shouldNotCreatePortScanFindingsWhenRawOutputHasNoPortScanMatches() throws Exception { + // -- PREPARE -- + RegexGroup hostGroup = OutputParserFixture.getRegexGroup("host", "$2"); + RegexGroup portGroup = OutputParserFixture.getRegexGroup("port", "$3"); + RegexGroup serviceGroup = OutputParserFixture.getRegexGroup("service", "$4"); + ContractOutputElement portScanElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.PortsScan, + "^\\s*(TCP|UDP)\\s+([\\d\\.]+|\\*)?:?(\\d+)\\s+\\S+\\s+(\\S+)", + Set.of(hostGroup, portGroup, serviceGroup), + true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(portScanElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject portScanInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + InjectExecutionInput input = buildStdoutInput("nothing to scan"); + + // -- EXECUTE -- + performCallbackRequest(agentId, portScanInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(8, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> true); + assertTrue(findingRepository.findAllByInjectId(portScanInject.getId()).isEmpty()); + } + + // Port + + @Test + @DisplayName("Should create a finding for each port number extracted from raw output") + void shouldCreateFindingForEachPortNumberExtractedFromRawOutput() throws Exception { + // -- PREPARE -- + RegexGroup portGroup = OutputParserFixture.getRegexGroup("port", "$1"); + ContractOutputElement portElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.Port, + "(?:TCP|UDP)\\s+[\\d\\.]+:(\\d+)", + Set.of(portGroup), + true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(portElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject portInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + String rawOutput = + " TCP 192.168.1.10:8080 0.0.0.0:0 LISTENING\\n" + + " TCP 192.168.1.10:443 0.0.0.0:0 LISTENING\\n"; + InjectExecutionInput input = buildStdoutInput(rawOutput); + + // -- EXECUTE -- + performCallbackRequest(agentId, portInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> findingRepository.findAllByInjectId(portInject.getId()).size() >= 2); + + List portFindings = findingRepository.findAllByInjectId(portInject.getId()); + assertEquals(2, portFindings.size()); + assertTrue( + portFindings.stream().anyMatch(f -> f.getValue().equals("8080")), + "Expected finding for port 8080"); + assertTrue( + portFindings.stream().anyMatch(f -> f.getValue().equals("443")), + "Expected finding for port 443"); + portFindings.forEach(f -> assertEquals(ContractOutputType.Port, f.getType())); + } + + @Test + @DisplayName("Should not create Port findings when raw output contains no port matches") + void shouldNotCreatePortFindingsWhenRawOutputContainsNoPortMatches() throws Exception { + // -- PREPARE -- + RegexGroup portGroup = OutputParserFixture.getRegexGroup("port", "$1"); + ContractOutputElement portElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.Port, + "(?:TCP|UDP)\\s+[\\d\\.]+:(\\d+)", + Set.of(portGroup), + true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(portElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject portInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + InjectExecutionInput input = buildStdoutInput("no ports here"); + + // -- EXECUTE -- + performCallbackRequest(agentId, portInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(8, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> true); + assertTrue(findingRepository.findAllByInjectId(portInject.getId()).isEmpty()); + } + + // Text + + @Test + @DisplayName("Should create findings for each text value extracted from raw output") + void shouldCreateFindingsForEachTextValueExtractedFromRawOutput() throws Exception { + // -- PREPARE -- + RegexGroup textGroup = OutputParserFixture.getRegexGroup("text", "$0"); + ContractOutputElement textElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.Text, "^(\\S+)", Set.of(textGroup), true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(textElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject textInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + String rawOutput = "System\\nRegistry\\n"; + InjectExecutionInput input = buildStdoutInput(rawOutput); + + // -- EXECUTE -- + performCallbackRequest(agentId, textInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> !findingRepository.findAllByInjectId(textInject.getId()).isEmpty()); + + List textFindings = findingRepository.findAllByInjectId(textInject.getId()); + assertFalse(textFindings.isEmpty(), "Expected at least one text finding"); + textFindings.forEach(f -> assertEquals(ContractOutputType.Text, f.getType())); + } + + @Test + @DisplayName("Should not create Text findings when raw output contains no matches") + void shouldNotCreateTextFindingsWhenRawOutputContainsNoMatches() throws Exception { + // -- PREPARE -- + RegexGroup textGroup = OutputParserFixture.getRegexGroup("text", "$1"); + ContractOutputElement textElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.Text, "IMPOSSIBLE_PATTERN_XYZ_123", Set.of(textGroup), true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(textElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject textInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + InjectExecutionInput input = buildStdoutInput("some random text"); + + // -- EXECUTE -- + performCallbackRequest(agentId, textInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(8, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> true); + assertTrue(findingRepository.findAllByInjectId(textInject.getId()).isEmpty()); + } + + // Number + + @Test + @DisplayName("Should create findings for each number extracted from raw output") + void shouldCreateFindingsForEachNumberExtractedFromRawOutput() throws Exception { + // -- PREPARE -- + RegexGroup numberGroup = OutputParserFixture.getRegexGroup("number", "$1"); + ContractOutputElement numberElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.Number, "(\\d{4,})", Set.of(numberGroup), true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(numberElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject numberInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + String rawOutput = "Process 1234 used 5678 bytes\\n"; + InjectExecutionInput input = buildStdoutInput(rawOutput); + + // -- EXECUTE -- + performCallbackRequest(agentId, numberInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> findingRepository.findAllByInjectId(numberInject.getId()).size() >= 2); + + List numberFindings = findingRepository.findAllByInjectId(numberInject.getId()); + assertEquals(2, numberFindings.size()); + assertTrue(numberFindings.stream().anyMatch(f -> f.getValue().equals("1234"))); + assertTrue(numberFindings.stream().anyMatch(f -> f.getValue().equals("5678"))); + numberFindings.forEach(f -> assertEquals(ContractOutputType.Number, f.getType())); + } + + @Test + @DisplayName("Should not create Number findings when raw output contains no numeric matches") + void shouldNotCreateNumberFindingsWhenRawOutputContainsNoNumericMatches() throws Exception { + // -- PREPARE -- + RegexGroup numberGroup = OutputParserFixture.getRegexGroup("number", "$1"); + ContractOutputElement numberElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.Number, "(\\d{4,})", Set.of(numberGroup), true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(numberElement)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject numberInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + InjectExecutionInput input = buildStdoutInput("no big numbers here"); + + // -- EXECUTE -- + performCallbackRequest(agentId, numberInject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(8, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> true); + assertTrue(findingRepository.findAllByInjectId(numberInject.getId()).isEmpty()); + } + + // IPv4 + + @Test + @DisplayName("Should create a finding for each valid IPv4 address extracted from raw output") + void shouldCreateFindingForEachValidIPv4AddressExtractedFromRawOutput() throws Exception { + // -- PREPARE -- + RegexGroup ipv4Group = OutputParserFixture.getRegexGroup("ipv4", "$0"); + ContractOutputElement ipv4Element = + OutputParserFixture.getContractOutputElement( + ContractOutputType.IPv4, + "\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b", + Set.of(ipv4Group), + true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(ipv4Element)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject ipv4Inject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + String rawOutput = + " TCP 192.168.1.10:135 0.0.0.0:0 LISTENING\\n" + + " TCP 10.0.0.5:443 0.0.0.0:0 LISTENING\\n"; + InjectExecutionInput input = buildStdoutInput(rawOutput); + + // -- EXECUTE -- + performCallbackRequest(agentId, ipv4Inject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> !findingRepository.findAllByInjectId(ipv4Inject.getId()).isEmpty()); + + List ipv4Findings = findingRepository.findAllByInjectId(ipv4Inject.getId()); + assertFalse(ipv4Findings.isEmpty(), "Expected at least one IPv4 finding"); + assertTrue( + ipv4Findings.stream().anyMatch(f -> f.getValue().equals("192.168.1.10")), + "Expected finding for 192.168.1.10"); + assertTrue( + ipv4Findings.stream().anyMatch(f -> f.getValue().equals("10.0.0.5")), + "Expected finding for 10.0.0.5"); + ipv4Findings.forEach(f -> assertEquals(ContractOutputType.IPv4, f.getType())); + } + + @Test + @DisplayName( + "Should not create IPv4 findings when raw output contains no valid IPv4 addresses") + void shouldNotCreateIPv4FindingsWhenRawOutputContainsNoValidIPv4Addresses() throws Exception { + // -- PREPARE -- + RegexGroup ipv4Group = OutputParserFixture.getRegexGroup("ipv4", "$0"); + ContractOutputElement ipv4Element = + OutputParserFixture.getContractOutputElement( + ContractOutputType.IPv4, + "\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b", + Set.of(ipv4Group), + true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(ipv4Element)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject ipv4Inject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + // 999.x.x.x is not a valid IPv4 — the processor's validate() rejects it + InjectExecutionInput input = buildStdoutInput("host 999.999.999.999 is unknown"); + + // -- EXECUTE -- + performCallbackRequest(agentId, ipv4Inject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(8, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> true); + assertTrue(findingRepository.findAllByInjectId(ipv4Inject.getId()).isEmpty()); + } + + // IPv6 + + @Test + @DisplayName("Should create a finding for each valid IPv6 address extracted from raw output") + void shouldCreateFindingForEachValidIPv6AddressExtractedFromRawOutput() throws Exception { + // -- PREPARE -- + RegexGroup ipv6Group = OutputParserFixture.getRegexGroup("ipv6", "$1"); + String ipv6Regex = "\\[([a-fA-F0-9:]+(?:%[a-zA-Z0-9]+)?)\\]:\\d+"; + ContractOutputElement ipv6Element = + OutputParserFixture.getContractOutputElement( + ContractOutputType.IPv6, ipv6Regex, Set.of(ipv6Group), true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(ipv6Element)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject ipv6Inject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + String rawOutput = + " UDP [fe80::1b03:a1ff:ccdb:b464%66]:1900 *:*\\n" + " UDP [2001:db8::1]:8080 *:*\\n"; + InjectExecutionInput input = buildStdoutInput(rawOutput); + + // -- EXECUTE -- + performCallbackRequest(agentId, ipv6Inject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> findingRepository.findAllByInjectId(ipv6Inject.getId()).size() >= 2); + + List ipv6Findings = findingRepository.findAllByInjectId(ipv6Inject.getId()); + assertEquals(2, ipv6Findings.size()); + assertTrue( + ipv6Findings.stream().anyMatch(f -> f.getValue().contains("fe80::1b03:a1ff:ccdb:b464")), + "Expected finding for fe80::1b03:a1ff:ccdb:b464"); + assertTrue( + ipv6Findings.stream().anyMatch(f -> f.getValue().contains("2001:db8::1")), + "Expected finding for 2001:db8::1"); + ipv6Findings.forEach(f -> assertEquals(ContractOutputType.IPv6, f.getType())); + } + + @Test + @DisplayName("Should not create IPv6 findings when raw output contains no IPv6 addresses") + void shouldNotCreateIPv6FindingsWhenRawOutputContainsNoIPv6Addresses() throws Exception { + // -- PREPARE -- + RegexGroup ipv6Group = OutputParserFixture.getRegexGroup("ipv6", "$1"); + String ipv6Regex = "\\[([a-fA-F0-9:]+(?:%[a-zA-Z0-9]+)?)\\]:\\d+"; + ContractOutputElement ipv6Element = + OutputParserFixture.getContractOutputElement( + ContractOutputType.IPv6, ipv6Regex, Set.of(ipv6Group), true); + OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(ipv6Element)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject ipv6Inject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + InjectExecutionInput input = buildStdoutInput("no ipv6 addresses"); + + // -- EXECUTE -- + performCallbackRequest(agentId, ipv6Inject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(8, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> true); + assertTrue(findingRepository.findAllByInjectId(ipv6Inject.getId()).isEmpty()); + } } } From 0dfc63457f6e2644a7983963613d631de43d8f62 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Thu, 5 Mar 2026 12:14:46 +0100 Subject: [PATCH 06/21] [backend] feat: review feedback --- .../io/openaev/rest/inject/InjectApiTest.java | 121 +++++++++--------- .../utils/helpers/InjectTestHelper.java | 25 ++++ 2 files changed, 89 insertions(+), 57 deletions(-) diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java index 1af1997ab38..3e864f8161a 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java @@ -1395,7 +1395,7 @@ private Object[] buildInjectWithOutputParser(OutputParser outputParser) throws E /** Wraps stdout content in the expected JSON envelope used by the implant callback. */ private InjectExecutionInput buildStdoutInput(String stdoutContent) { InjectExecutionInput input = new InjectExecutionInput(); - input.setMessage("{\"stdout\":\"" + stdoutContent.replace("\\", "\\\\") + "\"}"); + input.setMessage("{\"stdout\":\"" + stdoutContent + "\"}"); input.setAction(InjectExecutionAction.command_execution); input.setStatus("SUCCESS"); return input; @@ -1413,10 +1413,13 @@ void shouldCreateFindingsForEachCveExtractedFromRawOutput() throws Exception { Inject cveInject = (Inject) setup[0]; String agentId = (String) setup[1]; - String rawOutput = - "[CVE-2025-25241] [http] [critical] http://192.168.1.10/\\n" - + "[CVE-2025-99999] [http] [high] http://192.168.1.20/\\n"; - InjectExecutionInput input = buildStdoutInput(rawOutput); + // Build message directly, same format as given_targetedAsset_should_linkFindingToIt + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage( + "{\"stdout\":\"[CVE-2025-25241] [http] [critical] http://192.168.1.10/\\n" + + "[CVE-2025-99999] [http] [high] http://192.168.1.20/\\n\"}"); + input.setAction(InjectExecutionAction.command_execution); + input.setStatus("SUCCESS"); // -- EXECUTE -- performCallbackRequest(agentId, cveInject.getId(), input); @@ -1426,9 +1429,9 @@ void shouldCreateFindingsForEachCveExtractedFromRawOutput() throws Exception { .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> findingRepository.findAllByInjectId(cveInject.getId()).size() >= 2); + .until(() -> injectTestHelper.findFindingsByInjectId(cveInject.getId()).size() >= 2); - List cveFindings = findingRepository.findAllByInjectId(cveInject.getId()); + List cveFindings = injectTestHelper.findFindingsByInjectId(cveInject.getId()); assertEquals(2, cveFindings.size()); assertTrue( cveFindings.stream().anyMatch(f -> f.getValue().contains("CVE-2025-25241")), @@ -1456,12 +1459,12 @@ void shouldNotCreateCveFindingsWhenRawOutputContainsNoCveMatches() throws Except // -- ASSERT -- Awaitility.await() - .atMost(8, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> true); + .until(() -> injectTestHelper.hasInjectStatusTrace(cveInject.getId())); assertTrue( - findingRepository.findAllByInjectId(cveInject.getId()).isEmpty(), + injectTestHelper.findFindingsByInjectId(cveInject.getId()).isEmpty(), "No findings expected when output has no CVE match"); } @@ -1498,9 +1501,9 @@ void shouldCreateFindingForEachCredentialPairExtractedFromRawOutput() throws Exc .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> findingRepository.findAllByInjectId(credInject.getId()).size() >= 2); + .until(() -> injectTestHelper.findFindingsByInjectId(credInject.getId()).size() >= 2); - List credFindings = findingRepository.findAllByInjectId(credInject.getId()); + List credFindings = injectTestHelper.findFindingsByInjectId(credInject.getId()); assertEquals(2, credFindings.size()); assertTrue( credFindings.stream().anyMatch(f -> f.getValue().contains("alice:secret123")), @@ -1535,11 +1538,11 @@ void shouldNotCreateCredentialsFindingsWhenRawOutputContainsNoCredentials() thro // -- ASSERT -- Awaitility.await() - .atMost(8, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> true); - assertTrue(findingRepository.findAllByInjectId(credInject.getId()).isEmpty()); + .until(() -> injectTestHelper.hasInjectStatusTrace(credInject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(credInject.getId()).isEmpty()); } // PortScan @@ -1548,13 +1551,13 @@ void shouldNotCreateCredentialsFindingsWhenRawOutputContainsNoCredentials() thro @DisplayName("Should create a finding for each open port/service extracted from raw output") void shouldCreateFindingForEachOpenPortServiceExtractedFromRawOutput() throws Exception { // -- PREPARE -- - RegexGroup hostGroup = OutputParserFixture.getRegexGroup("host", "$2"); - RegexGroup portGroup = OutputParserFixture.getRegexGroup("port", "$3"); - RegexGroup serviceGroup = OutputParserFixture.getRegexGroup("service", "$4"); + RegexGroup hostGroup = OutputParserFixture.getRegexGroup("host", "$1"); + RegexGroup portGroup = OutputParserFixture.getRegexGroup("port", "$2"); + RegexGroup serviceGroup = OutputParserFixture.getRegexGroup("service", "$3"); ContractOutputElement portScanElement = OutputParserFixture.getContractOutputElement( ContractOutputType.PortsScan, - "^\\s*(TCP|UDP)\\s+([\\d\\.]+|\\*)?:?(\\d+)\\s+\\S+\\s+(\\S+)", + "(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}):(\\d+)\\s+\\S+\\s+(LISTENING)", Set.of(hostGroup, portGroup, serviceGroup), true); OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(portScanElement)); @@ -1562,10 +1565,12 @@ void shouldCreateFindingForEachOpenPortServiceExtractedFromRawOutput() throws Ex Inject portScanInject = (Inject) setup[0]; String agentId = (String) setup[1]; - String rawOutput = - " TCP 192.168.1.10:135 0.0.0.0:0 LISTENING\\n" - + " TCP 10.0.0.5:443 0.0.0.0:0 LISTENING\\n"; - InjectExecutionInput input = buildStdoutInput(rawOutput); + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage( + "{\"stdout\":\"192.168.1.10:135 0.0.0.0:0 LISTENING\\n" + + "10.0.0.5:443 0.0.0.0:0 LISTENING\\n\"}"); + input.setAction(InjectExecutionAction.command_execution); + input.setStatus("SUCCESS"); // -- EXECUTE -- performCallbackRequest(agentId, portScanInject.getId(), input); @@ -1575,10 +1580,11 @@ void shouldCreateFindingForEachOpenPortServiceExtractedFromRawOutput() throws Ex .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> findingRepository.findAllByInjectId(portScanInject.getId()).size() >= 2); + .until( + () -> injectTestHelper.findFindingsByInjectId(portScanInject.getId()).size() >= 2); List portScanFindings = - findingRepository.findAllByInjectId(portScanInject.getId()); + injectTestHelper.findFindingsByInjectId(portScanInject.getId()); assertEquals(2, portScanFindings.size()); assertTrue( portScanFindings.stream().anyMatch(f -> f.getValue().contains("192.168.1.10")), @@ -1593,13 +1599,13 @@ void shouldCreateFindingForEachOpenPortServiceExtractedFromRawOutput() throws Ex @DisplayName("Should not create PortScan findings when raw output has no port scan matches") void shouldNotCreatePortScanFindingsWhenRawOutputHasNoPortScanMatches() throws Exception { // -- PREPARE -- - RegexGroup hostGroup = OutputParserFixture.getRegexGroup("host", "$2"); - RegexGroup portGroup = OutputParserFixture.getRegexGroup("port", "$3"); - RegexGroup serviceGroup = OutputParserFixture.getRegexGroup("service", "$4"); + RegexGroup hostGroup = OutputParserFixture.getRegexGroup("host", "$1"); + RegexGroup portGroup = OutputParserFixture.getRegexGroup("port", "$2"); + RegexGroup serviceGroup = OutputParserFixture.getRegexGroup("service", "$3"); ContractOutputElement portScanElement = OutputParserFixture.getContractOutputElement( ContractOutputType.PortsScan, - "^\\s*(TCP|UDP)\\s+([\\d\\.]+|\\*)?:?(\\d+)\\s+\\S+\\s+(\\S+)", + "(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}):(\\d+)\\s+\\S+\\s+(LISTENING)", Set.of(hostGroup, portGroup, serviceGroup), true); OutputParser outputParser = OutputParserFixture.getOutputParser(Set.of(portScanElement)); @@ -1614,11 +1620,11 @@ void shouldNotCreatePortScanFindingsWhenRawOutputHasNoPortScanMatches() throws E // -- ASSERT -- Awaitility.await() - .atMost(8, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> true); - assertTrue(findingRepository.findAllByInjectId(portScanInject.getId()).isEmpty()); + .until(() -> injectTestHelper.hasInjectStatusTrace(portScanInject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(portScanInject.getId()).isEmpty()); } // Port @@ -1652,9 +1658,9 @@ void shouldCreateFindingForEachPortNumberExtractedFromRawOutput() throws Excepti .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> findingRepository.findAllByInjectId(portInject.getId()).size() >= 2); + .until(() -> injectTestHelper.findFindingsByInjectId(portInject.getId()).size() >= 2); - List portFindings = findingRepository.findAllByInjectId(portInject.getId()); + List portFindings = injectTestHelper.findFindingsByInjectId(portInject.getId()); assertEquals(2, portFindings.size()); assertTrue( portFindings.stream().anyMatch(f -> f.getValue().equals("8080")), @@ -1688,11 +1694,11 @@ void shouldNotCreatePortFindingsWhenRawOutputContainsNoPortMatches() throws Exce // -- ASSERT -- Awaitility.await() - .atMost(8, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> true); - assertTrue(findingRepository.findAllByInjectId(portInject.getId()).isEmpty()); + .until(() -> injectTestHelper.hasInjectStatusTrace(portInject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(portInject.getId()).isEmpty()); } // Text @@ -1721,9 +1727,9 @@ void shouldCreateFindingsForEachTextValueExtractedFromRawOutput() throws Excepti .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> !findingRepository.findAllByInjectId(textInject.getId()).isEmpty()); + .until(() -> !injectTestHelper.findFindingsByInjectId(textInject.getId()).isEmpty()); - List textFindings = findingRepository.findAllByInjectId(textInject.getId()); + List textFindings = injectTestHelper.findFindingsByInjectId(textInject.getId()); assertFalse(textFindings.isEmpty(), "Expected at least one text finding"); textFindings.forEach(f -> assertEquals(ContractOutputType.Text, f.getType())); } @@ -1748,11 +1754,11 @@ void shouldNotCreateTextFindingsWhenRawOutputContainsNoMatches() throws Exceptio // -- ASSERT -- Awaitility.await() - .atMost(8, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> true); - assertTrue(findingRepository.findAllByInjectId(textInject.getId()).isEmpty()); + .until(() -> injectTestHelper.hasInjectStatusTrace(textInject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(textInject.getId()).isEmpty()); } // Number @@ -1781,9 +1787,10 @@ void shouldCreateFindingsForEachNumberExtractedFromRawOutput() throws Exception .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> findingRepository.findAllByInjectId(numberInject.getId()).size() >= 2); + .until(() -> injectTestHelper.findFindingsByInjectId(numberInject.getId()).size() >= 2); - List numberFindings = findingRepository.findAllByInjectId(numberInject.getId()); + List numberFindings = + injectTestHelper.findFindingsByInjectId(numberInject.getId()); assertEquals(2, numberFindings.size()); assertTrue(numberFindings.stream().anyMatch(f -> f.getValue().equals("1234"))); assertTrue(numberFindings.stream().anyMatch(f -> f.getValue().equals("5678"))); @@ -1810,11 +1817,11 @@ void shouldNotCreateNumberFindingsWhenRawOutputContainsNoNumericMatches() throws // -- ASSERT -- Awaitility.await() - .atMost(8, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> true); - assertTrue(findingRepository.findAllByInjectId(numberInject.getId()).isEmpty()); + .until(() -> injectTestHelper.hasInjectStatusTrace(numberInject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(numberInject.getId()).isEmpty()); } // IPv4 @@ -1848,9 +1855,9 @@ void shouldCreateFindingForEachValidIPv4AddressExtractedFromRawOutput() throws E .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> !findingRepository.findAllByInjectId(ipv4Inject.getId()).isEmpty()); + .until(() -> !injectTestHelper.findFindingsByInjectId(ipv4Inject.getId()).isEmpty()); - List ipv4Findings = findingRepository.findAllByInjectId(ipv4Inject.getId()); + List ipv4Findings = injectTestHelper.findFindingsByInjectId(ipv4Inject.getId()); assertFalse(ipv4Findings.isEmpty(), "Expected at least one IPv4 finding"); assertTrue( ipv4Findings.stream().anyMatch(f -> f.getValue().equals("192.168.1.10")), @@ -1886,11 +1893,11 @@ void shouldNotCreateIPv4FindingsWhenRawOutputContainsNoValidIPv4Addresses() thro // -- ASSERT -- Awaitility.await() - .atMost(8, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> true); - assertTrue(findingRepository.findAllByInjectId(ipv4Inject.getId()).isEmpty()); + .until(() -> injectTestHelper.hasInjectStatusTrace(ipv4Inject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(ipv4Inject.getId()).isEmpty()); } // IPv6 @@ -1921,9 +1928,9 @@ void shouldCreateFindingForEachValidIPv6AddressExtractedFromRawOutput() throws E .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> findingRepository.findAllByInjectId(ipv6Inject.getId()).size() >= 2); + .until(() -> injectTestHelper.findFindingsByInjectId(ipv6Inject.getId()).size() >= 2); - List ipv6Findings = findingRepository.findAllByInjectId(ipv6Inject.getId()); + List ipv6Findings = injectTestHelper.findFindingsByInjectId(ipv6Inject.getId()); assertEquals(2, ipv6Findings.size()); assertTrue( ipv6Findings.stream().anyMatch(f -> f.getValue().contains("fe80::1b03:a1ff:ccdb:b464")), @@ -1955,11 +1962,11 @@ void shouldNotCreateIPv6FindingsWhenRawOutputContainsNoIPv6Addresses() throws Ex // -- ASSERT -- Awaitility.await() - .atMost(8, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .with() .pollInterval(1, TimeUnit.SECONDS) - .until(() -> true); - assertTrue(findingRepository.findAllByInjectId(ipv6Inject.getId()).isEmpty()); + .until(() -> injectTestHelper.hasInjectStatusTrace(ipv6Inject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(ipv6Inject.getId()).isEmpty()); } } } diff --git a/openaev-api/src/test/java/io/openaev/utils/helpers/InjectTestHelper.java b/openaev-api/src/test/java/io/openaev/utils/helpers/InjectTestHelper.java index 57a1098a8d4..3859e02db13 100644 --- a/openaev-api/src/test/java/io/openaev/utils/helpers/InjectTestHelper.java +++ b/openaev-api/src/test/java/io/openaev/utils/helpers/InjectTestHelper.java @@ -4,6 +4,7 @@ import io.openaev.database.repository.*; import io.openaev.utils.fixtures.*; import io.openaev.utils.fixtures.composers.*; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; @@ -97,4 +98,28 @@ public Finding forceSaveFinding(Finding finding) { public Asset forceSaveAsset(Asset asset) { return assetRepository.save(asset); } + + /** + * Queries findings for a given inject ID in a new independent transaction, so that findings + * committed by async processing threads are visible even when called from within an outer + * {@code @Transactional} test method. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List findFindingsByInjectId(String injectId) { + return findingRepository.findAllByInjectId(injectId); + } + + /** + * Returns true if the inject has at least one execution trace, confirming async processing + * completed. Runs in a new independent transaction so results committed by async threads are + * visible from within an outer {@code @Transactional} test. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean hasInjectStatusTrace(String injectId) { + return injectRepository + .findById(injectId) + .flatMap(Inject::getStatus) + .filter(s -> !s.getTraces().isEmpty()) + .isPresent(); + } } From 7758c241f1d0bab622f9f05960e9f0f7161efece Mon Sep 17 00:00:00 2001 From: savacano28 Date: Thu, 5 Mar 2026 13:13:59 +0100 Subject: [PATCH 07/21] [backend] feat: review feedback --- .../output_processor/OutputProcessorFactory.java | 2 +- .../service/InjectExpectationServiceTest.java | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java index 2684a82fd9f..3c4deb01ac4 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java @@ -36,7 +36,7 @@ public OutputProcessorFactory(List handlers) { * Retrieves the {@link OutputProcessor} for the given output type. * * @param type the contract output type - * @return the corresponding OutputProcessor + * @return an optional of the corresponding OutputProcessor * @throws IllegalArgumentException if no processor is found for the given type */ public Optional getProcessor(ContractOutputType type) { diff --git a/openaev-api/src/test/java/io/openaev/service/InjectExpectationServiceTest.java b/openaev-api/src/test/java/io/openaev/service/InjectExpectationServiceTest.java index 5f5bceb161e..4faff2c15d0 100644 --- a/openaev-api/src/test/java/io/openaev/service/InjectExpectationServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/service/InjectExpectationServiceTest.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; @@ -91,6 +92,7 @@ private void verifySetResultExpectationVulnerableCalledOnce( } @Test + @DisplayName("Should return all prevention expectations when none expired") void shouldReturnAllPreventionExpectationsWhenNoneExpired() { InjectExpectation expectation1 = InjectExpectationFixture.createPreventionInjectExpectation(inject, null); @@ -109,6 +111,7 @@ void shouldReturnAllPreventionExpectationsWhenNoneExpired() { } @Test + @DisplayName("Should return all detection expectations when none expired") void shouldReturnAllDetectionExpectationsWhenNoneExpired() { InjectExpectation expectation1 = InjectExpectationFixture.createDetectionInjectExpectation(inject, null); @@ -127,6 +130,7 @@ void shouldReturnAllDetectionExpectationsWhenNoneExpired() { } @Test + @DisplayName("Should return all manual expectations when none expired") void shouldReturnAllManualExpectationsWhenNoneExpired() { InjectExpectation expectation1 = InjectExpectationFixture.createManualInjectExpectation(null, inject); @@ -145,6 +149,7 @@ void shouldReturnAllManualExpectationsWhenNoneExpired() { } @Test + @DisplayName("Should set not vulnerable when no output parsers") void shouldSetNotVulnerableWhenNoOutputParsers() throws JsonProcessingException { try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { setupInjectWithOutputParser(null); @@ -158,6 +163,7 @@ void shouldSetNotVulnerableWhenNoOutputParsers() throws JsonProcessingException } @Test + @DisplayName("Should set not vulnerable when structured output is empty") void shouldSetNotVulnerableWhenEmptyStructuredOutput() { try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { setupVulnerabilityExpectation(); @@ -170,6 +176,7 @@ void shouldSetNotVulnerableWhenEmptyStructuredOutput() { } @Test + @DisplayName("Should set not vulnerable when structured output has no CVE type") void shouldSetNotVulnerableWhenNoCveType() throws JsonProcessingException { ObjectNode structuredOutput = mapper.createObjectNode(); structuredOutput @@ -193,6 +200,7 @@ void shouldSetNotVulnerableWhenNoCveType() throws JsonProcessingException { } @Test + @DisplayName("Should set vulnerable when structured output has CVE type and CVE data") void shouldSetVulnerableWhenHasCveTypeAndCveData() { ObjectNode structuredOutput = mapper.createObjectNode(); structuredOutput @@ -218,6 +226,7 @@ void shouldSetVulnerableWhenHasCveTypeAndCveData() { } @Test + @DisplayName("Should set not vulnerable when structured output is an empty array") void shouldSetNotVulnerableWhenStructuredOutputIsEmptyArray() { // isArray()=true but size()=0 -> not vulnerable ArrayNode structuredOutput = mapper.createArrayNode(); @@ -233,6 +242,7 @@ void shouldSetNotVulnerableWhenStructuredOutputIsEmptyArray() { } @Test + @DisplayName("Should set vulnerable when structured output is a non-empty array") void shouldSetVulnerableWhenStructuredOutputIsNonEmptyArray() { // isArray()=true and size()>0 -> vulnerable ArrayNode structuredOutput = mapper.createArrayNode(); @@ -249,6 +259,7 @@ void shouldSetVulnerableWhenStructuredOutputIsNonEmptyArray() { } @Test + @DisplayName("Should do nothing when no vulnerability expectations match the agent") void shouldDoNothingWhenNoVulnerabilityExpectationsForAgent() { // Expectation belongs to a different agent -> filtered out -> early return Agent otherAgent = AgentFixture.createDefaultAgentService(); @@ -268,6 +279,7 @@ void shouldDoNothingWhenNoVulnerabilityExpectationsForAgent() { } @Test + @DisplayName("Should do nothing when expectations are not of vulnerability type") void shouldDoNothingWhenExpectationsAreNotVulnerabilityType() { // Only non-VULNERABILITY expectations -> filtered out -> early return InjectExpectation prevention = @@ -287,6 +299,7 @@ void shouldDoNothingWhenExpectationsAreNotVulnerabilityType() { } @Test + @DisplayName("Should do nothing when expectation has a null agent") void shouldDoNothingWhenExpectationHasNullAgent() { // exp.getAgent() == null -> filtered out -> early return InjectExpectation expectationWithNullAgent = createVulnerabilityInjectExpectation(inject, null); @@ -303,6 +316,7 @@ void shouldDoNothingWhenExpectationHasNullAgent() { } @Test + @DisplayName("Should do nothing when inject has no expectations") void shouldDoNothingWhenInjectHasNoExpectations() { inject.setExpectations(List.of()); @@ -317,6 +331,7 @@ void shouldDoNothingWhenInjectHasNoExpectations() { } @Test + @DisplayName("Should save all expectations after processing") void shouldSaveAllExpectationsAfterProcessing() { setupVulnerabilityExpectation(); @@ -329,6 +344,7 @@ void shouldSaveAllExpectationsAfterProcessing() { } @Test + @DisplayName("Should call update for each vulnerability expectation") void shouldCallUpdateForEachVulnerabilityExpectation() { // Two vulnerability expectations for the same agent InjectExpectation exp1 = createVulnerabilityInjectExpectation(inject, agent); From 5b5b92954a126d1491035faf0abf385920fef552 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Thu, 5 Mar 2026 13:18:53 +0100 Subject: [PATCH 08/21] [backend] feat: review feedback --- .../io/openaev/rest/inject/InjectApiTest.java | 81 +++++++++---------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java index 3e864f8161a..a3dfeabc51b 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java @@ -965,6 +965,45 @@ private void performCallbackRequest(String agentId, String injectId, InjectExecu .getContentAsString(); } + /** + * Builds a pending inject backed by a Command payload that carries the given output parser, and + * returns {@code [inject, agentId]}. + */ + private Object[] buildInjectWithOutputParser(OutputParser outputParser) throws Exception { + Domain domain = injectTestHelper.forceSaveDomain(DomainFixture.getRandomDomain()); + Command command = + PayloadFixture.createCommand( + "bash", "echo test", null, null, new HashSet<>(Set.of(domain))); + command.setOutputParsers(Set.of(outputParser)); + Payload payloadSaved = injectTestHelper.forceSavePayload(command); + + Injector injector = InjectorFixture.createDefaultPayloadInjector(); + injectTestHelper.forceSaveInjector(injector); + + InjectorContract injectorContract = + InjectorContractFixture.createPayloadInjectorContractWithFieldsContent( + injector, payloadSaved, List.of()); + injectorContract.setContent(injectorContract.getConvertedContent().toString()); + InjectorContract injectorContractSaved = + injectTestHelper.forceSaveInjectorContract(injectorContract); + + Inject inject = getPendingInjectWithAssets(); + inject.setInjectorContract(injectorContractSaved); + injectTestHelper.forceSaveInject(inject); + + String agentId = ((Endpoint) inject.getAssets().getFirst()).getAgents().getFirst().getId(); + return new Object[] {inject, agentId}; + } + + /** Wraps stdout content in the expected JSON envelope used by the implant callback. */ + private InjectExecutionInput buildStdoutInput(String stdoutContent) { + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("{\"stdout\":\"" + stdoutContent + "\"}"); + input.setAction(InjectExecutionAction.command_execution); + input.setStatus("SUCCESS"); + return input; + } + @Nested @DisplayName("Action Handling:") @KeepRabbit @@ -1362,45 +1401,6 @@ void given_targetedAsset_should_linkFindingToIt() throws Exception { assertEquals(endpointSaved.getId(), findings.getLast().getAssets().getFirst().getId()); } - /** - * Builds a pending inject backed by a Command payload that carries the given output parser, - * and returns {@code [inject, agentId]}. - */ - private Object[] buildInjectWithOutputParser(OutputParser outputParser) throws Exception { - Domain domain = injectTestHelper.forceSaveDomain(DomainFixture.getRandomDomain()); - Command command = - PayloadFixture.createCommand( - "bash", "echo test", null, null, new HashSet<>(Set.of(domain))); - command.setOutputParsers(Set.of(outputParser)); - Payload payloadSaved = injectTestHelper.forceSavePayload(command); - - Injector injector = InjectorFixture.createDefaultPayloadInjector(); - injectTestHelper.forceSaveInjector(injector); - - InjectorContract injectorContract = - InjectorContractFixture.createPayloadInjectorContractWithFieldsContent( - injector, payloadSaved, List.of()); - injectorContract.setContent(injectorContract.getConvertedContent().toString()); - InjectorContract injectorContractSaved = - injectTestHelper.forceSaveInjectorContract(injectorContract); - - Inject inject = getPendingInjectWithAssets(); - inject.setInjectorContract(injectorContractSaved); - injectTestHelper.forceSaveInject(inject); - - String agentId = ((Endpoint) inject.getAssets().getFirst()).getAgents().getFirst().getId(); - return new Object[] {inject, agentId}; - } - - /** Wraps stdout content in the expected JSON envelope used by the implant callback. */ - private InjectExecutionInput buildStdoutInput(String stdoutContent) { - InjectExecutionInput input = new InjectExecutionInput(); - input.setMessage("{\"stdout\":\"" + stdoutContent + "\"}"); - input.setAction(InjectExecutionAction.command_execution); - input.setStatus("SUCCESS"); - return input; - } - // CVE @Test @@ -1413,7 +1413,6 @@ void shouldCreateFindingsForEachCveExtractedFromRawOutput() throws Exception { Inject cveInject = (Inject) setup[0]; String agentId = (String) setup[1]; - // Build message directly, same format as given_targetedAsset_should_linkFindingToIt InjectExecutionInput input = new InjectExecutionInput(); input.setMessage( "{\"stdout\":\"[CVE-2025-25241] [http] [critical] http://192.168.1.10/\\n" @@ -1885,7 +1884,7 @@ void shouldNotCreateIPv4FindingsWhenRawOutputContainsNoValidIPv4Addresses() thro Inject ipv4Inject = (Inject) setup[0]; String agentId = (String) setup[1]; - // 999.x.x.x is not a valid IPv4 — the processor's validate() rejects it + // 999.x.x.x is not a valid IPv4, the processor's validate() rejects it InjectExecutionInput input = buildStdoutInput("host 999.999.999.999 is unknown"); // -- EXECUTE -- From 5e514622b6435e5e38480c68f44e9014ae01d6d8 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Mon, 16 Feb 2026 16:12:56 +0100 Subject: [PATCH 09/21] [backend] feat: add asset processor --- .../AbstractOutputProcessor.java | 53 +-- .../AssetOutputProcessor.java | 161 ++++++- .../output_processor/CVEOutputProcessor.java | 23 +- .../CredentialsOutputProcessor.java | 25 +- .../FindingCapableOutputProcessor.java | 82 ++++ .../output_processor/IPv4OutputProcessor.java | 25 +- .../output_processor/IPv6OutputProcessor.java | 25 +- .../NumberOutputProcessor.java | 25 +- .../output_processor/OutputProcessor.java | 3 - .../OutputProcessorFactory.java | 2 +- .../output_processor/PortOutputProcessor.java | 25 +- .../PortScanOutputProcessor.java | 25 +- .../output_processor/TextOutputProcessor.java | 25 +- .../rest/asset/endpoint/EndpointApi.java | 51 +-- .../AbstractExecutionProcessingHandler.java | 43 ++ .../AgentExecutionProcessingHandler.java | 40 +- .../InjectorExecutionProcessingHandler.java | 40 +- .../io/openaev/service/EndpointService.java | 79 ++++ .../AbstractOutputProcessorTest.java | 10 +- .../AssetOutputProcessorTest.java | 255 +++++++++++ .../IPv4OutputProcessorTest.java | 2 +- .../IPv6OutputProcessorTest.java | 2 +- .../NumberOutputProcessorTest.java | 2 +- .../PortOutputProcessorTest.java | 2 +- .../TextOutputProcessorTest.java | 2 +- .../io/openaev/rest/inject/InjectApiTest.java | 425 +++++++++++++++++- .../AgentExecutionProcessingHandlerTest.java | 68 ++- .../inject/service/InjectServiceTest.java | 11 +- ...njectorExecutionProcessingHandlerTest.java | 116 ++++- .../io/openaev/database/model/Endpoint.java | 34 +- 30 files changed, 1311 insertions(+), 370 deletions(-) create mode 100644 openaev-api/src/main/java/io/openaev/output_processor/FindingCapableOutputProcessor.java create mode 100644 openaev-api/src/main/java/io/openaev/rest/inject/service/AbstractExecutionProcessingHandler.java create mode 100644 openaev-api/src/test/java/io/openaev/output_processor/AssetOutputProcessorTest.java diff --git a/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessor.java index 138422dfde2..f898837a82d 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/AbstractOutputProcessor.java @@ -7,28 +7,22 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import lombok.extern.slf4j.Slf4j; /** Abstract base class providing common functionality for structured output processor handlers. */ -@Slf4j public abstract class AbstractOutputProcessor implements OutputProcessor { protected final ContractOutputType type; protected final ContractOutputTechnicalType technicalType; protected final List fields; - protected final boolean isFindingCompatible; protected AbstractOutputProcessor( ContractOutputType type, ContractOutputTechnicalType technicalType, - List fields, - boolean isFindingCompatible) { + List fields) { this.type = type; this.technicalType = technicalType; this.fields = fields; - this.isFindingCompatible = isFindingCompatible; } @Override @@ -46,56 +40,13 @@ public List getFields() { return fields; } - @Override - public boolean isFindingCompatible() { - return isFindingCompatible; - } - @Override public boolean validate(JsonNode jsonNode) { return jsonNode != null; } - // FINDING METHODS - // Override these in handlers that support findings - - /** - * Convert JSON node to finding value string. Override this method if handler supports findings. - * Default returns empty string with warning log. - */ - public String toFindingValue(JsonNode jsonNode) { - log.debug("Handler {} does not implement toFindingValue, returning empty string", type); - return ""; - } - - /** - * Extract asset IDs from JSON node for finding linking. Override to provide custom logic, default - * returns empty list. - */ - public List toFindingAssets(JsonNode jsonNode) { - log.debug("Handler {} does not implement toFindingAssets, returning an empty list", type); - return Collections.emptyList(); - } - - /** - * Extract user IDs from JSON node for finding linking. Override to provide custom logic, default - * returns empty list. - */ - public List toFindingUsers(JsonNode jsonNode) { - log.debug("Handler {} does not implement toFindingUsers, returning an empty list", type); - return Collections.emptyList(); - } - - /** - * Extract team IDs from JSON node for finding linking. Override to provide custom logic, default - * returns empty list. - */ - public List toFindingTeams(JsonNode jsonNode) { - log.debug("Handler {} does not implement toFindingTeams, returning an empty list", type); - return Collections.emptyList(); - } - // UTILITY methods + /** * Builds a string representation from a JSON node. * diff --git a/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessor.java index 735d756820b..5ac2e781211 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/AssetOutputProcessor.java @@ -1,27 +1,170 @@ package io.openaev.output_processor; +import static io.openaev.database.model.AssetType.Values.ENDPOINT_TYPE; +import static io.openaev.database.model.AssetType.Values.SECURITY_PLATFORM_TYPE; + import com.fasterxml.jackson.databind.JsonNode; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; +import io.openaev.database.model.*; +import io.openaev.rest.asset.endpoint.form.EndpointInput; import io.openaev.rest.inject.service.ContractOutputContext; import io.openaev.rest.inject.service.ExecutionProcessingContext; -import java.util.List; +import io.openaev.rest.tag.TagService; +import io.openaev.service.EndpointService; +import java.util.*; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +@Slf4j @Component public class AssetOutputProcessor extends AbstractOutputProcessor { - public AssetOutputProcessor() { - super(ContractOutputType.Asset, ContractOutputTechnicalType.Object, List.of(), false); + private static final String NAME = "name"; + private static final String TYPE = "type"; + private static final String DESCRIPTION = "description"; + private static final String EXTERNAL_REFERENCE = "external_reference"; + private static final String TAGS = "tags"; + private static final String EXTENDED_ATTRIBUTES = "extended_attributes"; + private static final String PLATFORM = "platform"; + private static final String ARCH = "arch"; + private static final String IP_ADDRESSES = "ip_addresses"; + private static final String HOSTNAME = "hostname"; + private static final String MAC_ADDRESSES = "mac_addresses"; + private static final String END_OF_LIFE = "end_of_life"; + private final EndpointService endpointService; + private final TagService tagService; + + public AssetOutputProcessor(EndpointService endpointService, TagService tagService) { + super( + ContractOutputType.Asset, + ContractOutputTechnicalType.Object, + List.of( + new ContractOutputField(NAME, ContractOutputTechnicalType.Text, true), + new ContractOutputField(TYPE, ContractOutputTechnicalType.Text, true), + new ContractOutputField(DESCRIPTION, ContractOutputTechnicalType.Text, false), + new ContractOutputField(EXTERNAL_REFERENCE, ContractOutputTechnicalType.Text, true), + new ContractOutputField(TAGS, ContractOutputTechnicalType.Text, true), + new ContractOutputField( + EXTENDED_ATTRIBUTES, ContractOutputTechnicalType.Object, true))); + this.endpointService = endpointService; + this.tagService = tagService; + } + + @Override + public boolean validate(JsonNode jsonNode) { + if (!jsonNode.hasNonNull(NAME) + || !jsonNode.hasNonNull(TYPE) + || !jsonNode.hasNonNull(EXTERNAL_REFERENCE)) { + return false; + } + String type = jsonNode.path(TYPE).asText(); + return switch (type) { + case ENDPOINT_TYPE -> validateEndpoint(jsonNode.path(EXTENDED_ATTRIBUTES)); + case SECURITY_PLATFORM_TYPE -> true; + default -> false; + }; + } + + private boolean validateEndpoint(JsonNode extended) { + return extended.hasNonNull(PLATFORM) && extended.hasNonNull(ARCH); } @Override public void process( - ExecutionProcessingContext ctx, + ExecutionProcessingContext executionContext, ContractOutputContext contractOutputContext, JsonNode structuredOutputNode) { - // The current implementation of the AssetOutputProcessor does not generate findings, but it can - // be extended in the future to do so if needed. - // For now, it simply validates the input and does not perform any additional processing. + if (!structuredOutputNode.isArray()) { + log.warn( + "Expected an array node for asset output, got: {}", structuredOutputNode.getNodeType()); + return; + } + for (JsonNode assetNode : structuredOutputNode) { + if (!validate(assetNode)) { + log.warn("Invalid asset node, skipping: {}", assetNode); + continue; + } + String type = assetNode.path(TYPE).asText(); + switch (type) { + case ENDPOINT_TYPE -> processEndpoint(executionContext, assetNode); + case SECURITY_PLATFORM_TYPE -> log.info("SecurityPlatform not yet supported, skipping"); + } + } + } + + /** + * Processes a single endpoint asset node. Creates the endpoint if it does not already exist. + * + * @param assetNode The JSON node representing the endpoint asset + */ + private void processEndpoint(ExecutionProcessingContext executionContext, JsonNode assetNode) { + EndpointInput input = buildEndpointInput(assetNode); + Optional existing = endpointService.findExistingEndpoint(input); + if (existing.isPresent()) { + log.info("Endpoint already exists: {} (id={})", input.getName(), existing.get().getId()); + return; + } + Endpoint created = endpointService.createEndpoint(input); + log.info("Created endpoint: {} (id={})", input.getName(), created.getId()); + } + + /** + * Builds an EndpointInput object from a JSON asset node, extracting all relevant fields. + * + * @param assetNode The JSON node representing the endpoint asset + * @return EndpointInput populated with asset data + */ + private EndpointInput buildEndpointInput(JsonNode assetNode) { + JsonNode extended = assetNode.path(EXTENDED_ATTRIBUTES); + + EndpointInput input = new EndpointInput(); + + // AssetInput fields + input.setName(assetNode.path(NAME).asText()); + input.setDescription(assetNode.path(DESCRIPTION).asText()); + String external = assetNode.path(EXTERNAL_REFERENCE).asText(); + input.setExternalReference(external.isEmpty() ? null : external); + + // Tags + Set tagNames = new HashSet<>(); + for (JsonNode tag : assetNode.path(TAGS)) { + tagNames.add(tag.asText()); + } + input.setTagIds( + tagService.findOrCreateTagsFromNames(tagNames).stream().map(Tag::getId).toList()); + + // Platform and arch + input.setPlatform(Endpoint.PLATFORM_TYPE.fromString(extended.path(PLATFORM).asText())); + input.setArch(Endpoint.PLATFORM_ARCH.fromString(extended.path(ARCH).asText())); + + // IPs + JsonNode ipNode = extended.path(IP_ADDRESSES); + input.setIps(toStringArray(ipNode)); + + // Hostname + String hostname = extended.path(HOSTNAME).asText(); + input.setHostname(hostname); + + // MAC addresses + JsonNode macNode = extended.path(MAC_ADDRESSES); + input.setMacAddresses(toStringArray(macNode)); + + // EoL + input.setEol(extended.path(END_OF_LIFE).asBoolean(false)); + + return input; + } + + private String[] toStringArray(JsonNode node) { + if (node.isArray()) { + List values = new ArrayList<>(); + node.forEach(n -> values.add(n.asText())); + return values.toArray(new String[0]); + } + + if (!node.isMissingNode() && !node.isNull()) { + return new String[] {node.asText()}; + } + + return new String[0]; } } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessor.java index a0b0102ab26..27ab71fd2c2 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/CVEOutputProcessor.java @@ -5,7 +5,6 @@ import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; import io.openaev.rest.finding.FindingService; -import io.openaev.rest.inject.service.ContractOutputContext; import io.openaev.rest.inject.service.ExecutionProcessingContext; import io.openaev.service.InjectExpectationService; import java.util.ArrayList; @@ -14,14 +13,13 @@ import org.springframework.stereotype.Component; @Component -public class CVEOutputProcessor extends AbstractOutputProcessor { +public class CVEOutputProcessor extends FindingCapableOutputProcessor { private static final String ASSET_ID = "asset_id"; private static final String ID = "id"; private static final String HOST = "host"; private static final String SEVERITY = "severity"; - private final FindingService findingService; private final InjectExpectationService injectExpectationService; public CVEOutputProcessor( @@ -34,8 +32,7 @@ public CVEOutputProcessor( new ContractOutputField(ID, ContractOutputTechnicalType.Text, true), new ContractOutputField(HOST, ContractOutputTechnicalType.Text, true), new ContractOutputField(SEVERITY, ContractOutputTechnicalType.Text, true)), - true); - this.findingService = findingService; + findingService); this.injectExpectationService = injectExpectationService; } @@ -44,20 +41,10 @@ public boolean validate(JsonNode jsonNode) { return jsonNode.hasNonNull(ID) && jsonNode.hasNonNull(HOST) && jsonNode.hasNonNull(SEVERITY); } + /** Matches vulnerability expectations after findings are generated. */ @Override - public void process( - ExecutionProcessingContext executionContext, - ContractOutputContext contractOutputContext, - JsonNode structuredOutputNode) { - findingService.generateFindings( - executionContext, - contractOutputContext, - structuredOutputNode, - this::validate, - this::toFindingValue, - this::toFindingAssets, - this::toFindingTeams, - this::toFindingUsers); + protected void afterFindings( + ExecutionProcessingContext executionContext, JsonNode structuredOutputNode) { injectExpectationService.matchesVulnerabilityExpectations( executionContext, structuredOutputNode); } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessor.java index 48a1565e1f8..90aebac604b 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/CredentialsOutputProcessor.java @@ -5,19 +5,15 @@ import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; import io.openaev.rest.finding.FindingService; -import io.openaev.rest.inject.service.ContractOutputContext; -import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.springframework.stereotype.Component; @Component -public class CredentialsOutputProcessor extends AbstractOutputProcessor { +public class CredentialsOutputProcessor extends FindingCapableOutputProcessor { private static final String USERNAME = "username"; private static final String PASSWORD = "password"; - private final FindingService findingService; - public CredentialsOutputProcessor(FindingService findingService) { super( ContractOutputType.Credentials, @@ -25,8 +21,7 @@ public CredentialsOutputProcessor(FindingService findingService) { List.of( new ContractOutputField(USERNAME, ContractOutputTechnicalType.Text, true), new ContractOutputField(PASSWORD, ContractOutputTechnicalType.Text, true)), - true); - this.findingService = findingService; + findingService); } @Override @@ -34,22 +29,6 @@ public boolean validate(JsonNode jsonNode) { return jsonNode.hasNonNull(USERNAME) && jsonNode.hasNonNull(PASSWORD); } - @Override - public void process( - ExecutionProcessingContext executionContext, - ContractOutputContext contractOutputContext, - JsonNode structuredOutputNode) { - findingService.generateFindings( - executionContext, - contractOutputContext, - structuredOutputNode, - this::validate, - this::toFindingValue, - this::toFindingAssets, - this::toFindingTeams, - this::toFindingUsers); - } - @Override public String toFindingValue(JsonNode jsonNode) { String username = buildString(jsonNode, USERNAME); diff --git a/openaev-api/src/main/java/io/openaev/output_processor/FindingCapableOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/FindingCapableOutputProcessor.java new file mode 100644 index 00000000000..f3ef3d3f73c --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/FindingCapableOutputProcessor.java @@ -0,0 +1,82 @@ +package io.openaev.output_processor; + +import com.fasterxml.jackson.databind.JsonNode; +import io.openaev.database.model.ContractOutputField; +import io.openaev.database.model.ContractOutputTechnicalType; +import io.openaev.database.model.ContractOutputType; +import io.openaev.rest.finding.FindingService; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; +import java.util.Collections; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** Abstract base class for output processors that are capable of generating findings. */ +@Slf4j +public abstract class FindingCapableOutputProcessor extends AbstractOutputProcessor { + + protected final FindingService findingService; + + protected FindingCapableOutputProcessor( + ContractOutputType type, + ContractOutputTechnicalType technicalType, + List fields, + FindingService findingService) { + super(type, technicalType, fields); + this.findingService = findingService; + } + + /** + * Processes the structured output by generating findings via {@link FindingService}, then calls + * {@link #afterFindings} to allow subclasses to perform additional steps (e.g. expectation + * matching) without overriding this method entirely. + */ + @Override + public final void process( + ExecutionProcessingContext executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + findingService.generateFindings( + executionContext, + contractOutputContext, + structuredOutputNode, + this::validate, + this::toFindingValue, + this::toFindingAssets, + this::toFindingTeams, + this::toFindingUsers); + afterFindings(executionContext, structuredOutputNode); + } + + /** + * Hook called after findings are generated. Override to perform additional processing such as + * expectation matching without needing to override {@link #process} entirely. + * + *

Default implementation does nothing. + */ + protected void afterFindings( + ExecutionProcessingContext executionContext, JsonNode structuredOutputNode) { + // no-op by default + } + + /** Convert JSON node to finding value string. Subclasses must provide a meaningful value. */ + public abstract String toFindingValue(JsonNode jsonNode); + + /** Extract asset IDs from JSON node. Default returns empty list. */ + public List toFindingAssets(JsonNode jsonNode) { + log.debug("Processor {} does not implement toFindingAssets, returning empty list", type); + return Collections.emptyList(); + } + + /** Extract user IDs from JSON node. Default returns empty list. */ + public List toFindingUsers(JsonNode jsonNode) { + log.debug("Processor {} does not implement toFindingUsers, returning empty list", type); + return Collections.emptyList(); + } + + /** Extract team IDs from JSON node. Default returns empty list. */ + public List toFindingTeams(JsonNode jsonNode) { + log.debug("Processor {} does not implement toFindingTeams, returning empty list", type); + return Collections.emptyList(); + } +} diff --git a/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessor.java index ecaef7b5c98..fce8459d9d3 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/IPv4OutputProcessor.java @@ -4,22 +4,17 @@ import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; import io.openaev.rest.finding.FindingService; -import io.openaev.rest.inject.service.ContractOutputContext; -import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.apache.commons.validator.routines.InetAddressValidator; import org.springframework.stereotype.Component; @Component -public class IPv4OutputProcessor extends AbstractOutputProcessor { +public class IPv4OutputProcessor extends FindingCapableOutputProcessor { private static final InetAddressValidator VALIDATOR = InetAddressValidator.getInstance(); - private final FindingService findingService; - public IPv4OutputProcessor(FindingService findingService) { - super(ContractOutputType.IPv4, ContractOutputTechnicalType.Text, List.of(), true); - this.findingService = findingService; + super(ContractOutputType.IPv4, ContractOutputTechnicalType.Text, List.of(), findingService); } @Override @@ -27,22 +22,6 @@ public boolean validate(JsonNode jsonNode) { return VALIDATOR.isValidInet4Address(jsonNode.asText()); } - @Override - public void process( - ExecutionProcessingContext executionContext, - ContractOutputContext contractOutputContext, - JsonNode structuredOutputNode) { - findingService.generateFindings( - executionContext, - contractOutputContext, - structuredOutputNode, - this::validate, - this::toFindingValue, - this::toFindingAssets, - this::toFindingTeams, - this::toFindingUsers); - } - @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode); diff --git a/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessor.java index be765730406..31d6cec8a74 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/IPv6OutputProcessor.java @@ -4,22 +4,17 @@ import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; import io.openaev.rest.finding.FindingService; -import io.openaev.rest.inject.service.ContractOutputContext; -import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.apache.commons.validator.routines.InetAddressValidator; import org.springframework.stereotype.Component; @Component -public class IPv6OutputProcessor extends AbstractOutputProcessor { +public class IPv6OutputProcessor extends FindingCapableOutputProcessor { private static final InetAddressValidator VALIDATOR = InetAddressValidator.getInstance(); - private final FindingService findingService; - public IPv6OutputProcessor(FindingService findingService) { - super(ContractOutputType.IPv6, ContractOutputTechnicalType.Text, List.of(), true); - this.findingService = findingService; + super(ContractOutputType.IPv6, ContractOutputTechnicalType.Text, List.of(), findingService); } @Override @@ -27,22 +22,6 @@ public boolean validate(JsonNode jsonNode) { return VALIDATOR.isValidInet6Address(jsonNode.asText()); } - @Override - public void process( - ExecutionProcessingContext executionContext, - ContractOutputContext contractOutputContext, - JsonNode structuredOutputNode) { - findingService.generateFindings( - executionContext, - contractOutputContext, - structuredOutputNode, - this::validate, - this::toFindingValue, - this::toFindingAssets, - this::toFindingTeams, - this::toFindingUsers); - } - @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode); diff --git a/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessor.java index 810e14dc010..e0c6a65bd60 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/NumberOutputProcessor.java @@ -4,39 +4,18 @@ import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; import io.openaev.rest.finding.FindingService; -import io.openaev.rest.inject.service.ContractOutputContext; -import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.springframework.stereotype.Component; @Component -public class NumberOutputProcessor extends AbstractOutputProcessor { - - private final FindingService findingService; +public class NumberOutputProcessor extends FindingCapableOutputProcessor { public NumberOutputProcessor(FindingService findingService) { - super(ContractOutputType.Number, ContractOutputTechnicalType.Number, List.of(), true); - this.findingService = findingService; + super(ContractOutputType.Number, ContractOutputTechnicalType.Number, List.of(), findingService); } @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode); } - - @Override - public void process( - ExecutionProcessingContext executionContext, - ContractOutputContext contractOutputContext, - JsonNode structuredOutputNode) { - findingService.generateFindings( - executionContext, - contractOutputContext, - structuredOutputNode, - this::validate, - this::toFindingValue, - this::toFindingAssets, - this::toFindingTeams, - this::toFindingUsers); - } } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessor.java index 01bdb5cb471..3e45dce21f5 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessor.java @@ -24,9 +24,6 @@ public interface OutputProcessor { /** Get fields */ List getFields(); - /** Is finding compatible */ - boolean isFindingCompatible(); - /** Validate that the JSON node is correctly formatted for this type */ boolean validate(JsonNode jsonNode); diff --git a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java index 3c4deb01ac4..2684a82fd9f 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/OutputProcessorFactory.java @@ -36,7 +36,7 @@ public OutputProcessorFactory(List handlers) { * Retrieves the {@link OutputProcessor} for the given output type. * * @param type the contract output type - * @return an optional of the corresponding OutputProcessor + * @return the corresponding OutputProcessor * @throws IllegalArgumentException if no processor is found for the given type */ public Optional getProcessor(ContractOutputType type) { diff --git a/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessor.java index 1cb467e4c7b..e88b1e95dfb 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/PortOutputProcessor.java @@ -4,39 +4,18 @@ import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; import io.openaev.rest.finding.FindingService; -import io.openaev.rest.inject.service.ContractOutputContext; -import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.springframework.stereotype.Component; @Component -public class PortOutputProcessor extends AbstractOutputProcessor { - - private final FindingService findingService; +public class PortOutputProcessor extends FindingCapableOutputProcessor { public PortOutputProcessor(FindingService findingService) { - super(ContractOutputType.Port, ContractOutputTechnicalType.Number, List.of(), true); - this.findingService = findingService; + super(ContractOutputType.Port, ContractOutputTechnicalType.Number, List.of(), findingService); } @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode); } - - @Override - public void process( - ExecutionProcessingContext executionContext, - ContractOutputContext contractOutputContext, - JsonNode structuredOutputNode) { - findingService.generateFindings( - executionContext, - contractOutputContext, - structuredOutputNode, - this::validate, - this::toFindingValue, - this::toFindingAssets, - this::toFindingTeams, - this::toFindingUsers); - } } diff --git a/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessor.java index c4359e279e8..ee497990e33 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/PortScanOutputProcessor.java @@ -7,22 +7,18 @@ import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; import io.openaev.rest.finding.FindingService; -import io.openaev.rest.inject.service.ContractOutputContext; -import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.Collections; import java.util.List; import org.springframework.stereotype.Component; @Component -public class PortScanOutputProcessor extends AbstractOutputProcessor { +public class PortScanOutputProcessor extends FindingCapableOutputProcessor { private static final String ASSET_ID = "asset_id"; private static final String HOST = "host"; private static final String PORT = "port"; private static final String SERVICE = "service"; - private final FindingService findingService; - public PortScanOutputProcessor(FindingService findingService) { super( ContractOutputType.PortsScan, @@ -32,8 +28,7 @@ public PortScanOutputProcessor(FindingService findingService) { new ContractOutputField(HOST, ContractOutputTechnicalType.Text, true), new ContractOutputField(PORT, ContractOutputTechnicalType.Number, true), new ContractOutputField(SERVICE, ContractOutputTechnicalType.Text, true)), - true); - this.findingService = findingService; + findingService); } @Override @@ -41,22 +36,6 @@ public boolean validate(JsonNode jsonNode) { return jsonNode.hasNonNull(HOST) && jsonNode.hasNonNull(PORT) && jsonNode.hasNonNull(SERVICE); } - @Override - public void process( - ExecutionProcessingContext executionContext, - ContractOutputContext contractOutputContext, - JsonNode structuredOutputNode) { - findingService.generateFindings( - executionContext, - contractOutputContext, - structuredOutputNode, - this::validate, - this::toFindingValue, - this::toFindingAssets, - this::toFindingTeams, - this::toFindingUsers); - } - @Override public String toFindingValue(JsonNode jsonNode) { String host = buildString(jsonNode, HOST); diff --git a/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessor.java index 509f9554f02..a348821a3cf 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/TextOutputProcessor.java @@ -4,39 +4,18 @@ import io.openaev.database.model.ContractOutputTechnicalType; import io.openaev.database.model.ContractOutputType; import io.openaev.rest.finding.FindingService; -import io.openaev.rest.inject.service.ContractOutputContext; -import io.openaev.rest.inject.service.ExecutionProcessingContext; import java.util.List; import org.springframework.stereotype.Component; @Component -public class TextOutputProcessor extends AbstractOutputProcessor { - - private final FindingService findingService; +public class TextOutputProcessor extends FindingCapableOutputProcessor { public TextOutputProcessor(FindingService findingService) { - super(ContractOutputType.Text, ContractOutputTechnicalType.Text, List.of(), true); - this.findingService = findingService; + super(ContractOutputType.Text, ContractOutputTechnicalType.Text, List.of(), findingService); } @Override public String toFindingValue(JsonNode jsonNode) { return buildString(jsonNode); } - - @Override - public void process( - ExecutionProcessingContext executionContext, - ContractOutputContext contractOutputContext, - JsonNode structuredOutputNode) { - findingService.generateFindings( - executionContext, - contractOutputContext, - structuredOutputNode, - this::validate, - this::toFindingValue, - this::toFindingAssets, - this::toFindingTeams, - this::toFindingUsers); - } } diff --git a/openaev-api/src/main/java/io/openaev/rest/asset/endpoint/EndpointApi.java b/openaev-api/src/main/java/io/openaev/rest/asset/endpoint/EndpointApi.java index 1f9f02b1cae..37b640fbdde 100644 --- a/openaev-api/src/main/java/io/openaev/rest/asset/endpoint/EndpointApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/asset/endpoint/EndpointApi.java @@ -1,7 +1,6 @@ package io.openaev.rest.asset.endpoint; import static io.openaev.helper.StreamHelper.fromIterable; -import static io.openaev.helper.StreamHelper.iterableToSet; import io.openaev.aop.LogExecutionTime; import io.openaev.aop.RBAC; @@ -10,7 +9,6 @@ import io.openaev.database.model.AssetAgentJob; import io.openaev.database.model.Endpoint; import io.openaev.database.model.ResourceType; -import io.openaev.database.model.Tag; import io.openaev.database.repository.AssetAgentJobRepository; import io.openaev.database.repository.EndpointRepository; import io.openaev.database.repository.TagRepository; @@ -31,8 +29,6 @@ import jakarta.validation.constraints.NotNull; import java.io.IOException; import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -67,52 +63,7 @@ public Endpoint createEndpoint(@Valid @RequestBody final EndpointInput input) { @RBAC(actionPerformed = Action.CREATE, resourceType = ResourceType.ASSET) @Transactional(rollbackFor = Exception.class) public Endpoint upsertAgentLessEndpoint(@Valid @RequestBody final EndpointInput input) { - Optional endpoint = Optional.empty(); - if (input.getExternalReference() != null) { - endpoint = this.endpointService.findEndpointByExternalReference(input.getExternalReference()); - } - if (endpoint.isEmpty() && input.getIps() != null) { - List endpoints = - this.endpointService.findEndpointByHostnameAndAtLeastOneIp( - input.getHostname(), input.getIps()); - if (!endpoints.isEmpty()) { - endpoint = Optional.of(endpoints.getFirst()); - } - } - if (endpoint.isEmpty() && input.getMacAddresses() != null) { - List endpoints = - this.endpointService.findEndpointByHostnameAndAtLeastOneMacAddress( - input.getHostname(), input.getMacAddresses()); - if (!endpoints.isEmpty()) { - endpoint = Optional.of(endpoints.getFirst()); - } - } - if (endpoint.isPresent()) { - Endpoint endpointToUpdate = endpoint.get(); - // Mandatory fields - endpointToUpdate.setName(input.getName()); - Iterable tags = - Stream.concat( - endpointToUpdate.getTags().stream().map(Tag::getId).toList().stream(), - input.getTagIds().stream()) - .distinct() - .toList(); - endpointToUpdate.setTags(iterableToSet(tagRepository.findAllById(tags))); - endpointToUpdate.setArch(input.getArch()); - endpointToUpdate.setPlatform(input.getPlatform()); - // Optional fields - if (input.getIps() != null) { - endpointToUpdate.setIps(EndpointMapper.setIps(input.getIps())); - } - if (input.getHostname() != null) { - endpointToUpdate.setHostname(input.getHostname()); - } - if (input.getMacAddresses() != null) { - endpointToUpdate.setMacAddresses(input.getMacAddresses()); - } - return this.endpointService.updateEndpoint(endpointToUpdate); - } - return this.endpointService.createEndpoint(input); + return this.endpointService.upsertEndpoint(input); } @PostMapping(ENDPOINT_URI + "/register") diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/AbstractExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/AbstractExecutionProcessingHandler.java new file mode 100644 index 00000000000..7f901c1afa6 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/AbstractExecutionProcessingHandler.java @@ -0,0 +1,43 @@ +package io.openaev.rest.inject.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.openaev.output_processor.OutputProcessorFactory; +import java.util.List; + +public abstract class AbstractExecutionProcessingHandler implements ExecutionProcessingHandler { + + protected final OutputProcessorFactory outputProcessorFactory; + + protected AbstractExecutionProcessingHandler(OutputProcessorFactory outputProcessorFactory) { + this.outputProcessorFactory = outputProcessorFactory; + } + + /** + * Dispatches each contract output context to its corresponding processor, if one exists, using + * values extracted from the given structured output node. + * + *

A contract output is skipped silently when no processor is registered for its type or when + * the key is absent from the structured output. + * + * @param executionContext the current execution context + * @param contractOutputContexts the list of contract output contexts to dispatch + * @param structuredOutput the structured output node to read values from + */ + protected void dispatchToProcessors( + ExecutionProcessingContext executionContext, + List contractOutputContexts, + ObjectNode structuredOutput) { + contractOutputContexts.forEach( + contractOutputCtx -> + outputProcessorFactory + .getProcessor(contractOutputCtx.type()) + .ifPresent( + processor -> { + JsonNode node = structuredOutput.path(contractOutputCtx.key()); + if (!node.isMissingNode()) { + processor.process(executionContext, contractOutputCtx, node); + } + })); + } +} diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java index aa72a866f9d..b9a60c4237a 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java @@ -1,7 +1,6 @@ package io.openaev.rest.inject.service; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.openaev.database.model.ContractOutputElement; import io.openaev.database.model.ExecutionTraceAction; @@ -10,7 +9,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -22,11 +20,15 @@ */ @Slf4j @Component -@RequiredArgsConstructor -public class AgentExecutionProcessingHandler implements ExecutionProcessingHandler { +public class AgentExecutionProcessingHandler extends AbstractExecutionProcessingHandler { private final StructuredOutputUtils structuredOutputUtils; - private final OutputProcessorFactory outputProcessorFactory; + + public AgentExecutionProcessingHandler( + OutputProcessorFactory outputProcessorFactory, StructuredOutputUtils structuredOutputUtils) { + super(outputProcessorFactory); + this.structuredOutputUtils = structuredOutputUtils; + } /** * Determines if this handler supports the given execution context (agent execution). @@ -63,34 +65,22 @@ public Optional processContext(ExecutionProcessingContext executionC outputParsers, executionContext.input().getMessage()) .map( structuredOutput -> { - // Process findings for each compatible output parser - getAllIsFindingCompatibleContractOutputs(outputParsers).stream() - .map(ContractOutputContext::from) - .forEach( - contractOutputCtx -> { - outputProcessorFactory - .getProcessor(contractOutputCtx.type()) - .ifPresent( - processor -> { - JsonNode node = structuredOutput.path(contractOutputCtx.key()); - if (!node.isMissingNode()) { - processor.process(executionContext, contractOutputCtx, node); - } - }); - }); + List contractOutputContexts = + getAllContractOutputs(outputParsers).stream() + .map(ContractOutputContext::from) + .toList(); + dispatchToProcessors(executionContext, contractOutputContexts, structuredOutput); return structuredOutput; }); } /** - * Retrieves all contract output elements from the output parsers that are compatible with - * findings. + * Retrieves all contract output elements from the output parsers. * * @param outputParsers the set of output parsers to inspect - * @return list of finding-compatible contract output elements + * @return list of contract output elements */ - private List getAllIsFindingCompatibleContractOutputs( - Set outputParsers) { + private List getAllContractOutputs(Set outputParsers) { return outputParsers.stream() .flatMap(outputParser -> outputParser.getContractOutputElements().stream()) .filter(ContractOutputElement::isFinding) diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java index b3fd241ac52..3ac77c6aec8 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java @@ -1,7 +1,6 @@ package io.openaev.rest.inject.service; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import io.openaev.database.model.ExecutionTraceAction; @@ -12,7 +11,6 @@ import jakarta.annotation.Resource; import java.util.List; import java.util.Optional; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -24,13 +22,18 @@ */ @Slf4j @Component -@RequiredArgsConstructor -public class InjectorExecutionProcessingHandler implements ExecutionProcessingHandler { +public class InjectorExecutionProcessingHandler extends AbstractExecutionProcessingHandler { @Resource protected ObjectMapper mapper; - private final OutputProcessorFactory outputProcessorFactory; private final InjectorContractContentUtils injectorContractContentUtils; + public InjectorExecutionProcessingHandler( + OutputProcessorFactory outputProcessorFactory, + InjectorContractContentUtils injectorContractContentUtils) { + super(outputProcessorFactory); + this.injectorContractContentUtils = injectorContractContentUtils; + } + /** * Determines if this handler supports the given execution context (injector execution). * @@ -79,40 +82,25 @@ public Optional processContext(ExecutionProcessingContext executionC InjectorContract injectorContract = executionContext.inject().getInjectorContract().orElseThrow(); - List contractOutputElements = - getAllIsFindingCompatibleContractOutputs(injectorContract); - contractOutputElements.stream() - .map(ContractOutputContext::from) - .forEach( - contractOutputCtx -> { - outputProcessorFactory - .getProcessor(contractOutputCtx.type()) - .ifPresent( - processor -> { - JsonNode node = structuredOutput.path(contractOutputCtx.key()); - if (!node.isMissingNode()) { - processor.process(executionContext, contractOutputCtx, node); - } - }); - }); + List contractOutputContexts = + getAllContractOutputs(injectorContract).stream().map(ContractOutputContext::from).toList(); + dispatchToProcessors(executionContext, contractOutputContexts, structuredOutput); return Optional.of(structuredOutput); } /** - * Retrieves all contract output elements from the injector contract that are compatible with - * findings. + * Retrieves all contract output elements from the injector contract. * * @param injectorContract the injector contract to inspect - * @return list of finding-compatible contract output elements + * @return list of contract output elements */ - private List getAllIsFindingCompatibleContractOutputs( + private List getAllContractOutputs( InjectorContract injectorContract) { return injectorContractContentUtils .getContractOutputs(injectorContract.getConvertedContent(), mapper) .stream() - .filter(InjectorContractContentOutputElement::isFindingCompatible) .toList(); } } diff --git a/openaev-api/src/main/java/io/openaev/service/EndpointService.java b/openaev-api/src/main/java/io/openaev/service/EndpointService.java index 6f503e7bcb1..cfb81f7902c 100644 --- a/openaev-api/src/main/java/io/openaev/service/EndpointService.java +++ b/openaev-api/src/main/java/io/openaev/service/EndpointService.java @@ -792,4 +792,83 @@ public List getOptionsByNameLinkedToFindings( .map(i -> new FilterUtilsJpa.Option((String) i[0], (String) i[1])) .toList(); } + + /** + * Creates a new endpoint or updates an existing one based on the provided input. + * + *

If an endpoint matching the input is found (by external reference, hostname + IP, or + * hostname + MAC), it is updated with the new values. Otherwise, a new endpoint is created. + * + * @param input the endpoint input data + * @return the created or updated Endpoint entity + */ + public Endpoint upsertEndpoint(EndpointInput input) { + Optional endpoint = findExistingEndpoint(input); + if (endpoint.isPresent()) { + Endpoint endpointToUpdate = endpoint.get(); + // Mandatory fields + endpointToUpdate.setName(input.getName()); + Iterable tags = + Stream.concat( + endpointToUpdate.getTags().stream().map(Tag::getId).toList().stream(), + input.getTagIds().stream()) + .distinct() + .toList(); + endpointToUpdate.setTags(iterableToSet(tagRepository.findAllById(tags))); + endpointToUpdate.setArch(input.getArch()); + endpointToUpdate.setPlatform(input.getPlatform()); + // Optional fields + if (input.getIps() != null) { + endpointToUpdate.setIps(EndpointMapper.setIps(input.getIps())); + } + if (input.getHostname() != null) { + endpointToUpdate.setHostname(input.getHostname()); + } + if (input.getMacAddresses() != null) { + endpointToUpdate.setMacAddresses(input.getMacAddresses()); + } + return updateEndpoint(endpointToUpdate); + } + return createEndpoint(input); + } + + /** + * Attempts to find an existing endpoint matching the provided input. + * + *

The search is performed in the following order: + * + *

    + *
  1. By external reference + *
  2. By hostname and at least one IP address + *
  3. By hostname and at least one MAC address + *
+ * + * Returns the first match found, or {@code Optional.empty()} if no match exists. + * + * @param input the endpoint input data + * @return an Optional containing the found Endpoint, or empty if none found + */ + public Optional findExistingEndpoint(EndpointInput input) { + // 1. By external reference + if (input.getExternalReference() != null && !input.getExternalReference().isEmpty()) { + Optional found = findEndpointByExternalReference(input.getExternalReference()); + if (found.isPresent()) return found; + } + + // 2. By hostname + at least one IP + if (input.getIps() != null) { + List found = + findEndpointByHostnameAndAtLeastOneIp(input.getHostname(), input.getIps()); + if (!found.isEmpty()) return Optional.of(found.getFirst()); + } + + // 3. By hostname + at least one MAC address + if (input.getMacAddresses() != null) { + List found = + findEndpointByHostnameAndAtLeastOneMacAddress( + input.getHostname(), input.getMacAddresses()); + if (!found.isEmpty()) return Optional.of(found.getFirst()); + } + return Optional.empty(); + } } diff --git a/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java index 28b32872a5b..f15d33f4580 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java @@ -16,7 +16,15 @@ class AbstractOutputProcessorTest { private static class TestOutputProcessor extends AbstractOutputProcessor { TestOutputProcessor() { - super(null, null, Collections.emptyList(), false); + super(null, null, Collections.emptyList()); + } + + @Override + public void process( + ExecutionProcessingContext ctx, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + // No-op for testing purposes } @Override diff --git a/openaev-api/src/test/java/io/openaev/output_processor/AssetOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/AssetOutputProcessorTest.java new file mode 100644 index 00000000000..b125dee402c --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/output_processor/AssetOutputProcessorTest.java @@ -0,0 +1,255 @@ +package io.openaev.output_processor; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.database.model.Endpoint; +import io.openaev.database.model.Inject; +import io.openaev.rest.asset.endpoint.form.EndpointInput; +import io.openaev.rest.inject.service.ContractOutputContext; +import io.openaev.rest.inject.service.ExecutionProcessingContext; +import io.openaev.rest.tag.TagService; +import io.openaev.service.EndpointService; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class AssetOutputProcessorTest { + private final EndpointService endpointService = mock(EndpointService.class); + private final TagService tagService = mock(TagService.class); + private final AssetOutputProcessor processor = + new AssetOutputProcessor(endpointService, tagService); + private final ObjectMapper objectMapper = new ObjectMapper(); + + private ExecutionProcessingContext executionContext; + private ContractOutputContext contractOutputContext; + + @BeforeEach + void setUp() { + executionContext = mock(ExecutionProcessingContext.class); + contractOutputContext = mock(ContractOutputContext.class); + + Inject inject = mock(Inject.class); + when(executionContext.inject()).thenReturn(inject); + when(tagService.findOrCreateTagsFromNames(any())).thenReturn(Set.of()); + } + + @Test + @DisplayName("should return true for valid Endpoint node") + void shouldReturnTrueForValidEndpointNode() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + {"name":"Asset A","type":"Endpoint","external_reference":"https://ref/a", + "extended_attributes":{"platform":"Linux","arch":"x86_64"}} + """); + assertTrue(processor.validate(node)); + } + + @Test + @DisplayName("should return false when name is missing") + void shouldReturnFalseWhenNameMissing() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + {"type":"Endpoint","external_reference":"https://ref/a", + "extended_attributes":{"platform":"Linux","arch":"x86_64"}} + """); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("should return false when type is missing") + void shouldReturnFalseWhenTypeMissing() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + {"name":"Asset A","external_reference":"https://ref/a", + "extended_attributes":{"platform":"Linux","arch":"x86_64"}} + """); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("should return false when externalReference is missing") + void shouldReturnFalseWhenExternalReferenceMissing() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + {"name":"Asset A","type":"Endpoint", + "extended_attributes":{"platform":"Linux","arch":"x86_64"}} + """); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("should return false for Endpoint when platform is missing") + void shouldReturnFalseForEndpointWhenPlatformMissing() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + {"name":"Asset A","type":"Endpoint","external_reference":"https://ref/a", + "extended_attributes":{"arch":"x86_64"}} + """); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("should return false for Endpoint when arch is missing") + void shouldReturnFalseForEndpointWhenArchMissing() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + {"name":"Asset A","type":"Endpoint","external_reference":"https://ref/a", + "extended_attributes":{"platform":"Linux"}} + """); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("should return false for unknown asset type") + void shouldReturnFalseForUnknownAssetType() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + {"name":"Asset A","type":"Unknown","external_reference":"https://ref/a", + "extended_attributes":{}} + """); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("should skip invalid asset node") + void shouldSkipInvalidAssetNode() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + [{"type":"Endpoint","extended_attributes":{"platform":"Linux","arch":"x86_64"}}] + """); + processor.process(executionContext, contractOutputContext, node); + verifyNoInteractions(endpointService); + } + + @Test + @DisplayName("should create endpoint when it does not exist") + void shouldCreateEndpointWhenNotExisting() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + [{"name":"Asset A","type":"Endpoint","external_reference":"https://ref/a","tags":[], + "extended_attributes":{"platform":"Linux","arch":"x86_64"}}] + """); + Endpoint created = mock(Endpoint.class); + when(created.getId()).thenReturn("endpoint-id"); + when(endpointService.findExistingEndpoint(any())).thenReturn(Optional.empty()); + when(endpointService.createEndpoint(any(EndpointInput.class))).thenReturn(created); + + processor.process(executionContext, contractOutputContext, node); + + verify(endpointService).createEndpoint(any(EndpointInput.class)); + } + + @Test + @DisplayName("should not create endpoint when it already exists") + void shouldNotCreateEndpointWhenAlreadyExisting() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + [{"name":"Asset A","type":"Endpoint","external_reference":"https://ref/a","tags":[], + "extended_attributes":{"platform":"Linux","arch":"x86_64"}}] + """); + Endpoint existing = mock(Endpoint.class); + when(existing.getId()).thenReturn("existing-id"); + when(endpointService.findExistingEndpoint(any())).thenReturn(Optional.of(existing)); + + processor.process(executionContext, contractOutputContext, node); + + verify(endpointService, never()).createEndpoint(any(EndpointInput.class)); + } + + @Test + @DisplayName("should process asset node with tags and call tagService") + void shouldProcessAssetNodeWithTags() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + [{"name":"Asset B","type":"Endpoint","external_reference":"https://ref/b","tags":["tag1","tag2"], + "extended_attributes":{"platform":"Linux","arch":"x86_64"}}] + """); + Endpoint created = mock(Endpoint.class); + when(created.getId()).thenReturn("endpoint-id"); + when(endpointService.findExistingEndpoint(any())).thenReturn(Optional.empty()); + when(endpointService.createEndpoint(any(EndpointInput.class))).thenReturn(created); + when(tagService.findOrCreateTagsFromNames(any())).thenReturn(Set.of()); + + processor.process(executionContext, contractOutputContext, node); + + verify(tagService).findOrCreateTagsFromNames(any()); + verify(endpointService).createEndpoint(any(EndpointInput.class)); + } + + @Test + @DisplayName("should process asset node with all extended attributes") + void shouldProcessAssetNodeWithAllExtendedAttributes() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + [{"name":"Asset C","type":"Endpoint","external_reference":"https://ref/c","tags":[], + "extended_attributes":{"platform":"Linux","arch":"x86_64","hostname":"hostC","ip_addresses":["192.168.1.1"],"mac_addresses":["00:11:22:33:44:55"],"end_of_life":"true"}}] + """); + Endpoint created = mock(Endpoint.class); + when(created.getId()).thenReturn("endpoint-id"); + when(endpointService.findExistingEndpoint(any())).thenReturn(Optional.empty()); + when(endpointService.createEndpoint(any(EndpointInput.class))).thenReturn(created); + + processor.process(executionContext, contractOutputContext, node); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EndpointInput.class); + verify(endpointService).createEndpoint(captor.capture()); + EndpointInput endpointInput = captor.getValue(); + assertTrue("hostC".equals(endpointInput.getHostname())); + assertTrue(endpointInput.getIps() != null && endpointInput.getIps().length == 1); + assertTrue("192.168.1.1".equals(endpointInput.getIps()[0])); + assertTrue( + endpointInput.getMacAddresses() != null && endpointInput.getMacAddresses().length == 1); + assertTrue("00:11:22:33:44:55".equals(endpointInput.getMacAddresses()[0])); + assertTrue(endpointInput.isEol()); + } + + @Test + @DisplayName("should return false for security platform asset type") + void shouldReturnFalseForSecurityPlatformAssetType() throws Exception { + JsonNode node = + objectMapper.readTree( + """ + {"name":"Asset D","type":"SecurityPlatform","external_reference":"https://ref/d", + "extended_attributes":{}} + """); + assertFalse(processor.validate(node)); + } + + @Test + @DisplayName("should handle malformed JSON gracefully") + void shouldHandleMalformedJsonGracefully() throws Exception { + JsonNode node = objectMapper.readTree("{}\n"); + assertFalse(processor.validate(node)); + processor.process(executionContext, contractOutputContext, node); + verifyNoInteractions(endpointService); + } + + @Test + @DisplayName("should skip asset node with unexpected input type") + void shouldSkipAssetNodeWithUnexpectedInputType() throws Exception { + JsonNode node = objectMapper.readTree("42"); // not an object or array + assertFalse(processor.validate(node)); + processor.process(executionContext, contractOutputContext, node); + verifyNoInteractions(endpointService); + } +} diff --git a/openaev-api/src/test/java/io/openaev/output_processor/IPv4OutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/IPv4OutputProcessorTest.java index 9a6e60c1c91..7ab1c2ef1c2 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/IPv4OutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/IPv4OutputProcessorTest.java @@ -1,6 +1,6 @@ package io.openaev.output_processor; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import com.fasterxml.jackson.databind.JsonNode; diff --git a/openaev-api/src/test/java/io/openaev/output_processor/IPv6OutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/IPv6OutputProcessorTest.java index 0324cf1a7b4..a54a7dd83ba 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/IPv6OutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/IPv6OutputProcessorTest.java @@ -1,6 +1,6 @@ package io.openaev.output_processor; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import com.fasterxml.jackson.databind.JsonNode; diff --git a/openaev-api/src/test/java/io/openaev/output_processor/NumberOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/NumberOutputProcessorTest.java index fb75ff8b5f5..1b1ab2ef1ae 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/NumberOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/NumberOutputProcessorTest.java @@ -1,6 +1,6 @@ package io.openaev.output_processor; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import com.fasterxml.jackson.databind.JsonNode; diff --git a/openaev-api/src/test/java/io/openaev/output_processor/PortOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/PortOutputProcessorTest.java index 2fa242cec0d..1acf561d506 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/PortOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/PortOutputProcessorTest.java @@ -1,6 +1,6 @@ package io.openaev.output_processor; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import com.fasterxml.jackson.databind.JsonNode; diff --git a/openaev-api/src/test/java/io/openaev/output_processor/TextOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/TextOutputProcessorTest.java index f2bc9acee0c..a119cf401ef 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/TextOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/TextOutputProcessorTest.java @@ -1,6 +1,6 @@ package io.openaev.output_processor; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import com.fasterxml.jackson.databind.JsonNode; diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java index a3dfeabc51b..19e51dfd0cb 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java @@ -4,8 +4,7 @@ import static io.openaev.database.model.ExerciseStatus.RUNNING; import static io.openaev.database.model.InjectExpectationSignature.EXPECTATION_SIGNATURE_TYPE_END_DATE; import static io.openaev.database.model.InjectExpectationSignature.EXPECTATION_SIGNATURE_TYPE_START_DATE; -import static io.openaev.database.model.InjectorContract.CONTRACT_ELEMENT_CONTENT_KEY_TARGETED_ASSET_SEPARATOR; -import static io.openaev.database.model.InjectorContract.CONTRACT_ELEMENT_CONTENT_KEY_TARGETED_PROPERTY; +import static io.openaev.database.model.InjectorContract.*; import static io.openaev.injectors.email.EmailContract.EMAIL_DEFAULT; import static io.openaev.rest.atomic_testing.AtomicTestingApi.ATOMIC_TESTING_URI; import static io.openaev.rest.exercise.ExerciseApi.EXERCISE_URI; @@ -1328,9 +1327,9 @@ void shouldComputeAgentStatusAsMayBePrevented() throws Exception { } @Nested - @DisplayName("Finding Handling") + @DisplayName("Finding Processing Handling") @KeepRabbit - class FindingHandlingTest { + class FindingProcessingHandlingTest { @Test @DisplayName("Should link finding to targeted asset") @@ -1968,6 +1967,424 @@ void shouldNotCreateIPv6FindingsWhenRawOutputContainsNoIPv6Addresses() throws Ex assertTrue(injectTestHelper.findFindingsByInjectId(ipv6Inject.getId()).isEmpty()); } } + + @Nested + @DisplayName("Asset Processing Handling") + @KeepRabbit + class AssetProcessingHandlingTest { + + @Test + @DisplayName("Should create an asset agentless") + void shouldCreateAssetFromStructuredOutput() throws Exception { + // -- PREPARE -- + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("Creation Assets"); + input.setOutputStructured( + """ + { + "found_assets": [ + { + "name": "Asset A", + "type": "Endpoint", + "description": "describe asset A", + "external_reference": "https://shodan.io/.../assetA", + "tags": ["source:shodan.io"], + "extended_attributes": { + "ip_addresses": ["192.168.0.2"], + "platform": "Windows", + "hostname": "test.if", + "mac_addresses": ["1::22:45:67:89:AB"], + "arch": "x86_64", + "end_of_life": true + } + }, + { + "name": "Asset B", + "type": "Endpoint", + "description": "describe asset B", + "external_reference": "https://shodan.io/.../assetB", + "tags": ["source:shodan.io"], + "extended_attributes": { + "ip_addresses": ["192.168.0.10"], + "platform": "Linux", + "hostname": "test.io", + "mac_addresses": ["1::23:45:67:89:AB"], + "arch": "arm64", + "end_of_life": false + } + } + ] + } + """); + input.setAction(InjectExecutionAction.complete); + input.setStatus("SUCCESS"); + Inject inject = getPendingInjectWithAssets(); + Injector injector = InjectorFixture.createDefaultPayloadInjector(); + injectTestHelper.forceSaveInjector(injector); + ObjectNode convertedContent = + (ObjectNode) + mapper.readTree( + """ + { + "outputs": [ + { + "field": "found_assets", + "isFindingCompatible": false, + "isMultiple": true, + "labels": [ + "shodan" + ], + "type": "asset" + } + ] + } + """); + convertedContent.set(CONTRACT_CONTENT_FIELDS, objectMapper.valueToTree(List.of())); + InjectorContract injectorContract = + InjectorContractFixture.createInjectorContract(convertedContent); + injectorContract.setInjector(injector); + InjectorContract injectorContractSaved = + injectTestHelper.forceSaveInjectorContract(injectorContract); + inject.setInjectorContract(injectorContractSaved); + inject.setContent(convertedContent); + injectTestHelper.forceSaveInject(inject); + + // -- EXECUTE -- + performAgentlessCallbackRequest(inject.getId(), input); + + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until( + () -> { + List endpointsA = + endpointRepository.findByExternalReference("https://shodan.io/.../assetA"); + List endpointsB = + endpointRepository.findByExternalReference("https://shodan.io/.../assetB"); + return endpointsA.size() == 1 && endpointsB.size() == 1; + }); + + List endpointsA = + endpointRepository.findByExternalReference("https://shodan.io/.../assetA"); + List endpointsB = + endpointRepository.findByExternalReference("https://shodan.io/.../assetB"); + assertEquals(1, endpointsA.size()); + assertEquals(1, endpointsB.size()); + assertEquals("test.if", endpointsA.getFirst().getHostname()); + assertEquals("test.io", endpointsB.getFirst().getHostname()); + } + + @Test + @DisplayName("Should find asset from structured output and not create a new one") + void shouldFindAssetFromStructuredOutputAndNotCreateNewAsset() throws Exception { + // -- PREPARE -- + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("Creation Assets"); + input.setOutputStructured( + """ + { + "found_assets": [ + { + "name": "Asset A", + "type": "Endpoint", + "description": "describe asset A", + "external_reference": "https://shodan.io/.../assetA", + "tags": ["source:shodan.io"], + "extended_attributes": { + "ip_addresses": ["192.168.0.2"], + "platform": "Windows", + "hostname": "test.if", + "mac_addresses": ["1::22:45:67:89:AB"], + "arch": "x86_64", + "end_of_life": true + } + }, + { + "name": "Asset B", + "type": "Endpoint", + "description": "describe asset B", + "external_reference": "https://shodan.io/.../assetA", + "tags": ["source:shodan.io"], + "extended_attributes": { + "ip_addresses": ["192.168.0.2"], + "platform": "Windows", + "hostname": "test.if", + "mac_addresses": ["1::22:45:67:89:AB"], + "arch": "arm64", + "end_of_life": false + } + } + ] + } + """); + input.setAction(InjectExecutionAction.complete); + input.setStatus("SUCCESS"); + Inject inject = getPendingInjectWithAssets(); + Injector injector = InjectorFixture.createDefaultPayloadInjector(); + injectTestHelper.forceSaveInjector(injector); + ObjectNode convertedContent = + (ObjectNode) + mapper.readTree( + """ + { + "outputs": [ + { + "field": "found_assets", + "isFindingCompatible": false, + "isMultiple": true, + "labels": [ + "shodan" + ], + "type": "asset" + } + ] + } + """); + convertedContent.set(CONTRACT_CONTENT_FIELDS, objectMapper.valueToTree(List.of())); + InjectorContract injectorContract = + InjectorContractFixture.createInjectorContract(convertedContent); + injectorContract.setInjector(injector); + InjectorContract injectorContractSaved = + injectTestHelper.forceSaveInjectorContract(injectorContract); + inject.setInjectorContract(injectorContractSaved); + inject.setContent(convertedContent); + injectTestHelper.forceSaveInject(inject); + + // -- EXECUTE -- + performAgentlessCallbackRequest(inject.getId(), input); + + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until( + () -> { + List endpointsA = + endpointRepository.findByExternalReference("https://shodan.io/.../assetA"); + return endpointsA.size() == 1; + }); + + List endpointsA = + endpointRepository.findByExternalReference("https://shodan.io/.../assetA"); + assertEquals(1, endpointsA.size()); + assertEquals("test.if", endpointsA.getFirst().getHostname()); + } + + @Test + @DisplayName("Should not produce anything when contract has no asset outputType") + void shouldNotProduceAnythingWhenContractHasNoAssetOutputType() throws Exception { + // -- PREPARE -- + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("Creation Assets"); + input.setOutputStructured( + """ + { + "found_assets": [ + { + "name": "Asset A", + "type": "Endpoint", + "description": "describe asset A", + "external_reference": "https://shodan.io/.../assetA", + "tags": ["source:shodan.io"], + "extended_attributes": { + "ip_addresses": ["192.168.0.2"], + "platform": "Windows", + "hostname": "test.if", + "mac_addresses": ["1::22:45:67:89:AB"], + "arch": "x86_64", + "end_of_life": true + } + } + ] + } + """); + input.setAction(InjectExecutionAction.complete); + input.setStatus("SUCCESS"); + Inject inject = getPendingInjectWithAssets(); + Injector injector = InjectorFixture.createDefaultPayloadInjector(); + injectTestHelper.forceSaveInjector(injector); + ObjectNode convertedContent = + (ObjectNode) + mapper.readTree( + """ + { + "outputs": [ + { + "field": "cve", + "isFindingCompatible": true, + "isMultiple": true, + "labels": [ + "shodan" + ], + "type": "cve" + } + ] + } + """); + convertedContent.set(CONTRACT_CONTENT_FIELDS, objectMapper.valueToTree(List.of())); + InjectorContract injectorContract = + InjectorContractFixture.createInjectorContract(convertedContent); + injectorContract.setInjector(injector); + InjectorContract injectorContractSaved = + injectTestHelper.forceSaveInjectorContract(injectorContract); + inject.setInjectorContract(injectorContractSaved); + inject.setContent(convertedContent); + injectTestHelper.forceSaveInject(inject); + + // -- EXECUTE -- + performAgentlessCallbackRequest(inject.getId(), input); + + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until( + () -> { + List findings = findingRepository.findAllByInjectId(inject.getId()); + return findings.isEmpty(); + }); + } + + @Test + @DisplayName("Should Not Produce Nothing When StructuredOutput Is Empty") + void shouldNotProduceNothingWhenStructuredOutputIsEmpty() throws Exception { + // -- PREPARE -- + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("Creation Assets"); + input.setOutputStructured("{}"); + input.setAction(InjectExecutionAction.complete); + input.setStatus("SUCCESS"); + Inject inject = getPendingInjectWithAssets(); + Injector injector = InjectorFixture.createDefaultPayloadInjector(); + injectTestHelper.forceSaveInjector(injector); + ObjectNode convertedContent = + (ObjectNode) + mapper.readTree( + """ + { + "outputs": [ + { + "field": "found_assets", + "isFindingCompatible": false, + "isMultiple": true, + "labels": [ + "shodan" + ], + "type": "asset" + } + ] + } + """); + convertedContent.set(CONTRACT_CONTENT_FIELDS, objectMapper.valueToTree(List.of())); + InjectorContract injectorContract = + InjectorContractFixture.createInjectorContract(convertedContent); + injectorContract.setInjector(injector); + InjectorContract injectorContractSaved = + injectTestHelper.forceSaveInjectorContract(injectorContract); + inject.setInjectorContract(injectorContractSaved); + inject.setContent(convertedContent); + injectTestHelper.forceSaveInject(inject); + + // -- EXECUTE -- + performAgentlessCallbackRequest(inject.getId(), input); + + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until( + () -> { + List endpointsA = + endpointRepository.findByExternalReference("https://shodan.io/.../assetA"); + return endpointsA.isEmpty(); + }); + } + + @Test + @DisplayName("Should Create Asset Even If Some Informations Are Null") + void shouldCreateAssetEvenIfSomeInformationAreNull() throws Exception { + // -- PREPARE -- + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("Creation Assets"); + input.setOutputStructured( + """ + { + "found_assets": [ + { + "name": "Asset C", + "type": "Endpoint", + "description": "describe asset C", + "external_reference": "https://shodan.io/.../assetC", + "tags": ["source:shodan.io"], + "extended_attributes": { + "ip_addresses": [], + "platform": "Unknown", + "mac_addresses": ["1::22:45:67:89:AB"], + "arch": "x86_64", + "end_of_life": true + } + } + ] + } + """); + input.setAction(InjectExecutionAction.complete); + input.setStatus("SUCCESS"); + Inject inject = getPendingInjectWithAssets(); + Injector injector = InjectorFixture.createDefaultPayloadInjector(); + injectTestHelper.forceSaveInjector(injector); + ObjectNode convertedContent = + (ObjectNode) + mapper.readTree( + """ + { + "outputs": [ + { + "field": "found_assets", + "isFindingCompatible": false, + "isMultiple": true, + "labels": [ + "shodan:asset" + ], + "type": "asset" + } + ] + } + """); + convertedContent.set(CONTRACT_CONTENT_FIELDS, objectMapper.valueToTree(List.of())); + InjectorContract injectorContract = + InjectorContractFixture.createInjectorContract(convertedContent); + injectorContract.setInjector(injector); + InjectorContract injectorContractSaved = + injectTestHelper.forceSaveInjectorContract(injectorContract); + inject.setInjectorContract(injectorContractSaved); + inject.setContent(convertedContent); + injectTestHelper.forceSaveInject(inject); + + // -- EXECUTE -- + performAgentlessCallbackRequest(inject.getId(), input); + + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until( + () -> { + List endpointsA = + endpointRepository.findByExternalReference("https://shodan.io/.../assetC"); + return endpointsA.size() == 1; + }); + + List endpointsA = + endpointRepository.findByExternalReference("https://shodan.io/.../assetC"); + assertEquals(1, endpointsA.size()); + assertEquals("", endpointsA.getFirst().getHostname()); + assertEquals(Endpoint.PLATFORM_TYPE.Unknown, endpointsA.getFirst().getPlatform()); + assertEquals(Endpoint.PLATFORM_ARCH.x86_64, endpointsA.getFirst().getArch()); + assertTrue(endpointsA.getFirst().isEoL()); + assertEquals(0, endpointsA.getFirst().getIps().length); + } + } } @Nested diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java index 013cdf5dc2f..a3aa6cb344c 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java @@ -1,7 +1,6 @@ package io.openaev.rest.inject.service; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import com.fasterxml.jackson.databind.JsonNode; @@ -91,6 +90,20 @@ void shouldReturnEmptyWhenComputeStructuredOutputReturnsEmpty() throws Exception verifyNoInteractions(outputProcessorFactory); } + @Test + @DisplayName("Should return empty and skip dispatch when output parsers are empty") + void shouldReturnEmptyWhenOutputParsersAreEmpty() throws Exception { + ExecutionProcessingContext ctx = createValidCtx(); + when(structuredOutputUtils.extractOutputParsers(any())).thenReturn(Set.of()); + when(structuredOutputUtils.computeStructuredOutputFromOutputParsers(any(), anyString())) + .thenReturn(Optional.empty()); + + Optional result = handler.processContext(ctx); + + assertTrue(result.isEmpty()); + verifyNoInteractions(outputProcessorFactory); + } + @Test @DisplayName("Should skip processor when contract element key is missing in produced JSON") void shouldSkipProcessorWhenKeyInContractIsMissingInProducedJson() throws Exception { @@ -117,7 +130,28 @@ void shouldSkipProcessorWhenKeyInContractIsMissingInProducedJson() throws Except handler.processContext(ctx); - // Should NOT be called for missing key + verify(mockProcessor, never()).process(any(), any(), any()); + } + + @Test + @DisplayName("Should skip processor when no processor is registered for the output type") + void shouldSkipWhenNoProcessorRegisteredForType() throws Exception { + ExecutionProcessingContext ctx = createValidCtx(); + + ContractOutputElement element = + OutputParserFixture.getContractOutputElement(ContractOutputType.CVE, "cve", Set.of(), true); + OutputParser parser = OutputParserFixture.getOutputParser(Set.of(element)); + when(structuredOutputUtils.extractOutputParsers(any())).thenReturn(Set.of(parser)); + + ObjectNode json = mapper.createObjectNode(); + json.put("cve-key", "some-value"); + when(structuredOutputUtils.computeStructuredOutputFromOutputParsers(any(), anyString())) + .thenReturn(Optional.of(json)); + when(outputProcessorFactory.getProcessor(ContractOutputType.CVE)).thenReturn(Optional.empty()); + + Optional result = handler.processContext(ctx); + + assertTrue(result.isPresent()); verify(mockProcessor, never()).process(any(), any(), any()); } @@ -140,10 +174,36 @@ void shouldProcessCorrectlyWhenMultipleParsersElementsMatch() throws Exception { handler.processContext(ctx); - // Should be called once for the matching element verify(mockProcessor, times(1)).process(eq(ctx), any(), any(JsonNode.class)); } + @Test + @DisplayName( + "Should dispatch to processor with the correct node value from the structured output") + void shouldDispatchToProcessorWithCorrectNodeValue() throws Exception { + ExecutionProcessingContext ctx = createValidCtx(); + + ContractOutputElement element = + OutputParserFixture.getContractOutputElement(ContractOutputType.CVE, "cve", Set.of(), true); + OutputParser parser = OutputParserFixture.getOutputParser(Set.of(element)); + when(structuredOutputUtils.extractOutputParsers(any())).thenReturn(Set.of(parser)); + + ObjectNode json = mapper.createObjectNode(); + json.put("cve-key", "expected-value"); + when(structuredOutputUtils.computeStructuredOutputFromOutputParsers(any(), anyString())) + .thenReturn(Optional.of(json)); + when(outputProcessorFactory.getProcessor(ContractOutputType.CVE)) + .thenReturn(Optional.of(mockProcessor)); + + handler.processContext(ctx); + + verify(mockProcessor, times(1)) + .process( + eq(ctx), + argThat(c -> "cve-key".equals(c.key()) && ContractOutputType.CVE.equals(c.type())), + argThat(node -> "expected-value".equals(node.asText()))); + } + private ExecutionProcessingContext createValidCtx() { return new ExecutionProcessingContext( inject, diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java index 7f1d6c274a6..119db55dcfe 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectServiceTest.java @@ -1,8 +1,6 @@ package io.openaev.rest.inject.service; import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -11,7 +9,10 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.openaev.database.model.*; -import io.openaev.database.repository.*; +import io.openaev.database.repository.InjectDocumentRepository; +import io.openaev.database.repository.InjectRepository; +import io.openaev.database.repository.InjectStatusRepository; +import io.openaev.database.repository.TeamRepository; import io.openaev.executors.utils.ExecutorUtils; import io.openaev.healthcheck.dto.HealthCheck; import io.openaev.healthcheck.enums.ExternalServiceDependency; @@ -44,7 +45,9 @@ import java.util.HashSet; import java.util.List; import java.util.Optional; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java index 587f33b8a6e..8017d380054 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java @@ -1,10 +1,12 @@ package io.openaev.rest.inject.service; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.openaev.database.model.ContractOutputType; import io.openaev.database.model.ExecutionTraceStatus; import io.openaev.database.model.Inject; import io.openaev.database.model.InjectorContract; @@ -14,9 +16,11 @@ import io.openaev.rest.inject.form.InjectExecutionAction; import io.openaev.rest.inject.form.InjectExecutionInput; import io.openaev.rest.injector_contract.InjectorContractContentUtils; +import io.openaev.utils.fixtures.AgentFixture; import io.openaev.utils.fixtures.InjectFixture; import java.util.List; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -48,16 +52,20 @@ void setUp() { @Test @DisplayName("Should support only injector execution contexts") - void testSupports() { + void shouldSupportOnlyInjectorExecutionContexts() { ExecutionProcessingContext injectorCtx = new ExecutionProcessingContext(inject, null, new InjectExecutionInput(), Map.of()); + ExecutionProcessingContext agentCtx = + new ExecutionProcessingContext( + inject, AgentFixture.createDefaultAgentService(), new InjectExecutionInput(), Map.of()); assertTrue(handler.supports(injectorCtx)); + assertFalse(handler.supports(agentCtx)); } @Test - @DisplayName("Should return empty if status is not success or action is not COMPLETE") - void testEarlyExitConditions() throws Exception { + @DisplayName("Should return empty if status is not SUCCESS or action is not COMPLETE") + void shouldReturnEmptyWhenStatusNotSuccessOrActionNotComplete() throws Exception { // Case 1: Status is ERROR InjectExecutionInput inputError = buildInput(ExecutionTraceStatus.ERROR, InjectExecutionAction.complete, "{}"); @@ -81,9 +89,41 @@ void testEarlyExitConditions() throws Exception { } @Test - @DisplayName("Should skip processor if the key is missing from the structured output JSON") - void testSkipWhenKeyIsMissing() throws Exception { - // JSON exists but does not contain "missing_key" + @DisplayName("Should return empty when outputStructured is null") + void shouldReturnEmptyWhenOutputStructuredIsNull() throws Exception { + ExecutionProcessingContext ctx = createValidCtx(null); + + Optional result = handler.processContext(ctx); + + assertTrue(result.isEmpty()); + verifyNoInteractions(outputProcessorFactory); + } + + @Test + @DisplayName("Should return empty when outputStructured is blank") + void shouldReturnEmptyWhenOutputStructuredIsBlank() throws Exception { + ExecutionProcessingContext ctx = createValidCtx(" "); + + Optional result = handler.processContext(ctx); + + assertTrue(result.isEmpty()); + verifyNoInteractions(outputProcessorFactory); + } + + @Test + @DisplayName("Should return empty and log warning when outputStructured is invalid JSON") + void shouldReturnEmptyWhenOutputStructuredIsInvalidJson() throws Exception { + ExecutionProcessingContext ctx = createValidCtx("not-valid-json{{{"); + + Optional result = handler.processContext(ctx); + + assertTrue(result.isEmpty()); + verifyNoInteractions(outputProcessorFactory); + } + + @Test + @DisplayName("Should return present result and skip processor when key is missing from JSON") + void shouldSkipProcessorWhenKeyIsMissing() throws Exception { ExecutionProcessingContext ctx = createValidCtx("{\"unrelated_key\": \"value\"}"); InjectorContractContentOutputElement element = new InjectorContractContentOutputElement(); @@ -100,6 +140,68 @@ void testSkipWhenKeyIsMissing() throws Exception { verify(mockProcessor, never()).process(any(), any(), any()); } + @Test + @DisplayName("Should skip dispatch when contract outputs list is empty") + void shouldSkipDispatchWhenContractOutputsAreEmpty() throws Exception { + ExecutionProcessingContext ctx = createValidCtx("{\"some_key\": \"value\"}"); + + when(injectorContract.getConvertedContent()).thenReturn(mapper.createObjectNode()); + when(injectorContractContentUtils.getContractOutputs(any(), any())).thenReturn(List.of()); + + Optional result = handler.processContext(ctx); + + assertTrue(result.isPresent()); + verifyNoInteractions(outputProcessorFactory); + } + + @Test + @DisplayName("Should skip processor when no processor is registered for the output type") + void shouldSkipWhenNoProcessorRegisteredForType() throws Exception { + ExecutionProcessingContext ctx = createValidCtx("{\"cve-field\": [\"CVE-2024-1234\"]}"); + + InjectorContractContentOutputElement element = new InjectorContractContentOutputElement(); + element.setField("cve-field"); + element.setType(ContractOutputType.CVE); + element.setFindingCompatible(true); + + when(injectorContract.getConvertedContent()).thenReturn(mapper.createObjectNode()); + when(injectorContractContentUtils.getContractOutputs(any(), any())) + .thenReturn(List.of(element)); + when(outputProcessorFactory.getProcessor(ContractOutputType.CVE)).thenReturn(Optional.empty()); + + Optional result = handler.processContext(ctx); + + assertTrue(result.isPresent()); + verify(mockProcessor, never()).process(any(), any(), any()); + } + + @Test + @DisplayName( + "Should dispatch to processor with the correct node value from the structured output") + void shouldDispatchToProcessorWithCorrectNodeValue() throws Exception { + ExecutionProcessingContext ctx = createValidCtx("{\"cve-field\": \"CVE-2024-9999\"}"); + + InjectorContractContentOutputElement element = new InjectorContractContentOutputElement(); + element.setField("cve-field"); + element.setType(ContractOutputType.CVE); + element.setFindingCompatible(true); + + when(injectorContract.getConvertedContent()).thenReturn(mapper.createObjectNode()); + when(injectorContractContentUtils.getContractOutputs(any(), any())) + .thenReturn(List.of(element)); + when(outputProcessorFactory.getProcessor(ContractOutputType.CVE)) + .thenReturn(Optional.of(mockProcessor)); + + Optional result = handler.processContext(ctx); + + assertTrue(result.isPresent()); + verify(mockProcessor, times(1)) + .process( + eq(ctx), + argThat(c -> "cve-field".equals(c.key()) && ContractOutputType.CVE.equals(c.type())), + argThat(node -> "CVE-2024-9999".equals(node.asText()))); + } + private ExecutionProcessingContext createValidCtx(String json) { return new ExecutionProcessingContext( inject, diff --git a/openaev-model/src/main/java/io/openaev/database/model/Endpoint.java b/openaev-model/src/main/java/io/openaev/database/model/Endpoint.java index d37f7411308..5ea6190abd3 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Endpoint.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Endpoint.java @@ -35,7 +35,23 @@ public enum PLATFORM_ARCH { @JsonProperty("arm64") arm64, @JsonProperty("Unknown") - Unknown, + Unknown; + + /** + * Returns the PLATFORM_ARCH enum constant corresponding to the given string value. If the value + * is null or does not match any known architecture, returns Unknown. + * + * @param value the string representation of the platform architecture + * @return the corresponding PLATFORM_ARCH, or Unknown if not recognized + */ + public static PLATFORM_ARCH fromString(String value) { + if (value == null) return Unknown; + return switch (value.toLowerCase()) { + case "x86_64" -> x86_64; + case "arm64", "aarch64" -> arm64; + default -> Unknown; + }; + } } public enum PLATFORM_TYPE { @@ -61,6 +77,22 @@ public static List getAllNamesAsStrings() { return Arrays.stream(values()).map(Enum::name).toList(); } + /** + * Returns the PLATFORM_TYPE enum constant corresponding to the given string value. If the value + * is null or does not match any known type, returns Unknown. + * + * @param value the string representation of the platform type + * @return the corresponding PLATFORM_TYPE, or Unknown if not recognized + */ + public static PLATFORM_TYPE fromString(String value) { + if (value == null) return Unknown; + try { + return PLATFORM_TYPE.valueOf(value); + } catch (IllegalArgumentException e) { + return Unknown; + } + } + /** * Convert and return all enum from a list of String * From c0837a0859567b899d549a27a9237615416fcd4a Mon Sep 17 00:00:00 2001 From: savacano28 Date: Thu, 5 Mar 2026 13:58:22 +0100 Subject: [PATCH 10/21] [backend] feat: review feedback --- .../io/openaev/rest/inject/StructuredOutputUtilsTest.java | 4 +++- .../service/InjectorExecutionProcessingHandlerTest.java | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/StructuredOutputUtilsTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/StructuredOutputUtilsTest.java index a32ab1b2966..1390ab624a5 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/StructuredOutputUtilsTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/StructuredOutputUtilsTest.java @@ -12,7 +12,9 @@ import java.util.Arrays; import java.util.Optional; import java.util.Set; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java index 8017d380054..2414e8a0198 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java @@ -1,6 +1,7 @@ package io.openaev.rest.inject.service; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; From 9def97aa3a62d84326f5b7829d66954b7cb99b26 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Thu, 5 Mar 2026 13:59:37 +0100 Subject: [PATCH 11/21] [backend] feat: review feedback --- .../io/openaev/output_processor/AssetOutputProcessorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openaev-api/src/test/java/io/openaev/output_processor/AssetOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/AssetOutputProcessorTest.java index b125dee402c..d292947ca59 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/AssetOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/AssetOutputProcessorTest.java @@ -232,7 +232,7 @@ void shouldReturnFalseForSecurityPlatformAssetType() throws Exception { {"name":"Asset D","type":"SecurityPlatform","external_reference":"https://ref/d", "extended_attributes":{}} """); - assertFalse(processor.validate(node)); + assertTrue(processor.validate(node)); } @Test From 6b2c36d8073fda8498e3dfded6ae0f8d39c9c24f Mon Sep 17 00:00:00 2001 From: savacano28 Date: Thu, 5 Mar 2026 14:01:52 +0100 Subject: [PATCH 12/21] [backend] feat: review feedback --- .../inject/service/AgentExecutionProcessingHandlerTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java index a3aa6cb344c..40c90490eea 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java @@ -1,6 +1,7 @@ package io.openaev.rest.inject.service; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; import com.fasterxml.jackson.databind.JsonNode; From a134d38986195c177e6d527e1d710b110b78ff89 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Thu, 5 Mar 2026 14:03:11 +0100 Subject: [PATCH 13/21] [backend] feat: review feedback --- .../openaev/output_processor/FindingCapableOutputProcessor.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/output_processor/FindingCapableOutputProcessor.java b/openaev-api/src/main/java/io/openaev/output_processor/FindingCapableOutputProcessor.java index f3ef3d3f73c..f60c8a91c38 100644 --- a/openaev-api/src/main/java/io/openaev/output_processor/FindingCapableOutputProcessor.java +++ b/openaev-api/src/main/java/io/openaev/output_processor/FindingCapableOutputProcessor.java @@ -51,8 +51,6 @@ public final void process( /** * Hook called after findings are generated. Override to perform additional processing such as * expectation matching without needing to override {@link #process} entirely. - * - *

Default implementation does nothing. */ protected void afterFindings( ExecutionProcessingContext executionContext, JsonNode structuredOutputNode) { From 3fdc18b05bd70e03cd8692f4ba1264fbb0defde3 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Thu, 5 Mar 2026 14:05:36 +0100 Subject: [PATCH 14/21] [backend] feat: review feedback --- .../inject/service/AbstractExecutionProcessingHandler.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/AbstractExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/AbstractExecutionProcessingHandler.java index 7f901c1afa6..0c708a8532a 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/AbstractExecutionProcessingHandler.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/AbstractExecutionProcessingHandler.java @@ -17,9 +17,6 @@ protected AbstractExecutionProcessingHandler(OutputProcessorFactory outputProces * Dispatches each contract output context to its corresponding processor, if one exists, using * values extracted from the given structured output node. * - *

A contract output is skipped silently when no processor is registered for its type or when - * the key is absent from the structured output. - * * @param executionContext the current execution context * @param contractOutputContexts the list of contract output contexts to dispatch * @param structuredOutput the structured output node to read values from From 15e381c720b1adca34a39d77f4eec4a8d1b1acf9 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Thu, 5 Mar 2026 14:09:58 +0100 Subject: [PATCH 15/21] [backend] feat: review feedback --- .../AbstractOutputProcessorTest.java | 102 +++++++++--------- 1 file changed, 48 insertions(+), 54 deletions(-) diff --git a/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java b/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java index f15d33f4580..47e08a20615 100644 --- a/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java +++ b/openaev-api/src/test/java/io/openaev/output_processor/AbstractOutputProcessorTest.java @@ -9,11 +9,13 @@ import java.util.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class AbstractOutputProcessorTest { - private static class TestOutputProcessor extends AbstractOutputProcessor { + @Nested + public class TestOutputProcessor extends AbstractOutputProcessor { TestOutputProcessor() { super(null, null, Collections.emptyList()); @@ -27,64 +29,56 @@ public void process( // No-op for testing purposes } - @Override - public void process( - ExecutionProcessingContext ctx, - ContractOutputContext contractOutputContext, - JsonNode structuredOutputNode) { - // No-op for testing purposes - } - } + private TestOutputProcessor processor; + private ObjectMapper objectMapper; - private TestOutputProcessor processor; - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - processor = new TestOutputProcessor(); - objectMapper = new ObjectMapper(); - } + @BeforeEach + void setUp() { + processor = new TestOutputProcessor(); + objectMapper = new ObjectMapper(); + } - @Test - @DisplayName( - "Should join array elements and trim quotes when buildString is called with an array node") - void shouldJoinArrayElementsAndTrimQuotesWhenBuildStringCalledWithArrayNode() throws Exception { - JsonNode node = objectMapper.readTree("[\"foo\", \"bar\"]"); - String result = processor.buildString(node); - assertEquals("foo bar", result); - } + @Test + @DisplayName( + "Should join array elements and trim quotes when buildString is called with an array node") + void shouldJoinArrayElementsAndTrimQuotesWhenBuildStringCalledWithArrayNode() throws Exception { + JsonNode node = objectMapper.readTree("[\"foo\", \"bar\"]"); + String result = processor.buildString(node); + assertEquals("foo bar", result); + } - @Test - @DisplayName("Should trim quotes when buildString is called with a string node") - void shouldTrimQuotesWhenBuildStringCalledWithStringNode() throws Exception { - JsonNode node = objectMapper.readTree("\"baz\""); - String result = processor.buildString(node); - assertEquals("baz", result); - } + @Test + @DisplayName("Should trim quotes when buildString is called with a string node") + void shouldTrimQuotesWhenBuildStringCalledWithStringNode() throws Exception { + JsonNode node = objectMapper.readTree("\"baz\""); + String result = processor.buildString(node); + assertEquals("baz", result); + } - @Test - @DisplayName("Should extract and process value when buildString is called with a key") - void shouldExtractAndProcessValueWhenBuildStringCalledWithKey() throws Exception { - JsonNode node = objectMapper.readTree("{\"key\": [\"a\", \"b\"]}"); - String result = processor.buildString(node, "key"); - assertEquals("a b", result); - } + @Test + @DisplayName("Should extract and process value when buildString is called with a key") + void shouldExtractAndProcessValueWhenBuildStringCalledWithKey() throws Exception { + JsonNode node = objectMapper.readTree("{\"key\": [\"a\", \"b\"]}"); + String result = processor.buildString(node, "key"); + assertEquals("a b", result); + } - @Test - @DisplayName("Should return empty string when buildString is called with a missing or null key") - void shouldReturnEmptyStringWhenBuildStringCalledWithMissingOrNullKey() throws Exception { - JsonNode node = objectMapper.readTree("{}"); - assertEquals("", processor.buildString(node, "missing")); - JsonNode node2 = objectMapper.readTree("{\"key\": null}"); - assertEquals("", processor.buildString(node2, "key")); - } + @Test + @DisplayName("Should return empty string when buildString is called with a missing or null key") + void shouldReturnEmptyStringWhenBuildStringCalledWithMissingOrNullKey() throws Exception { + JsonNode node = objectMapper.readTree("{}"); + assertEquals("", processor.buildString(node, "missing")); + JsonNode node2 = objectMapper.readTree("{\"key\": null}"); + assertEquals("", processor.buildString(node2, "key")); + } - @Test - @DisplayName("Should remove leading and trailing quotes when trimQuotes is called") - void shouldRemoveLeadingAndTrailingQuotesWhenTrimQuotesCalled() { - assertEquals("foo", processor.trimQuotes("\"foo\"")); - assertEquals("bar", processor.trimQuotes("bar")); - assertEquals("foo\"bar", processor.trimQuotes("\"foo\"bar")); - assertEquals("foo\"bar", processor.trimQuotes("foo\"bar")); + @Test + @DisplayName("Should remove leading and trailing quotes when trimQuotes is called") + void shouldRemoveLeadingAndTrailingQuotesWhenTrimQuotesCalled() { + assertEquals("foo", processor.trimQuotes("\"foo\"")); + assertEquals("bar", processor.trimQuotes("bar")); + assertEquals("foo\"bar", processor.trimQuotes("\"foo\"bar")); + assertEquals("foo\"bar", processor.trimQuotes("foo\"bar")); + } } } From 8ae21a4e713cca00760fa22607811002c9ce82c8 Mon Sep 17 00:00:00 2001 From: Antoine MAZEAS Date: Fri, 6 Mar 2026 10:16:40 +0100 Subject: [PATCH 16/21] TODO tests; remove resolve() and supports() methods, call directly Signed-off-by: Antoine MAZEAS --- .../AgentExecutionProcessingHandler.java | 11 ---- .../service/BatchingInjectStatusService.java | 9 ++- .../service/ExecutionProcessingHandler.java | 8 --- .../service/InjectExecutionService.java | 55 ++++++++----------- .../InjectorExecutionProcessingHandler.java | 11 ---- 5 files changed, 30 insertions(+), 64 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java index b9a60c4237a..2a0ed49a1d5 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java @@ -30,17 +30,6 @@ public AgentExecutionProcessingHandler( this.structuredOutputUtils = structuredOutputUtils; } - /** - * Determines if this handler supports the given execution context (agent execution). - * - * @param executionContext the execution context to check - * @return true if the context is for an agent execution, false otherwise - */ - @Override - public boolean supports(ExecutionProcessingContext executionContext) { - return executionContext.isAgentExecution(); - } - /** * Processes the execution context, generating structured output and handling additional * capabilities such as findings extraction, expectation matching, or asset creation. diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/BatchingInjectStatusService.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/BatchingInjectStatusService.java index 8fa0c721b6f..e3b338d9549 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/BatchingInjectStatusService.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/BatchingInjectStatusService.java @@ -131,8 +131,13 @@ public List handleInjectExecutionCallback( .orElse(null); // Process the execution trace - injectExecutionService.processInjectExecution( - inject, agent, callback.getInjectExecutionInput()); + if (agent == null) { + injectExecutionService.processInjectExecutionWithInjector( + inject, callback.getInjectExecutionInput()); + } else { + injectExecutionService.processInjectExecutionWithAgent( + inject, agent, callback.getInjectExecutionInput()); + } successfullyProcessedCallbacks.add(callback); } catch (ElementNotFoundException e) { injectExecutionService.handleInjectExecutionError(inject, e); diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingHandler.java index 7026e4c09ca..fd37bce4640 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingHandler.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingHandler.java @@ -12,14 +12,6 @@ * asset creation). */ public interface ExecutionProcessingHandler { - /** - * Determines if this handler supports the given execution context. - * - * @param executionContext the execution context to check - * @return true if supported, false otherwise - */ - boolean supports(ExecutionProcessingContext executionContext); - /** * Processes the execution context, generating structured output and handling additional * capabilities. diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectExecutionService.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectExecutionService.java index 285c720c878..ca527a05fb9 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectExecutionService.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectExecutionService.java @@ -16,7 +16,6 @@ import jakarta.annotation.Nullable; import jakarta.annotation.Resource; import java.time.Instant; -import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,7 +33,9 @@ public class InjectExecutionService { private final AgentRepository agentRepository; private final InjectStatusService injectStatusService; private final InjectService injectService; - public final List executionProcessingHandlers; + + private final AgentExecutionProcessingHandler agentExecutionProcessingHandler; + private final InjectorExecutionProcessingHandler injectorExecutionProcessingHandler; @Resource protected ObjectMapper mapper; @@ -68,12 +69,25 @@ public void handleInjectExecutionCallback( "Cannot complete inject that is not in PENDING state"); } Agent agent = loadAgentIfPresent(agentId); - processInjectExecution(inject, agent, input); + if (agent == null) { + processInjectExecutionWithInjector(inject, input); + } else { + processInjectExecutionWithAgent(inject, agent, input); + } } catch (ElementNotFoundException e) { handleInjectExecutionError(inject, e); } } + public void processInjectExecutionWithAgent( + Inject inject, Agent agent, InjectExecutionInput input) { + processInjectExecution(inject, agent, input, agentExecutionProcessingHandler); + } + + public void processInjectExecutionWithInjector(Inject inject, InjectExecutionInput input) { + processInjectExecution(inject, null, input, injectorExecutionProcessingHandler); + } + /** * Processes the execution of an inject by resolving the appropriate handler based on the * execution source (injector or agent), extracting findings, matching expectations and updating @@ -84,16 +98,18 @@ public void handleInjectExecutionCallback( * @param input the execution input containing action, status, and output data * @throws RuntimeException if the output structured cannot be parsed */ - public void processInjectExecution( - Inject inject, @Nullable Agent agent, InjectExecutionInput input) { + private void processInjectExecution( + Inject inject, + @Nullable Agent agent, + InjectExecutionInput input, + AbstractExecutionProcessingHandler handler) { try { Map valueTargetedAssetsMap = injectService.getValueTargetedAssetMap(inject); // Build the context encapsulating all execution data and conditions (success, action, source) ExecutionProcessingContext executionContext = new ExecutionProcessingContext(inject, agent, input, valueTargetedAssetsMap); // Delegate to the appropriate handler (injector or agent) to process output execution - ObjectNode resolvedStructured = - resolveExecutionContext(executionContext).processContext(executionContext).orElse(null); + ObjectNode resolvedStructured = handler.processContext(executionContext).orElse(null); injectStatusService.updateInjectStatus(inject, agent, input, resolvedStructured); addEndDateInjectExpectationTimeSignatureIfNeeded(inject, agent, input); @@ -103,31 +119,6 @@ public void processInjectExecution( } } - /** - * Resolves the appropriate execution processing handler based on the execution context. Expects - * exactly one handler to support the given context, otherwise throws an exception. - * - * @param executionContext - * @return - */ - public ExecutionProcessingHandler resolveExecutionContext( - ExecutionProcessingContext executionContext) { - List matchingHandlers = - executionProcessingHandlers.stream().filter(h -> h.supports(executionContext)).toList(); - - if (matchingHandlers.isEmpty()) { - throw new IllegalStateException( - "No handler found for execution context: " + executionContext); - } - - if (matchingHandlers.size() > 1) { - throw new IllegalStateException( - "Multiple handlers matched execution context: " + matchingHandlers); - } - - return matchingHandlers.getFirst(); - } - /** * Adds an end date signature to inject expectations if the action is COMPLETE. * diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java index 3ac77c6aec8..b3c5a74b7c8 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java @@ -34,17 +34,6 @@ public InjectorExecutionProcessingHandler( this.injectorContractContentUtils = injectorContractContentUtils; } - /** - * Determines if this handler supports the given execution context (injector execution). - * - * @param executionContext the execution context to check - * @return true if the context is for an injector execution, false otherwise - */ - @Override - public boolean supports(ExecutionProcessingContext executionContext) { - return executionContext.isInjectorExecution(); - } - /** * Processes the execution context, generating structured output and handling additional * capabilities such as findings extraction, expectation matching, or asset creation. From 623e48960815e7fda9c53ea104a0f9c7fb9c8949 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Fri, 6 Mar 2026 13:23:14 +0100 Subject: [PATCH 17/21] [frontend] feat: add translation for new keys --- .../AgentExecutionProcessingHandlerTest.java | 12 --- .../service/InjectExecutionServiceTest.java | 75 +++++++------------ ...njectorExecutionProcessingHandlerTest.java | 15 ---- 3 files changed, 27 insertions(+), 75 deletions(-) diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java index 40c90490eea..dcfdc931f9d 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java @@ -1,6 +1,5 @@ package io.openaev.rest.inject.service; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; @@ -45,17 +44,6 @@ void setUp() { this.agent = AgentFixture.createDefaultAgentService(); } - @Test - @DisplayName("Should support only agent execution contexts") - void shouldSupportOnlyAgentExecutionContexts() { - ExecutionProcessingContext agentCtx = createValidCtx(); - ExecutionProcessingContext injectorCtx = - new ExecutionProcessingContext(inject, null, new InjectExecutionInput(), Map.of()); - - assertTrue(handler.supports(agentCtx)); - assertFalse(handler.supports(injectorCtx)); - } - @Test @DisplayName("Should return empty when status is not SUCCESS or action is not command execution") void shouldReturnEmptyWhenStatusNotSuccessOrActionNotExecution() throws Exception { diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java index 6fe10302ca6..5a438813918 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java @@ -1,6 +1,5 @@ package io.openaev.rest.inject.service; -import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -9,8 +8,6 @@ import io.openaev.rest.inject.form.InjectExecutionAction; import io.openaev.rest.inject.form.InjectExecutionInput; import io.openaev.service.InjectExpectationService; -import java.util.List; -import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -19,11 +16,13 @@ class InjectExecutionServiceTest { private InjectExecutionService service; - private ExecutionProcessingHandler handler; + private AgentExecutionProcessingHandler agentHandler; + private InjectorExecutionProcessingHandler injectorHandler; @BeforeEach void setUp() { - handler = mock(ExecutionProcessingHandler.class); + agentHandler = mock(AgentExecutionProcessingHandler.class); + injectorHandler = mock(InjectorExecutionProcessingHandler.class); InjectService injectService = mock(InjectService.class); InjectStatusService injectStatusService = mock(InjectStatusService.class); InjectExpectationService injectExpectationService = mock(InjectExpectationService.class); @@ -34,33 +33,13 @@ void setUp() { null, injectStatusService, injectService, - List.of(handler)); + agentHandler, + injectorHandler); } @Test - @DisplayName("Should resolve handler for agent context") - void shouldResolveExecutionContextForAgentContext() { - ExecutionProcessingContext agentContext = - new ExecutionProcessingContext( - mock(Inject.class), mock(Agent.class), mock(InjectExecutionInput.class), Map.of()); - when(handler.supports(agentContext)).thenReturn(true); - ExecutionProcessingHandler resolved = service.resolveExecutionContext(agentContext); - assertEquals(handler, resolved); - } - - @Test - @DisplayName("Should resolve handler for injector context") - void shouldResolveExecutionContextForInjectorContext() { - ExecutionProcessingContext injectorContext = - new ExecutionProcessingContext( - mock(Inject.class), null, mock(InjectExecutionInput.class), Map.of()); - when(handler.supports(injectorContext)).thenReturn(true); - ExecutionProcessingHandler resolved = service.resolveExecutionContext(injectorContext); - assertEquals(handler, resolved); - } - - @Test - @DisplayName("Should call processContext on handler in processInjectExecution") + @DisplayName( + "Should call processContext on handler in processInjectExecution when source is an agent") void shouldCallProcessContextOnHandlerInProcessInjectExecution() throws Exception { Inject inject = mock(Inject.class); Agent agent = mock(Agent.class); @@ -72,28 +51,28 @@ void shouldCallProcessContextOnHandlerInProcessInjectExecution() throws Exceptio input.setAction(InjectExecutionAction.command_execution); input.setStatus("SUCCESS"); - when(handler.supports(any())).thenReturn(true); - when(handler.processContext(any())).thenReturn(Optional.of(mock(ObjectNode.class))); + when(agentHandler.processContext(any())).thenReturn(Optional.of(mock(ObjectNode.class))); InjectExecutionService spyService = spy(service); - doReturn(handler).when(spyService).resolveExecutionContext(any()); - spyService.processInjectExecution(inject, agent, input); - verify(handler).processContext(any()); + spyService.processInjectExecutionWithAgent(inject, agent, input); + verify(agentHandler).processContext(any()); } @Test - @DisplayName("Should throw exception if no handler supports context") - void shouldThrowExceptionIfNoHandlerSupportsContext() { - ExecutionProcessingHandler nonSupportingHandler = mock(ExecutionProcessingHandler.class); - ExecutionProcessingContext context = - new ExecutionProcessingContext( - mock(Inject.class), null, mock(InjectExecutionInput.class), Map.of()); - when(nonSupportingHandler.supports(context)).thenReturn(false); - InjectExecutionService serviceWithNonSupportingHandler = - new InjectExecutionService(null, null, null, null, null, List.of(nonSupportingHandler)); - Exception ex = - assertThrows( - IllegalStateException.class, - () -> serviceWithNonSupportingHandler.resolveExecutionContext(context)); - assertTrue(ex.getMessage().contains("No handler found")); + @DisplayName( + "Should call processContext on handler in processInjectExecution when source is an injector") + void shouldCallProcessContextOnInjectorHandlerInProcessInjectExecution() throws Exception { + Inject inject = mock(Inject.class); + + InjectExecutionInput input = new InjectExecutionInput(); + String logMessage = + "{\"stdout\":\"[CVE-2025-25241] [http] [critical] http://seen-ip-endpoint/\\n[CVE-2025-25002] [http] [critical] http://seen-ip-endpoint/\\n\"}"; + input.setMessage(logMessage); + input.setAction(InjectExecutionAction.command_execution); + input.setStatus("SUCCESS"); + + when(injectorHandler.processContext(any())).thenReturn(Optional.of(mock(ObjectNode.class))); + InjectExecutionService spyService = spy(service); + spyService.processInjectExecutionWithInjector(inject, input); + verify(agentHandler).processContext(any()); } } diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java index 2414e8a0198..4d9bee7b353 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java @@ -1,6 +1,5 @@ package io.openaev.rest.inject.service; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -17,7 +16,6 @@ import io.openaev.rest.inject.form.InjectExecutionAction; import io.openaev.rest.inject.form.InjectExecutionInput; import io.openaev.rest.injector_contract.InjectorContractContentUtils; -import io.openaev.utils.fixtures.AgentFixture; import io.openaev.utils.fixtures.InjectFixture; import java.util.List; import java.util.Map; @@ -51,19 +49,6 @@ void setUp() { handler.mapper = mapper; } - @Test - @DisplayName("Should support only injector execution contexts") - void shouldSupportOnlyInjectorExecutionContexts() { - ExecutionProcessingContext injectorCtx = - new ExecutionProcessingContext(inject, null, new InjectExecutionInput(), Map.of()); - ExecutionProcessingContext agentCtx = - new ExecutionProcessingContext( - inject, AgentFixture.createDefaultAgentService(), new InjectExecutionInput(), Map.of()); - - assertTrue(handler.supports(injectorCtx)); - assertFalse(handler.supports(agentCtx)); - } - @Test @DisplayName("Should return empty if status is not SUCCESS or action is not COMPLETE") void shouldReturnEmptyWhenStatusNotSuccessOrActionNotComplete() throws Exception { From b9721f4b4b06dd65afa9cc95f389c64d8818b281 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Fri, 6 Mar 2026 13:27:06 +0100 Subject: [PATCH 18/21] [backend] feat: fix feedbacks pr --- .../openaev/rest/inject/service/InjectExecutionServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java index 5a438813918..c90c75fc0b2 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java @@ -73,6 +73,6 @@ void shouldCallProcessContextOnInjectorHandlerInProcessInjectExecution() throws when(injectorHandler.processContext(any())).thenReturn(Optional.of(mock(ObjectNode.class))); InjectExecutionService spyService = spy(service); spyService.processInjectExecutionWithInjector(inject, input); - verify(agentHandler).processContext(any()); + verify(injectorHandler).processContext(any()); } } From 93905a6f1d9002ae8353654190e8b9495e6ddd7b Mon Sep 17 00:00:00 2001 From: savacano28 Date: Fri, 6 Mar 2026 17:41:13 +0100 Subject: [PATCH 19/21] [backend] feat: fix feedbacks pr --- .../openaev/rest/finding/FindingService.java | 62 ++++- .../io/openaev/rest/inject/InjectApiTest.java | 223 ++++++++++++++++++ 2 files changed, 284 insertions(+), 1 deletion(-) diff --git a/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java index 3ff86ad4dc4..347bdf653c4 100644 --- a/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java +++ b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java @@ -218,7 +218,67 @@ public void createFindings( @NotNull final List findings, @NotBlank final String injectId) { Inject inject = injectService.inject(injectId); findings.forEach(finding -> finding.setInject(inject)); - findingRepository.saveAll(findings); + List deduplicatedFindings = deduplicateFindings(findings); + findingRepository.saveAll(deduplicatedFindings); + } + + /** + * Deduplicates a list of findings based on the unique constraint keys: value, type, and field. + * When duplicates are found, their assets, teams and users are merged into the first occurrence + * finding_field)}. + * + * @param findings the raw list of findings, potentially containing duplicates + * @return a deduplicated list with associations merged + */ + private List deduplicateFindings(@NotNull final List findings) { + Map seen = new java.util.LinkedHashMap<>(); + for (Finding finding : findings) { + String key = finding.getValue() + "|" + finding.getType() + "|" + finding.getField(); + Finding existing = seen.get(key); + if (existing == null) { + seen.put(key, finding); + } else { + log.debug( + "Duplicate finding detected (value={}, type={}, field={}): merging associations", + finding.getValue(), + finding.getType(), + finding.getField()); + if (finding.getAssets() != null) { + List merged = + new ArrayList<>(existing.getAssets() != null ? existing.getAssets() : List.of()); + finding + .getAssets() + .forEach( + a -> { + if (!merged.contains(a)) merged.add(a); + }); + existing.setAssets(merged); + } + if (finding.getTeams() != null) { + List merged = + new ArrayList<>(existing.getTeams() != null ? existing.getTeams() : List.of()); + finding + .getTeams() + .forEach( + t -> { + if (!merged.contains(t)) merged.add(t); + }); + existing.setTeams(merged); + } + if (finding.getUsers() != null) { + List merged = + new ArrayList<>(existing.getUsers() != null ? existing.getUsers() : List.of()); + finding + .getUsers() + .forEach( + u -> { + if (!merged.contains(u)) merged.add(u); + }); + existing.setUsers(merged); + } + } + } + return new ArrayList<>(seen.values()); } public List buildFindings( diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java index 19e51dfd0cb..40e524e00d0 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java @@ -1400,6 +1400,229 @@ void given_targetedAsset_should_linkFindingToIt() throws Exception { assertEquals(endpointSaved.getId(), findings.getLast().getAssets().getFirst().getId()); } + // Deduplication + + @Test + @DisplayName( + "Should consolidate duplicate CVE findings when structured output contains multiple entries with the same id") + void shouldConsolidateDuplicateCveFindingsWhenStructuredOutputContainsDuplicates() + throws Exception { + // -- PREPARE -- + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("Duplicate CVE findings test"); + input.setOutputStructured( + """ + { + "cve": [ + {"id": "CVE-2025-99999", "host": "192.168.1.10", "severity": "critical"}, + {"id": "CVE-2025-99999", "host": "192.168.1.20", "severity": "critical"} + ] + } + """); + input.setAction(InjectExecutionAction.complete); + input.setStatus("SUCCESS"); + + Inject inject = getPendingInjectWithAssets(); + Injector injector = InjectorFixture.createDefaultPayloadInjector(); + injectTestHelper.forceSaveInjector(injector); + + ObjectNode convertedContent = + (ObjectNode) + mapper.readTree( + """ + { + "outputs": [ + { + "field": "cve", + "isFindingCompatible": true, + "isMultiple": true, + "labels": [], + "type": "cve" + } + ] + } + """); + convertedContent.set(CONTRACT_CONTENT_FIELDS, objectMapper.valueToTree(List.of())); + InjectorContract injectorContract = + InjectorContractFixture.createInjectorContract(convertedContent); + injectorContract.setInjector(injector); + InjectorContract injectorContractSaved = + injectTestHelper.forceSaveInjectorContract(injectorContract); + inject.setInjectorContract(injectorContractSaved); + inject.setContent(convertedContent); + injectTestHelper.forceSaveInject(inject); + + // -- EXECUTE -- + performAgentlessCallbackRequest(inject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> !injectTestHelper.findFindingsByInjectId(inject.getId()).isEmpty()); + + List findings = findingRepository.findAllByInjectId(inject.getId()); + assertEquals( + 1, + findings.size(), + "Duplicate CVE findings with the same id must be consolidated into one"); + assertEquals(ContractOutputType.CVE, findings.getFirst().getType()); + assertEquals("CVE-2025-99999", findings.getFirst().getValue()); + } + + @Test + @DisplayName( + "Should consolidate duplicate Port findings when two scanned hosts both have the same port open") + void shouldConsolidateDuplicatePortFindingsWhenTwoHostsHaveSamePortOpen() throws Exception { + // -- PREPARE -- + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("nmap TCP connect scan"); + input.setOutputStructured( + """ + { + "ports": [22, 8080, 8080] + } + """); + input.setAction(InjectExecutionAction.complete); + input.setStatus("SUCCESS"); + + Inject inject = getPendingInjectWithAssets(); + Injector injector = InjectorFixture.createDefaultPayloadInjector(); + injectTestHelper.forceSaveInjector(injector); + + ObjectNode convertedContent = + (ObjectNode) + mapper.readTree( + """ + { + "outputs": [ + { + "field": "ports", + "isFindingCompatible": true, + "isMultiple": true, + "labels": ["scan"], + "type": "port" + } + ] + } + """); + convertedContent.set(CONTRACT_CONTENT_FIELDS, objectMapper.valueToTree(List.of())); + InjectorContract injectorContract = + InjectorContractFixture.createInjectorContract(convertedContent); + injectorContract.setInjector(injector); + InjectorContract injectorContractSaved = + injectTestHelper.forceSaveInjectorContract(injectorContract); + inject.setInjectorContract(injectorContractSaved); + inject.setContent(convertedContent); + injectTestHelper.forceSaveInject(inject); + + // -- EXECUTE -- + performAgentlessCallbackRequest(inject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> injectTestHelper.findFindingsByInjectId(inject.getId()).size() >= 2); + + List findings = findingRepository.findAllByInjectId(inject.getId()); + assertEquals( + 2, + findings.size(), + "Port 22 and port 8080 (deduplicated) must produce exactly 2 findings"); + assertTrue( + findings.stream().anyMatch(f -> f.getValue().equals("22")), + "Expected finding for port 22"); + assertTrue( + findings.stream().anyMatch(f -> f.getValue().equals("8080")), + "Expected deduplicated finding for port 8080"); + findings.forEach(f -> assertEquals(ContractOutputType.Port, f.getType())); + } + + @Test + @DisplayName( + "Should merge assets of duplicate PortsScan findings when two assets expose the same host/port/service") + void shouldMergeAssetsOfDuplicatePortScanFindingsWhenTwoAssetsHaveSameHostPortService() + throws Exception { + // -- PREPARE -- + Endpoint endpointA = EndpointFixture.createEndpoint(); + Endpoint endpointASaved = injectTestHelper.forceSaveEndpoint(endpointA); + Endpoint endpointB = EndpointFixture.createEndpoint(); + Endpoint endpointBSaved = injectTestHelper.forceSaveEndpoint(endpointB); + + InjectExecutionInput input = new InjectExecutionInput(); + input.setMessage("nmap scan_results two assets both expose 192.168.1.10:8080/http"); + input.setOutputStructured( + String.format( + """ + { + "scan_results": [ + {"asset_id": "%s", "host": "192.168.1.10", "port": "8080", "service": "http"}, + {"asset_id": "%s", "host": "192.168.1.10", "port": "8080", "service": "http"} + ] + } + """, + endpointASaved.getId(), endpointBSaved.getId())); + input.setAction(InjectExecutionAction.complete); + input.setStatus("SUCCESS"); + + Inject inject = getPendingInjectWithAssets(); + Injector injector = InjectorFixture.createDefaultPayloadInjector(); + injectTestHelper.forceSaveInjector(injector); + + ObjectNode convertedContent = + (ObjectNode) + mapper.readTree( + """ + { + "outputs": [ + { + "field": "scan_results", + "isFindingCompatible": true, + "isMultiple": true, + "labels": ["scan"], + "type": "portscan" + } + ] + } + """); + convertedContent.set(CONTRACT_CONTENT_FIELDS, objectMapper.valueToTree(List.of())); + InjectorContract injectorContract = + InjectorContractFixture.createInjectorContract(convertedContent); + injectorContract.setInjector(injector); + InjectorContract injectorContractSaved = + injectTestHelper.forceSaveInjectorContract(injectorContract); + inject.setInjectorContract(injectorContractSaved); + inject.setContent(convertedContent); + injectTestHelper.forceSaveInject(inject); + + // -- EXECUTE -- + performAgentlessCallbackRequest(inject.getId(), input); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> !injectTestHelper.findFindingsByInjectId(inject.getId()).isEmpty()); + + List findings = findingRepository.findAllByInjectId(inject.getId()); + assertEquals( + 1, + findings.size(), + "Two PortsScan entries with same host/port/service must be consolidated into one finding"); + Finding merged = findings.getFirst(); + assertEquals(ContractOutputType.PortsScan, merged.getType()); + assertEquals("192.168.1.10:8080 (http)", merged.getValue()); + List assetIds = merged.getAssets().stream().map(Asset::getId).toList(); + assertTrue( + assetIds.contains(endpointASaved.getId()), "Merged finding must be linked to asset A"); + assertTrue( + assetIds.contains(endpointBSaved.getId()), "Merged finding must be linked to asset B"); + } + // CVE @Test From 67b4110dcd0b418e21f9176ee09d1060a8f874f8 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Fri, 6 Mar 2026 18:21:42 +0100 Subject: [PATCH 20/21] [backend] feat: fix feedbacks pr --- .../src/main/java/io/openaev/rest/finding/FindingService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java index 347bdf653c4..0e46c74b6df 100644 --- a/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java +++ b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java @@ -224,8 +224,7 @@ public void createFindings( /** * Deduplicates a list of findings based on the unique constraint keys: value, type, and field. - * When duplicates are found, their assets, teams and users are merged into the first occurrence - * finding_field)}. + * When duplicates are found, their assets, teams and users are merged into the first occurrence. * * @param findings the raw list of findings, potentially containing duplicates * @return a deduplicated list with associations merged From 2fc9bc492c21b4b160dc638c5b75276fc2c10570 Mon Sep 17 00:00:00 2001 From: savacano28 Date: Fri, 6 Mar 2026 19:50:50 +0100 Subject: [PATCH 21/21] [backend] feat: fix feedbacks pr --- .../src/test/java/io/openaev/rest/inject/InjectApiTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java index 40e524e00d0..ff8fe6d363b 100644 --- a/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java +++ b/openaev-api/src/test/java/io/openaev/rest/inject/InjectApiTest.java @@ -1708,7 +1708,7 @@ void shouldCreateFindingForEachCredentialPairExtractedFromRawOutput() throws Exc Inject credInject = (Inject) setup[0]; String agentId = (String) setup[1]; - // domain\\user:pass format — each line produces one finding + // domain\\user:pass format, each line produces one finding String rawOutput = "SMB 192.168.11.23 445 SERVER [+] WORKGROUP\\\\alice:secret123 (Pwn3d!)\\n" + "SMB 192.168.11.23 445 SERVER [+] WORKGROUP\\\\bob:hunter2 (Pwn3d!)\\n";