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..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,57 +40,22 @@ 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. - */ - @Override - public String toFindingValue(JsonNode jsonNode) { - log.warn("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.warn("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.warn("Handler {} does not implement toFindingUsers, returning an empty list", type); - return Collections.emptyList(); - } + // UTILITY methods /** - * Extract team IDs from JSON node for finding linking. Override to provide custom logic, default - * returns empty list. + * 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) */ - public List toFindingTeams(JsonNode jsonNode) { - log.warn("Handler {} does not implement toFindingTeams, returning an empty list", type); - return Collections.emptyList(); - } - - // Utility methods protected String buildString(@NotNull final JsonNode jsonNode) { if (jsonNode.isArray()) { List values = new ArrayList<>(); @@ -108,6 +67,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 +85,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..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,14 +1,170 @@ package io.openaev.output_processor; -import io.openaev.database.model.ContractOutputTechnicalType; -import io.openaev.database.model.ContractOutputType; -import java.util.List; +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.*; +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.*; +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 executionContext, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + 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 079165d5267..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 @@ -4,20 +4,26 @@ 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.ExecutionProcessingContext; +import io.openaev.service.InjectExpectationService; import java.util.ArrayList; import java.util.Collections; import java.util.List; 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"; - public CVEOutputProcessor() { + private final InjectExpectationService injectExpectationService; + + public CVEOutputProcessor( + FindingService findingService, InjectExpectationService injectExpectationService) { super( ContractOutputType.CVE, ContractOutputTechnicalType.Object, @@ -26,7 +32,8 @@ public CVEOutputProcessor() { new ContractOutputField(ID, ContractOutputTechnicalType.Text, true), new ContractOutputField(HOST, ContractOutputTechnicalType.Text, true), new ContractOutputField(SEVERITY, ContractOutputTechnicalType.Text, true)), - true); + findingService); + this.injectExpectationService = injectExpectationService; } @Override @@ -34,7 +41,14 @@ public boolean validate(JsonNode jsonNode) { return jsonNode.hasNonNull(ID) && jsonNode.hasNonNull(HOST) && jsonNode.hasNonNull(SEVERITY); } - // Findings + /** Matches vulnerability expectations after findings are generated. */ + @Override + protected void afterFindings( + ExecutionProcessingContext executionContext, JsonNode structuredOutputNode) { + 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..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 @@ -4,23 +4,24 @@ 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 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"; - public CredentialsOutputProcessor() { + public CredentialsOutputProcessor(FindingService findingService) { super( ContractOutputType.Credentials, ContractOutputTechnicalType.Object, List.of( new ContractOutputField(USERNAME, ContractOutputTechnicalType.Text, true), new ContractOutputField(PASSWORD, ContractOutputTechnicalType.Text, true)), - true); + findingService); } @Override 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..f60c8a91c38 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/output_processor/FindingCapableOutputProcessor.java @@ -0,0 +1,80 @@ +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. + */ + 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 4df44cb8b5f..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 @@ -3,17 +3,18 @@ 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 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(); - public IPv4OutputProcessor() { - super(ContractOutputType.IPv4, ContractOutputTechnicalType.Text, List.of(), true); + public IPv4OutputProcessor(FindingService findingService) { + super(ContractOutputType.IPv4, ContractOutputTechnicalType.Text, List.of(), findingService); } @Override 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..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 @@ -3,17 +3,18 @@ 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 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(); - public IPv6OutputProcessor() { - super(ContractOutputType.IPv6, ContractOutputTechnicalType.Text, List.of(), true); + public IPv6OutputProcessor(FindingService findingService) { + super(ContractOutputType.IPv6, ContractOutputTechnicalType.Text, List.of(), findingService); } @Override 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..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 @@ -3,14 +3,15 @@ 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 java.util.List; import org.springframework.stereotype.Component; @Component -public class NumberOutputProcessor extends AbstractOutputProcessor { +public class NumberOutputProcessor extends FindingCapableOutputProcessor { - public NumberOutputProcessor() { - super(ContractOutputType.Number, ContractOutputTechnicalType.Number, List.of(), true); + public NumberOutputProcessor(FindingService findingService) { + super(ContractOutputType.Number, ContractOutputTechnicalType.Number, List.of(), findingService); } @Override 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..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 @@ -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; /** @@ -22,18 +24,14 @@ 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); - // 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..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 @@ -3,14 +3,15 @@ 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 java.util.List; import org.springframework.stereotype.Component; @Component -public class PortOutputProcessor extends AbstractOutputProcessor { +public class PortOutputProcessor extends FindingCapableOutputProcessor { - public PortOutputProcessor() { - super(ContractOutputType.Port, ContractOutputTechnicalType.Number, List.of(), true); + public PortOutputProcessor(FindingService findingService) { + super(ContractOutputType.Port, ContractOutputTechnicalType.Number, List.of(), findingService); } @Override 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..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 @@ -6,18 +6,20 @@ 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 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"; - public PortScanOutputProcessor() { + public PortScanOutputProcessor(FindingService findingService) { super( ContractOutputType.PortsScan, ContractOutputTechnicalType.Object, @@ -26,7 +28,7 @@ public PortScanOutputProcessor() { new ContractOutputField(HOST, ContractOutputTechnicalType.Text, true), new ContractOutputField(PORT, ContractOutputTechnicalType.Number, true), new ContractOutputField(SERVICE, ContractOutputTechnicalType.Text, true)), - true); + findingService); } @Override @@ -48,6 +50,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..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 @@ -3,14 +3,15 @@ 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 java.util.List; import org.springframework.stereotype.Component; @Component -public class TextOutputProcessor extends AbstractOutputProcessor { +public class TextOutputProcessor extends FindingCapableOutputProcessor { - public TextOutputProcessor() { - super(ContractOutputType.Text, ContractOutputTechnicalType.Text, List.of(), true); + public TextOutputProcessor(FindingService findingService) { + super(ContractOutputType.Text, ContractOutputTechnicalType.Text, List.of(), findingService); } @Override 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/finding/FindingService.java b/openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java index 5ca315c4d27..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 @@ -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,295 @@ 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)); + List deduplicatedFindings = deduplicateFindings(findings); + findingRepository.saveAll(deduplicatedFindings); } /** - * Function used to get the asset associated with a given structured output. + * 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. * - * @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. + * @param findings the raw list of findings, potentially containing duplicates + * @return a deduplicated list with associations merged */ - private Asset getAssetLinkedToStructuredOutput( - JsonNode struturedOutput, Map valueTargetedAssetsMap, Agent sourceAgent) { - if (valueTargetedAssetsMap.isEmpty() || !struturedOutput.has("host")) { - return sourceAgent.getAsset(); + 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()); + } - String host = struturedOutput.get("host").asText(); - return valueTargetedAssetsMap.keySet().stream() - .filter(host::contains) - .findFirst() - .map(valueTargetedAssetsMap::get) - .orElse(null); + 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; + } + + 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/AbstractExecutionProcessingHandler.java b/openaev-api/src/main/java/io/openaev/rest/inject/service/AbstractExecutionProcessingHandler.java new file mode 100644 index 00000000000..0c708a8532a --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/AbstractExecutionProcessingHandler.java @@ -0,0 +1,40 @@ +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. + * + * @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 new file mode 100644 index 00000000000..2a0ed49a1d5 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandler.java @@ -0,0 +1,78 @@ +package io.openaev.rest.inject.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.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 +public class AgentExecutionProcessingHandler extends AbstractExecutionProcessingHandler { + + private final StructuredOutputUtils structuredOutputUtils; + + public AgentExecutionProcessingHandler( + OutputProcessorFactory outputProcessorFactory, StructuredOutputUtils structuredOutputUtils) { + super(outputProcessorFactory); + this.structuredOutputUtils = structuredOutputUtils; + } + + /** + * 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 -> { + List contractOutputContexts = + getAllContractOutputs(outputParsers).stream() + .map(ContractOutputContext::from) + .toList(); + dispatchToProcessors(executionContext, contractOutputContexts, structuredOutput); + return structuredOutput; + }); + } + + /** + * Retrieves all contract output elements from the output parsers. + * + * @param outputParsers the set of output parsers to inspect + * @return list of contract output elements + */ + private List getAllContractOutputs(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..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 @@ -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,14 @@ 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); + 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/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..fd37bce4640 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/ExecutionProcessingHandler.java @@ -0,0 +1,25 @@ +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 { + /** + * 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..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 @@ -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,15 @@ 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 +29,13 @@ 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; + + private final AgentExecutionProcessingHandler agentExecutionProcessingHandler; + private final InjectorExecutionProcessingHandler injectorExecutionProcessingHandler; @Resource protected ObjectMapper mapper; @@ -75,51 +69,53 @@ 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); + if (agent == null) { + processInjectExecutionWithInjector(inject, input); + } else { + processInjectExecutionWithAgent(inject, agent, input); + } } catch (ElementNotFoundException e) { handleInjectExecutionError(inject, e); } } - /** Processes the execution of an inject by updating its status and extracting findings. */ - public void processInjectExecution( + 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 + * 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 + */ + private void processInjectExecution( Inject inject, @Nullable Agent agent, InjectExecutionInput input, - Set outputParsers) { - ObjectNode structured = null; + AbstractExecutionProcessingHandler handler) { 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 = handler.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); } } @@ -144,86 +140,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..b3c5a74b7c8 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandler.java @@ -0,0 +1,95 @@ +package io.openaev.rest.inject.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.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 +public class InjectorExecutionProcessingHandler extends AbstractExecutionProcessingHandler { + + @Resource protected ObjectMapper mapper; + private final InjectorContractContentUtils injectorContractContentUtils; + + public InjectorExecutionProcessingHandler( + OutputProcessorFactory outputProcessorFactory, + InjectorContractContentUtils injectorContractContentUtils) { + super(outputProcessorFactory); + this.injectorContractContentUtils = injectorContractContentUtils; + } + + /** + * 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 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. + * + * @param injectorContract the injector contract to inspect + * @return list of contract output elements + */ + private List getAllContractOutputs( + InjectorContract injectorContract) { + return injectorContractContentUtils + .getContractOutputs(injectorContract.getConvertedContent(), mapper) + .stream() + .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/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/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..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 @@ -4,69 +4,81 @@ 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; +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(), false); + super(null, null, Collections.emptyList()); } - } - private TestOutputProcessor processor; - private ObjectMapper objectMapper; + @Override + public void process( + ExecutionProcessingContext ctx, + ContractOutputContext contractOutputContext, + JsonNode structuredOutputNode) { + // No-op for testing purposes + } - @BeforeEach - void setUp() { - processor = new TestOutputProcessor(); - objectMapper = new ObjectMapper(); - } + private TestOutputProcessor processor; + private ObjectMapper 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); - } + @BeforeEach + void setUp() { + processor = new TestOutputProcessor(); + objectMapper = new ObjectMapper(); + } - @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 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 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 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 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 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 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 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")); + } } } 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..d292947ca59 --- /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":{}} + """); + assertTrue(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/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..7ab1c2ef1c2 --- /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.assertEquals; +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..a54a7dd83ba --- /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.assertEquals; +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..1b1ab2ef1ae --- /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.assertEquals; +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..1acf561d506 --- /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.assertEquals; +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..a119cf401ef --- /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.assertEquals; +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..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 @@ -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; @@ -41,7 +40,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 +61,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 +98,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 +122,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 +130,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 +931,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) @@ -965,6 +964,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 @@ -1289,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") @@ -1361,6 +1399,1214 @@ void given_targetedAsset_should_linkFindingToIt() throws Exception { assertEquals(1, findings.getLast().getAssets().size()); 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 + @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]; + + 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); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> injectTestHelper.findFindingsByInjectId(cveInject.getId()).size() >= 2); + + List cveFindings = injectTestHelper.findFindingsByInjectId(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(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> injectTestHelper.hasInjectStatusTrace(cveInject.getId())); + assertTrue( + injectTestHelper.findFindingsByInjectId(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(() -> injectTestHelper.findFindingsByInjectId(credInject.getId()).size() >= 2); + + List credFindings = injectTestHelper.findFindingsByInjectId(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(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> injectTestHelper.hasInjectStatusTrace(credInject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(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", "$1"); + RegexGroup portGroup = OutputParserFixture.getRegexGroup("port", "$2"); + RegexGroup serviceGroup = OutputParserFixture.getRegexGroup("service", "$3"); + ContractOutputElement portScanElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.PortsScan, + "(\\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)); + Object[] setup = buildInjectWithOutputParser(outputParser); + Inject portScanInject = (Inject) setup[0]; + String agentId = (String) setup[1]; + + 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); + + // -- ASSERT -- + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until( + () -> injectTestHelper.findFindingsByInjectId(portScanInject.getId()).size() >= 2); + + List portScanFindings = + injectTestHelper.findFindingsByInjectId(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", "$1"); + RegexGroup portGroup = OutputParserFixture.getRegexGroup("port", "$2"); + RegexGroup serviceGroup = OutputParserFixture.getRegexGroup("service", "$3"); + ContractOutputElement portScanElement = + OutputParserFixture.getContractOutputElement( + ContractOutputType.PortsScan, + "(\\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)); + 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(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> injectTestHelper.hasInjectStatusTrace(portScanInject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(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(() -> injectTestHelper.findFindingsByInjectId(portInject.getId()).size() >= 2); + + List portFindings = injectTestHelper.findFindingsByInjectId(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(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> injectTestHelper.hasInjectStatusTrace(portInject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(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(() -> !injectTestHelper.findFindingsByInjectId(textInject.getId()).isEmpty()); + + List textFindings = injectTestHelper.findFindingsByInjectId(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(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> injectTestHelper.hasInjectStatusTrace(textInject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(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(() -> injectTestHelper.findFindingsByInjectId(numberInject.getId()).size() >= 2); + + 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"))); + 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(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> injectTestHelper.hasInjectStatusTrace(numberInject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(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(() -> !injectTestHelper.findFindingsByInjectId(ipv4Inject.getId()).isEmpty()); + + 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")), + "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(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> injectTestHelper.hasInjectStatusTrace(ipv4Inject.getId())); + assertTrue(injectTestHelper.findFindingsByInjectId(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(() -> injectTestHelper.findFindingsByInjectId(ipv6Inject.getId()).size() >= 2); + + List ipv6Findings = injectTestHelper.findFindingsByInjectId(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(15, TimeUnit.SECONDS) + .with() + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> injectTestHelper.hasInjectStatusTrace(ipv6Inject.getId())); + 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); + } } } 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/AgentExecutionProcessingHandlerTest.java b/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java new file mode 100644 index 00000000000..dcfdc931f9d --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/AgentExecutionProcessingHandlerTest.java @@ -0,0 +1,212 @@ +package io.openaev.rest.inject.service; + +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 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 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 { + 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); + + 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()); + } + + @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); + + 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, + 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..c90c75fc0b2 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectExecutionServiceTest.java @@ -0,0 +1,78 @@ +package io.openaev.rest.inject.service; + +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.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 AgentExecutionProcessingHandler agentHandler; + private InjectorExecutionProcessingHandler injectorHandler; + + @BeforeEach + void setUp() { + agentHandler = mock(AgentExecutionProcessingHandler.class); + injectorHandler = mock(InjectorExecutionProcessingHandler.class); + InjectService injectService = mock(InjectService.class); + InjectStatusService injectStatusService = mock(InjectStatusService.class); + InjectExpectationService injectExpectationService = mock(InjectExpectationService.class); + service = + new InjectExecutionService( + null, + injectExpectationService, + null, + injectStatusService, + injectService, + agentHandler, + injectorHandler); + } + + @Test + @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); + + 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(agentHandler.processContext(any())).thenReturn(Optional.of(mock(ObjectNode.class))); + InjectExecutionService spyService = spy(service); + spyService.processInjectExecutionWithAgent(inject, agent, input); + verify(agentHandler).processContext(any()); + } + + @Test + @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(injectorHandler).processContext(any()); + } +} 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..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; @@ -565,23 +568,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..4d9bee7b353 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/rest/inject/service/InjectorExecutionProcessingHandlerTest.java @@ -0,0 +1,207 @@ +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 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; +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 java.util.Optional; +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 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, "{}"); + 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 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(); + 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()); + } + + @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, + 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..4faff2c15d0 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,368 @@ 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.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.*; 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 = + @DisplayName("Should return all prevention expectations when none expired") + 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 = + @DisplayName("Should return all detection expectations when none expired") + 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 = + @DisplayName("Should return all manual expectations when none expired") + 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 + @DisplayName("Should set not vulnerable when no output parsers") + void shouldSetNotVulnerableWhenNoOutputParsers() throws JsonProcessingException { + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + setupInjectWithOutputParser(null); + setupVulnerabilityExpectation(); + + injectExpectationService.matchesVulnerabilityExpectations( + createContext(new InjectExecutionInput()), mapper.createObjectNode()); + + verifySetResultExpectationVulnerableCalledOnce(mocked); + } + } + + @Test + @DisplayName("Should set not vulnerable when structured output is empty") + void shouldSetNotVulnerableWhenEmptyStructuredOutput() { + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + setupVulnerabilityExpectation(); + + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), mapper.createObjectNode()); + + verifySetResultExpectationVulnerableCalledOnce(mocked); + } + } + + @Test + @DisplayName("Should set not vulnerable when structured output has no CVE type") + 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 + @DisplayName("Should set vulnerable when structured output has CVE type and CVE data") + 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 + @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(); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + setupVulnerabilityExpectation(); + + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), structuredOutput); + + verifySetResultExpectationVulnerableCalledOnce(mocked); + } + } + + @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(); + structuredOutput.addObject().put("id", "CVE-2025-9999"); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + setupVulnerabilityExpectation(); + + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), structuredOutput); + + verifySetResultExpectationVulnerableCalledOnce(mocked); + } + } + + @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(); + 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 + @DisplayName("Should do nothing when expectations are not of vulnerability type") + 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 + @DisplayName("Should do nothing when expectation has a null agent") + 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 + @DisplayName("Should do nothing when inject has no expectations") + 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 + @DisplayName("Should save all expectations after processing") + void shouldSaveAllExpectationsAfterProcessing() { + setupVulnerabilityExpectation(); + + try (MockedStatic mocked = Mockito.mockStatic(ExpectationUtils.class)) { + injectExpectationService.matchesVulnerabilityExpectations( + createContext(buildDefaultInput(null)), mapper.createObjectNode()); + + verify(injectExpectationRepository, times(1)).saveAll(any()); + } + } + + @Test + @DisplayName("Should call update for each vulnerability expectation") + 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..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 @@ -2,14 +2,9 @@ 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 java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; @@ -33,11 +28,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()) @@ -98,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(); + } } 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 * 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(