Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.*;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.*;
Expand All @@ -47,15 +51,23 @@
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;

@TestInstance(PER_CLASS)
@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";
Expand All @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Comment on lines +228 to +241
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MockServer expectations are set up with withPath(""), but the OpenCTI GraphQL client posts to a URL ending with /graphql (see OpenCTIConfig#getApiUrl) and will therefore request path /graphql or /. As written, this expectation likely won’t match the real requests, causing connector registration/push to fail and the test to be flaky/failing. Consider matching the actual paths (e.g. / and /graphql) or using a broader matcher, and ensure the matching expectation is configured for the request you intend to intercept.

Copilot uses AI. Check for mistakes.

mockServer
.when(request().withMethod("POST").withPath("graphql"))
.respond(response().withStatusCode(200));
Comment on lines +241 to +245
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

registerOrPingAllConnectors() is invoked before the /graphql expectation is configured. Since connector registration calls the GraphQL endpoint, the registration HTTP call won’t be stubbed here and may fail, leaving the connector unregistered and breaking later pushes. Configure the GraphQL expectation before calling registerOrPingAllConnectors() (or move the registration call after all relevant expectations are set).

Suggested change
openCTIConnectorService.registerOrPingAllConnectors();
mockServer
.when(request().withMethod("POST").withPath("graphql"))
.respond(response().withStatusCode(200));
mockServer
.when(request().withMethod("POST").withPath("graphql"))
.respond(response().withStatusCode(200));
openCTIConnectorService.registerOrPingAllConnectors();

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MockServer response for the GraphQL endpoint is 200 with an empty body. OpenCTIClient expects a GraphQL-shaped JSON response containing at least a data or errors field; an empty body will be treated as an error response, causing registerConnector/pushStixBundle to throw ConnectorError. Return a minimal valid GraphQL JSON payload (e.g., { "data": {} }) for the stubbed /graphql requests.

Suggested change
.respond(response().withStatusCode(200));
.respond(
response()
.withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"data\":{}}"));

Copilot uses AI. Check for mistakes.
}

@Nested
Expand All @@ -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();
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -43,6 +45,7 @@ public class InjectorContractFixture {

@Autowired private InjectorContractRepository injectorContractRepository;
@Autowired private EmailInjectorIntegrationFactory emailInjectorIntegrationFactory;
@Autowired private ManualInjectorIntegrationFactory manualInjectorIntegrationFactory;

public InjectorContract getWellKnownSingleEmailContract() {
Optional<InjectorContract> injectorContract =
Expand Down Expand Up @@ -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> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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
Expand Down
Loading