diff --git a/openaev-api/src/main/java/io/openaev/service/stix/SecurityCoverageService.java b/openaev-api/src/main/java/io/openaev/service/stix/SecurityCoverageService.java index 8d5ab3124c5..c96bd19a5a0 100644 --- a/openaev-api/src/main/java/io/openaev/service/stix/SecurityCoverageService.java +++ b/openaev-api/src/main/java/io/openaev/service/stix/SecurityCoverageService.java @@ -18,6 +18,8 @@ import io.openaev.database.repository.ScenarioRepository; import io.openaev.database.repository.SecurityCoverageRepository; import io.openaev.opencti.connectors.impl.SecurityCoverageConnector; +import io.openaev.opencti.connectors.service.OpenCTIConnectorService; +import io.openaev.opencti.errors.ConnectorError; import io.openaev.rest.attack_pattern.service.AttackPatternService; import io.openaev.rest.exercise.service.ExerciseService; import io.openaev.rest.inject.service.InjectService; @@ -45,6 +47,7 @@ import io.openaev.utils.time.TimeUtils; import jakarta.annotation.Resource; import jakarta.validation.constraints.NotNull; +import java.io.IOException; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; @@ -78,7 +81,7 @@ public class SecurityCoverageService { @Resource private OpenAEVConfig openAEVConfig; private final ObjectMapper objectMapper; private final VulnerabilityService vulnerabilityService; - + private final OpenCTIConnectorService openCTIConnectorService; private final PreviewFeatureService previewFeatureService; // FIXME: don't access the connector directly when we deal with multiple origins @@ -305,9 +308,32 @@ public Scenario buildScenarioFromSecurityCoverage(SecurityCoverage securityCover "Creating or Updating Scenario with ID: {} from Security coverage with external ID: {}", scenario.getId(), securityCoverage.getExternalId()); + return scenario; } + /** + * Enrich and push the security coverage to OpenCTI. This injects the OpenAEV scenario external + * URL into the STIX object. + * + * @param scenario The scenario containing the security coverage. + * @throws ParsingException If STIX parsing fails. + * @throws ConnectorError If the OpenCTI push fails. + */ + public void pushSecurityCoverageBundleWithExternalURI(Scenario scenario) + throws ParsingException, ConnectorError, IOException { + SecurityCoverage coverage = scenario.getSecurityCoverage(); + String externalLink = openAEVConfig.getBaseUrl() + "/admin/scenarios/" + scenario.getId(); + + DomainObject sdo = (DomainObject) stixParser.parseObject(coverage.getContent()); + sdo.setProperty(CommonProperties.EXTERNAL_URI.toString(), new StixString(externalLink)); + + Bundle bundle = + new Bundle(new Identifier("bundle", UUID.randomUUID().toString()), List.of(sdo)); + + openCTIConnectorService.pushSecurityCoverageStixBundle(bundle); + } + /** * Updates an existing {@link Scenario} from a {@link SecurityCoverage}, or creates one if none is * associated with the coverage. diff --git a/openaev-api/src/main/java/io/openaev/service/stix/StixService.java b/openaev-api/src/main/java/io/openaev/service/stix/StixService.java index d8356d89389..c2f979e698a 100644 --- a/openaev-api/src/main/java/io/openaev/service/stix/StixService.java +++ b/openaev-api/src/main/java/io/openaev/service/stix/StixService.java @@ -1,10 +1,9 @@ package io.openaev.service.stix; -import com.fasterxml.jackson.databind.ObjectMapper; import io.openaev.database.model.Scenario; import io.openaev.database.model.SecurityCoverage; +import io.openaev.opencti.errors.ConnectorError; import io.openaev.rest.exception.BadRequestException; -import io.openaev.stix.parsing.Parser; import io.openaev.stix.parsing.ParsingException; import java.io.IOException; import lombok.RequiredArgsConstructor; @@ -18,8 +17,6 @@ public class StixService { private final SecurityCoverageService securityCoverageService; - private final ObjectMapper objectMapper; - private final Parser stixParser; /** * Generate or update a Scenario from Stix bundle @@ -28,7 +25,8 @@ public class StixService { * @return Scenario */ @Transactional(rollbackFor = Exception.class) - public Scenario processBundle(String stixJson) throws IOException, ParsingException { + public Scenario processBundle(String stixJson) + throws IOException, ParsingException, ConnectorError { try { // Update securityCoverage with the last bundle @@ -38,6 +36,7 @@ public Scenario processBundle(String stixJson) throws IOException, ParsingExcept // Update Scenario using the last SecurityCoverage Scenario scenario = securityCoverageService.buildScenarioFromSecurityCoverage(securityCoverage); + securityCoverageService.pushSecurityCoverageBundleWithExternalURI(scenario); return scenario; } catch (BadRequestException | ParsingException e) { throw e; diff --git a/openaev-api/src/test/java/io/openaev/api/stix_process/StixApiTest.java b/openaev-api/src/test/java/io/openaev/api/stix_process/StixApiTest.java index 410f173b0f2..381c1fdf441 100644 --- a/openaev-api/src/test/java/io/openaev/api/stix_process/StixApiTest.java +++ b/openaev-api/src/test/java/io/openaev/api/stix_process/StixApiTest.java @@ -12,6 +12,10 @@ import static org.assertj.core.api.Assertions.assertThatNoException; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -28,9 +32,9 @@ import io.openaev.database.repository.ScenarioRepository; import io.openaev.database.repository.SecurityCoverageRepository; import io.openaev.database.repository.TagRepository; -import io.openaev.integration.Manager; -import io.openaev.integration.impl.injectors.manual.ManualInjectorIntegrationFactory; +import io.openaev.opencti.connectors.service.OpenCTIConnectorService; import io.openaev.service.AssetGroupService; +import io.openaev.service.stix.SecurityCoverageService; import io.openaev.stix.objects.constants.CommonProperties; import io.openaev.utils.constants.StixConstants; import io.openaev.utils.fixtures.*; @@ -47,8 +51,15 @@ import java.util.*; import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.*; +import org.mockserver.configuration.Configuration; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.socket.PortFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; @@ -56,6 +67,7 @@ @Transactional @WithMockUser(withCapabilities = {Capability.MANAGE_STIX_BUNDLE}) @DisplayName("STIX API Integration Tests") +@TestPropertySource(properties = {"openaev.xtm.opencti.enable=true"}) class StixApiTest extends IntegrationTest { public static final String T_1531 = "T1531"; @@ -80,9 +92,14 @@ class StixApiTest extends IntegrationTest { @Autowired private InjectorContractComposer injectorContractComposer; @Autowired private TagComposer tagComposer; @Autowired private DomainComposer domainComposer; + @Autowired private ConnectorInstanceComposer connectorInstanceComposer; + @Autowired private ConnectorInstanceConfigurationComposer connectorInstanceConfigurationComposer; + @Autowired private CatalogConnectorComposer catalogConnectorComposer; @Autowired private InjectorFixture injectorFixture; - @Autowired private ManualInjectorIntegrationFactory manualInjectorIntegrationFactory; + @Autowired private InjectorContractFixture injectorContractFixture; + @MockitoSpyBean private SecurityCoverageService securityCoverageService; + @Autowired private OpenCTIConnectorService openCTIConnectorService; private JsonNode stixSecurityCoverage; private JsonNode stixSecurityCoverageNoDuration; @@ -93,9 +110,28 @@ class StixApiTest extends IntegrationTest { private JsonNode stixSecurityCoverageOnlyVulns; private JsonNode stixSecurityCoverageWithDomainName; + private static ClientAndServer mockServer; + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + mockServer = new ClientAndServer(Configuration.configuration(), PortFactory.findFreePort()); + registry.add( + "openaev.xtm.opencti.url", + () -> String.format("http://localhost:%d/", mockServer.getLocalPort())); + registry.add( + "openaev.test.connector.url", + () -> String.format("http://localhost:%d/", mockServer.getLocalPort())); + } + + @AfterAll + void after() { + if (mockServer != null) { + mockServer.stop(); + } + } + @BeforeEach void setUp() throws Exception { - new Manager(List.of(manualInjectorIntegrationFactory)).monitorIntegrations(); attackPatternComposer.reset(); vulnerabilityComposer.reset(); @@ -184,6 +220,29 @@ void setUp() throws Exception { vulnerabilityComposer.forVulnerability( VulnerabilityFixture.createVulnerabilityInput("CVE-2025-56786"))) .persist(); + + injectorContractComposer + .forInjectorContract(injectorContractFixture.getWellKnownSingleManualContract()) + .persist(); + + // need to mock unregistered connector to be use in process + mockServer + .when(request().withMethod("POST").withPath("")) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "data": {} + } + """)); + openCTIConnectorService.registerOrPingAllConnectors(); + + mockServer + .when(request().withMethod("POST").withPath("graphql")) + .respond(response().withStatusCode(200)); } @Nested @@ -205,7 +264,7 @@ class ImportStixBundles { .andReturn() .getResponse() .getContentAsString(); - + verify(securityCoverageService).pushSecurityCoverageBundleWithExternalURI(any()); assertThat(response).isNotBlank(); String scenarioId = JsonPath.read(response, "$.scenarioId"); Scenario createdScenario = scenarioRepository.findById(scenarioId).orElseThrow(); @@ -363,6 +422,7 @@ void shouldReturnBadRequestWhenStixStructureInvalid() throws Exception { @DisplayName( "Should create the scenario from stix bundle and not set recurrence end if not specified") void shouldCreateScenarioNoEnd() throws Exception { + String response = mvc.perform( post(STIX_URI + "/process-bundle") diff --git a/openaev-api/src/test/java/io/openaev/utils/fixtures/InjectorContractFixture.java b/openaev-api/src/test/java/io/openaev/utils/fixtures/InjectorContractFixture.java index 1bd6862f504..48b9783af9a 100644 --- a/openaev-api/src/test/java/io/openaev/utils/fixtures/InjectorContractFixture.java +++ b/openaev-api/src/test/java/io/openaev/utils/fixtures/InjectorContractFixture.java @@ -10,6 +10,7 @@ import static io.openaev.injector_contract.fields.ContractSelect.selectFieldWithDefault; import static io.openaev.injectors.email.EmailContract.EMAIL_DEFAULT; import static io.openaev.injectors.email.EmailContract.EMAIL_GLOBAL; +import static io.openaev.injectors.manual.ManualContract.MANUAL_DEFAULT; import static io.openaev.utils.fixtures.InjectorFixture.createDefaultPayloadInjector; import com.fasterxml.jackson.core.JsonProcessingException; @@ -29,6 +30,7 @@ import io.openaev.injector_contract.fields.ContractTargetedAsset; import io.openaev.integration.Manager; import io.openaev.integration.impl.injectors.email.EmailInjectorIntegrationFactory; +import io.openaev.integration.impl.injectors.manual.ManualInjectorIntegrationFactory; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.time.Instant; @@ -43,6 +45,7 @@ public class InjectorContractFixture { @Autowired private InjectorContractRepository injectorContractRepository; @Autowired private EmailInjectorIntegrationFactory emailInjectorIntegrationFactory; + @Autowired private ManualInjectorIntegrationFactory manualInjectorIntegrationFactory; public InjectorContract getWellKnownSingleEmailContract() { Optional injectorContract = @@ -302,4 +305,19 @@ public static void addTargetedAssetFields( arrayNode.add(objectMapper.valueToTree(targetPropertySelector)); injectorContract.getConvertedContent().set(CONTRACT_CONTENT_FIELDS, arrayNode); } + + public InjectorContract getWellKnownSingleManualContract() { + Optional injectorContract = + injectorContractRepository.findById(MANUAL_DEFAULT); + if (injectorContract.isPresent()) { + return injectorContract.get(); + } + try { + Manager manager = new Manager(List.of(manualInjectorIntegrationFactory)); + manager.monitorIntegrations(); + return injectorContractRepository.findById(MANUAL_DEFAULT).orElseThrow(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } } diff --git a/openaev-api/src/test/java/io/openaev/utils/fixtures/opencti/TestBeanConnector.java b/openaev-api/src/test/java/io/openaev/utils/fixtures/opencti/TestBeanConnector.java index ce4eb3564e1..9418e1a6ec0 100644 --- a/openaev-api/src/test/java/io/openaev/utils/fixtures/opencti/TestBeanConnector.java +++ b/openaev-api/src/test/java/io/openaev/utils/fixtures/opencti/TestBeanConnector.java @@ -4,6 +4,7 @@ import io.openaev.opencti.connectors.ConnectorType; import java.util.UUID; import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component @@ -12,6 +13,9 @@ public class TestBeanConnector extends ConnectorBase { private final String name = "Test Bean Connector"; private final ConnectorType type = ConnectorType.INTERNAL_ENRICHMENT; + @Value("${openaev.test.connector.url:#{'test opencti server url'}}") + private String url; + public TestBeanConnector() { this.setAuto(false); this.setOnlyContextual(false); @@ -22,12 +26,12 @@ public TestBeanConnector() { @Override public String getUrl() { - return "test opencti server url"; + return url; } @Override public String getApiUrl() { - return "test opencti server url"; + return url; } @Override