Repository: OpenAEV-Platform/openaev (java)

Issue:
Don't create a finding each time a scenario is run
## Context
When you run a scenario in OpenBAS, a new finding is generated each time the condition is matched—even if the same finding already exists for the same asset and inject (e.g., an open port like SSH on port 22). This leads to multiple redundant findings for the same problem across runs.

## Use case
We want to track issues like an open port (e.g., SSH on port 22) for a specific asset (e.g., an IP address). Instead of creating a new finding each time the scenario is run and the issue is still present, it would be more useful to update the existing finding to reflect that the issue persists. This allows for better historical tracking and avoids cluttering the findings list with duplicates.

## Proposed Solution
Update OpenBAS to:

Identify existing findings (based on asset, scenario) when a scenario is run.

Update the timestamp or metadata of the existing finding if the issue is still present, instead of creating a new one.

Close or expire the finding if it is no longer detected in subsequent scenario runs.

This would allow findings to represent the current state of an issue (present or resolved), rather than creating a new item for each scenario execution.

## Additional Information
This approach improves finding lifecycle management and aligns with the idea that a finding should represent the current state of an issue, not a historical log of every detection.


Discussion:

Patch:
diff --git a/openbas-api/src/main/java/io/openbas/migration/V3_90__add_automatic_finding_hash_column.java b/openbas-api/src/main/java/io/openbas/migration/V3_90__add_automatic_finding_hash_column.java
new file mode 100644
--- /dev/null
+++ b/openbas-api/src/main/java/io/openbas/migration/V3_90__add_automatic_finding_hash_column.java
@@ -0,0 +1,63 @@
+package io.openbas.migration;
+
+import java.sql.Statement;
+import org.flywaydb.core.api.migration.BaseJavaMigration;
+import org.flywaydb.core.api.migration.Context;
+import org.springframework.stereotype.Component;
+
+@Component
+public class V3_90__add_automatic_finding_hash_column extends BaseJavaMigration {
+
+  @Override
+  public void migrate(Context context) throws Exception {
+    try (Statement statement = context.getConnection().createStatement()) {
+
+      String updateInjectExpectationResult =
+          """
+          -- necessary for hashing functions
+          CREATE SEQUENCE IF NOT EXISTS exercise_launch_order_seq
+          AS BIGINT
+          INCREMENT BY 1
+          START WITH 1;
+
+          ALTER TABLE exercises
+          ADD COLUMN exercise_launch_order BIGINT;
+
+          CREATE OR REPLACE FUNCTION update_launch_order_trigger()
+          RETURNS TRIGGER AS $$
+          BEGIN
+              UPDATE exercises
+              SET exercise_launch_order = CASE
+                  WHEN NEW.exercise_start_date IS NULL
+                      THEN NULL
+                  ELSE nextval('exercise_launch_order_seq')
+                  END
+              WHERE exercise_id = NEW.exercise_id;
+              RETURN NEW;
+          END;
+          $$ LANGUAGE plpgsql;
+
+          CREATE OR REPLACE TRIGGER after_insert_exercise
+          AFTER INSERT ON exercises
+          FOR EACH ROW
+          EXECUTE PROCEDURE update_launch_order_trigger();
+
+          CREATE OR REPLACE TRIGGER after_update_exercise_start_date
+          AFTER UPDATE OF exercise_start_date ON exercises
+          FOR EACH ROW
+          EXECUTE PROCEDURE update_launch_order_trigger();
+
+          -- migration of existing records
+          UPDATE exercises e
+          SET exercise_launch_order = migrated_start_order
+          FROM (SELECT exercise_id, nextval('exercise_launch_order_seq') as migrated_start_order
+                FROM exercises
+                WHERE exercise_start_date IS NOT NULL
+                ORDER BY exercise_start_date DESC NULLS LAST) o
+          WHERE e.exercise_id = o.exercise_id;
+          """;
+
+      statement.executeUpdate(updateInjectExpectationResult);
+    }
+  }
+}
diff --git a/openbas-api/src/main/java/io/openbas/rest/finding/FindingApi.java b/openbas-api/src/main/java/io/openbas/rest/finding/FindingApi.java
--- a/openbas-api/src/main/java/io/openbas/rest/finding/FindingApi.java
+++ b/openbas-api/src/main/java/io/openbas/rest/finding/FindingApi.java
@@ -37,7 +37,12 @@ public class FindingApi extends RestBehavior {
   @PostMapping("/search")
   public Page<FindingOutput> findings(
       @RequestBody @Valid final SearchPaginationInput searchPaginationInput) {
-    return buildPaginationJPA(this.findingRepository::findAll, searchPaginationInput, Finding.class)
+    return buildPaginationJPA(
+            (specification, pageable) ->
+                this.findingRepository.findAll(
+                    FindingSpecification.forLatestSimulations().and(specification), pageable),
+            searchPaginationInput,
+            Finding.class)
         .map(findingMapper::toFindingOutput);
   }
 
@@ -79,14 +84,19 @@ public Page<FindingOutput> findingsBySimulation(
   public Page<FindingOutput> findingsByScenario(
       @PathVariable @NotNull final String scenarioId,
       @RequestBody @Valid final SearchPaginationInput searchPaginationInput) {
-    return buildPaginationJPA(
-            (Specification<Finding> specification, Pageable pageable) ->
-                this.findingRepository.findAll(
-                    FindingSpecification.findFindingsForScenario(scenarioId).and(specification),
-                    pageable),
-            searchPaginationInput,
-            Finding.class)
-        .map(findingMapper::toFindingOutput);
+    Page<FindingOutput> page =
+        buildPaginationJPA(
+                (Specification<Finding> specification, Pageable pageable) ->
+                    this.findingRepository.findAll(
+                        FindingSpecification.findFindingsForScenario(scenarioId)
+                            .and(FindingSpecification.forLatestSimulations())
+                            .and(specification),
+                        pageable),
+                searchPaginationInput,
+                Finding.class)
+            .map(findingMapper::toFindingOutput);
+
+    return page;
   }
 
   @LogExecutionTime
@@ -98,7 +108,9 @@ public Page<FindingOutput> findingsByEndpoint(
     return buildPaginationJPA(
             (Specification<Finding> specification, Pageable pageable) ->
                 this.findingRepository.findAll(
-                    FindingSpecification.findFindingsForEndpoint(endpointId).and(specification),
+                    FindingSpecification.findFindingsForEndpoint(endpointId)
+                        .and(FindingSpecification.forLatestSimulations())
+                        .and(specification),
                     pageable),
             searchPaginationInput,
             Finding.class)
diff --git a/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/ExcludeMembersOfAssetGroupsSpecification.java b/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/ExcludeMembersOfAssetGroupsSpecification.java
--- a/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/ExcludeMembersOfAssetGroupsSpecification.java
+++ b/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/ExcludeMembersOfAssetGroupsSpecification.java
@@ -52,8 +52,7 @@ private Specification<T> getDynamicFilterSpecification(
           .select(criteriaBuilder.literal(1))
           .where(
               positiveSpec.toPredicate(assetTable, query, criteriaBuilder),
-              criteriaBuilder.equal(
-                  finalFrom.get("id"), query.getRoots().stream().findFirst().get().get("id")));
+              criteriaBuilder.equal(finalFrom.get("id"), root.get("id")));
       return criteriaBuilder.exists(subQuery).not();
     });
   }
@@ -68,8 +67,7 @@ private Specification<T> getTransitiveTargetingSpecification(
       subQuery
           .select(criteriaBuilder.literal(1))
           .where(
-              criteriaBuilder.equal(
-                  finalFrom.get("id"), query.getRoots().stream().findFirst().get().get("id")),
+              criteriaBuilder.equal(finalFrom.get("id"), root.get("id")),
               assetGroupTable.get("id").in(assetGroupIds.stream().toList()));
       return criteriaBuilder.exists(subQuery).not();
     };
diff --git a/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/IncludeDirectEndpointTargetsSpecification.java b/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/IncludeDirectEndpointTargetsSpecification.java
--- a/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/IncludeDirectEndpointTargetsSpecification.java
+++ b/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/IncludeDirectEndpointTargetsSpecification.java
@@ -30,8 +30,7 @@ private Specification<T> getDirectTargetingSpecification(
           .select(criteriaBuilder.literal(1))
           .where(
               criteriaBuilder.equal(injectTable.get("id"), scopedInject.getId()),
-              criteriaBuilder.equal(
-                  finalFrom.get("id"), query.getRoots().stream().findFirst().get().get("id")));
+              criteriaBuilder.equal(finalFrom.get("id"), root.get("id")));
       return criteriaBuilder.exists(subQuery);
     };
   }
diff --git a/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/IncludeMembersOfAssetGroupsSpecification.java b/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/IncludeMembersOfAssetGroupsSpecification.java
--- a/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/IncludeMembersOfAssetGroupsSpecification.java
+++ b/openbas-api/src/main/java/io/openbas/service/targets/search/specifications/IncludeMembersOfAssetGroupsSpecification.java
@@ -52,8 +52,7 @@ private Specification<T> getDynamicFilterSpecification(
           .select(criteriaBuilder.literal(1))
           .where(
               positiveSpec.toPredicate(assetTable, query, criteriaBuilder),
-              criteriaBuilder.equal(
-                  finalJoin.get("id"), query.getRoots().stream().findFirst().get().get("id")));
+              criteriaBuilder.equal(finalJoin.get("id"), root.get("id")));
       return criteriaBuilder.exists(subQuery);
     });
   }
@@ -68,8 +67,7 @@ private Specification<T> getTransitiveTargetingSpecification(
       subQuery
           .select(criteriaBuilder.literal(1))
           .where(
-              criteriaBuilder.equal(
-                  finalJoin.get("id"), query.getRoots().stream().findFirst().get().get("id")),
+              criteriaBuilder.equal(finalJoin.get("id"), root.get("id")),
               assetGroupTable.get("id").in(assetGroupIds));
       return criteriaBuilder.exists(subQuery);
     };
diff --git a/openbas-model/src/main/java/io/openbas/database/model/Exercise.java b/openbas-model/src/main/java/io/openbas/database/model/Exercise.java
--- a/openbas-model/src/main/java/io/openbas/database/model/Exercise.java
+++ b/openbas-model/src/main/java/io/openbas/database/model/Exercise.java
@@ -23,6 +23,7 @@
 import java.time.Instant;
 import java.util.*;
 import java.util.stream.Collectors;
+import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.Setter;
 import org.hibernate.annotations.UuidGenerator;
@@ -92,6 +93,11 @@ public class Exercise implements Base {
   @Queryable(filterable = true, sortable = true)
   private Instant start;
 
+  @Column(name = "exercise_launch_order", insertable = false, updatable = false)
+  @JsonIgnore
+  @Setter(AccessLevel.NONE)
+  private Long launchOrder;
+
   @Column(name = "exercise_end_date")
   @JsonProperty("exercise_end_date")
   private Instant end;
diff --git a/openbas-model/src/main/java/io/openbas/database/model/Inject.java b/openbas-model/src/main/java/io/openbas/database/model/Inject.java
--- a/openbas-model/src/main/java/io/openbas/database/model/Inject.java
+++ b/openbas-model/src/main/java/io/openbas/database/model/Inject.java
@@ -252,6 +252,7 @@ public class Inject implements Base, Injection {
   private List<InjectExpectation> expectations = new ArrayList<>();
 
   @JsonIgnore
+  @Getter
   @OneToMany(mappedBy = "inject", cascade = CascadeType.ALL, orphanRemoval = true)
   private List<Finding> findings = new ArrayList<>();
 
diff --git a/openbas-model/src/main/java/io/openbas/database/specification/FindingSpecification.java b/openbas-model/src/main/java/io/openbas/database/specification/FindingSpecification.java
--- a/openbas-model/src/main/java/io/openbas/database/specification/FindingSpecification.java
+++ b/openbas-model/src/main/java/io/openbas/database/specification/FindingSpecification.java
@@ -1,6 +1,8 @@
 package io.openbas.database.specification;
 
+import io.openbas.database.model.ExerciseStatus;
 import io.openbas.database.model.Finding;
+import jakarta.persistence.criteria.*;
 import org.jetbrains.annotations.NotNull;
 import org.springframework.data.jpa.domain.Specification;
 
@@ -26,4 +28,32 @@ public static Specification<Finding> findFindingsForScenario(@NotNull final Stri
   public static Specification<Finding> findFindingsForEndpoint(@NotNull final String endpointId) {
     return (root, query, cb) -> cb.equal(root.get("assets").get("id"), endpointId);
   }
+
+  public static Specification<Finding> forLatestSimulations() {
+    return (root, query, cb) -> {
+      Join<?, ?> exerciseJoin1 =
+          root.join("inject", JoinType.INNER).join("exercise", JoinType.LEFT);
+      Join<?, ?> exerciseJoin2 =
+          exerciseJoin1.join("scenario", JoinType.LEFT).join("exercises", JoinType.LEFT);
+
+      exerciseJoin2.on(
+          cb.and(
+              cb.equal(
+                  exerciseJoin1.get("scenario").get("id"), exerciseJoin2.get("scenario").get("id")),
+              // check this column is not null for joining
+              cb.isNotNull(exerciseJoin1.get("launchOrder")),
+              cb.isNotNull(exerciseJoin2.get("launchOrder")),
+              // only consider finished simulations
+              cb.equal(exerciseJoin1.get("status"), ExerciseStatus.FINISHED),
+              cb.equal(exerciseJoin2.get("status"), ExerciseStatus.FINISHED),
+              // trim to "latest" simulation
+              cb.lessThan(exerciseJoin1.get("launchOrder"), exerciseJoin2.get("launchOrder"))));
+
+      return cb.and(
+          cb.isNull(exerciseJoin2.get("id")),
+          cb.or(
+              cb.equal(exerciseJoin1.get("status"), ExerciseStatus.FINISHED),
+              cb.isNull(exerciseJoin1.get("id"))));
+    };
+  }
 }


Test patch:
diff --git a/openbas-api/src/test/java/io/openbas/rest/finding/EsFindingServiceTest.java b/openbas-api/src/test/java/io/openbas/rest/finding/EsFindingServiceTest.java
--- a/openbas-api/src/test/java/io/openbas/rest/finding/EsFindingServiceTest.java
+++ b/openbas-api/src/test/java/io/openbas/rest/finding/EsFindingServiceTest.java
@@ -1,6 +1,6 @@
 package io.openbas.rest.finding;
 
-import static io.openbas.rest.finding.FindingFixture.*;
+import static io.openbas.utils.fixtures.FindingFixture.*;
 import static io.openbas.utils.fixtures.InjectFixture.getDefaultInject;
 import static org.junit.jupiter.api.Assertions.*;
 
diff --git a/openbas-api/src/test/java/io/openbas/rest/finding/FindingApiTest.java b/openbas-api/src/test/java/io/openbas/rest/finding/FindingApiTest.java
--- a/openbas-api/src/test/java/io/openbas/rest/finding/FindingApiTest.java
+++ b/openbas-api/src/test/java/io/openbas/rest/finding/FindingApiTest.java
@@ -1,7 +1,9 @@
 package io.openbas.rest.finding;
 
-import static io.openbas.rest.finding.FindingFixture.TEXT_FIELD;
+import static io.openbas.helper.StreamHelper.fromIterable;
 import static io.openbas.utils.JsonUtils.asJsonString;
+import static io.openbas.utils.fixtures.FindingFixture.TEXT_FIELD;
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
 import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -10,6 +12,9 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 import io.openbas.IntegrationTest;
 import io.openbas.database.model.*;
+import io.openbas.database.repository.FindingRepository;
+import io.openbas.rest.finding.form.FindingOutput;
+import io.openbas.utils.FindingMapper;
 import io.openbas.utils.fixtures.*;
 import io.openbas.utils.fixtures.composers.*;
 import io.openbas.utils.mockUser.WithMockAdminUser;
@@ -20,11 +25,11 @@
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
+import java.util.Hashtable;
 import java.util.List;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.TestInstance;
+import java.util.Map;
+import net.javacrumbs.jsonunit.core.Option;
+import org.junit.jupiter.api.*;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.MediaType;
 import org.springframework.test.web.servlet.MockMvc;
@@ -33,6 +38,7 @@
 @TestInstance(PER_CLASS)
 @Transactional
 @WithMockAdminUser
+@DisplayName("Findings search tests")
 class FindingApiTest extends IntegrationTest {
 
   private static final String FINDING_URI = "/api/findings";
@@ -44,176 +50,768 @@ class FindingApiTest extends IntegrationTest {
   @Autowired private AssetGroupComposer assetGroupComposer;
   @Autowired private EndpointComposer endpointComposer;
   @Autowired private InjectComposer injectComposer;
+  @Autowired private InjectorContractComposer injectorContractComposer;
   @Autowired private ScenarioComposer scenarioComposer;
   @Autowired private ExerciseComposer simulationComposer;
   @Autowired private AgentComposer agentComposer;
   @Autowired private TagComposer tagComposer;
+  @Autowired private InjectorFixture injectorFixture;
+  @Autowired private FindingRepository findingRepository;
+  @Autowired private FindingMapper findingMapper;
   @Autowired private EntityManager entityManager;
-  private Exercise savedSimulation;
-  private Scenario savedScenario;
-  private AssetGroup savedAssetGroup;
-  private Endpoint savedEndpoint;
-  private InjectComposer.Composer injectComposer1;
-  private ExerciseComposer.Composer simulationComposer1;
-  private EndpointComposer.Composer endpointComposer1;
 
   @BeforeEach
-  void setup() {
-    endpointComposer1 = endpointComposer.forEndpoint(EndpointFixture.createEndpoint());
-
-    savedEndpoint =
-        endpointComposer1
-            .withAgent(agentComposer.forAgent(AgentFixture.createDefaultAgentService()))
-            .persist()
-            .get();
-
-    AssetGroupComposer.Composer assetGroupComposer =
-        this.assetGroupComposer
-            .forAssetGroup(AssetGroupFixture.createDefaultAssetGroup("asset-group"))
-            .withAsset(endpointComposer1);
-
-    savedAssetGroup = assetGroupComposer.persist().get();
-
-    injectComposer1 =
-        injectComposer
-            .forInject(InjectFixture.getDefaultInject())
-            .withAssetGroup(assetGroupComposer);
-
-    simulationComposer1 =
-        simulationComposer
-            .forExercise(ExerciseFixture.createRunningAttackExercise())
-            .withInject(injectComposer1);
-
-    savedScenario =
-        scenarioComposer
-            .forScenario(ScenarioFixture.createDefaultCrisisScenario())
-            .withSimulation(simulationComposer1)
-            .persist()
-            .get();
-
-    savedSimulation = savedScenario.getExercises().getFirst();
+  void setUp() {
+    scenarioComposer.reset();
+    simulationComposer.reset();
+    injectComposer.reset();
+    tagComposer.reset();
+    agentComposer.reset();
+    findingComposer.reset();
+    endpointComposer.reset();
+    assetGroupComposer.reset();
+    injectorContractComposer.reset();
   }
 
-  @DisplayName("Search global findings")
-  @Test
-  public void given_a_search_input_should_return_page_of_findings() throws Exception {
-    Finding savedFinding =
-        findingComposer
-            .forFinding(FindingFixture.createDefaultTextFinding())
-            .withEndpoint(endpointComposer.forEndpoint(savedEndpoint))
-            .withInject(injectComposer1)
-            .withTag(tagComposer.forTag(TagFixture.getTagWithText("Finding")))
-            .persist()
-            .get();
-    SearchPaginationInput input =
-        buildDefaultFilters(
-            ContractOutputType.Text,
-            "Text",
-            savedFinding,
-            savedSimulation,
-            savedScenario,
-            savedEndpoint,
-            savedAssetGroup);
-
-    entityManager.flush();
-    entityManager.clear();
-
-    performCallbackRequest(FINDING_URI + "/search", input)
-        .andExpect(jsonPath("$.content.[0].finding_type").value(savedFinding.getType().label))
-        .andExpect(jsonPath("$.content.[0].finding_name").value("Text"))
-        .andExpect(jsonPath("$.content.[0].finding_field").value(TEXT_FIELD))
-        .andExpect(jsonPath("$.content.[0].finding_value").value("text_value"))
-        .andExpect(
-            jsonPath("$.content.[0].finding_assets.[0].asset_id").value(savedEndpoint.getId()))
-        .andExpect(
-            jsonPath("$.content.[0].finding_inject.inject_id")
-                .value(savedFinding.getInject().getId()))
-        .andExpect(
-            jsonPath("$.content.[0].finding_simulation.exercise_id").value(savedSimulation.getId()))
-        .andExpect(
-            jsonPath("$.content.[0].finding_scenario.scenario_id").value(savedScenario.getId()));
-  }
+  @Nested
+  @DisplayName("With several simulations from same scenario in database")
+  class WithSeveralSimulationsFromSameScenario {
+    private final int numberOfPreviousSimulations = 5;
+    private final String firstInjectName = "firstInjectName";
+    private final String secondInjectName = "secondInjectName";
+    private final String thirdInjectName = "thirdInjectName";
+    private final String fourthInjectName = "fourthInjectName";
+
+    private ScenarioComposer.Composer getScenarioWithSimulationsWrapper() {
+      ScenarioComposer.Composer scenarioWrapper =
+          scenarioComposer.forScenario(ScenarioFixture.getScenario());
+
+      // add simulations with default findings
+      for (int i = 0; i < numberOfPreviousSimulations; i++) {
+        Hashtable<String, InjectComposer.Composer> injects =
+            attachSimulationToScenario(
+                scenarioWrapper, ExerciseFixture.createFinishedAttackExercise());
+        for (Map.Entry<String, InjectComposer.Composer> entry : injects.entrySet()) {
+          for (FindingComposer.Composer findingWrapper : getDefaultFindings()) {
+            entry.getValue().withFinding(findingWrapper);
+          }
+        }
+      }
+
+      return scenarioWrapper;
+    }
 
-  @Test
-  @DisplayName("Search findings by simulation")
-  void should_return_findings_by_simulation() throws Exception {
-    Finding savedFinding =
-        findingComposer
-            .forFinding(FindingFixture.createDefaultIPV6Finding())
-            .withEndpoint(endpointComposer.forEndpoint(savedEndpoint))
-            .withInject(injectComposer1)
-            .withTag(tagComposer.forTag(TagFixture.getTagWithText("Finding IPv6")))
-            .persist()
-            .get();
-    SearchPaginationInput input =
-        buildDefaultFilters(
-            ContractOutputType.IPv6,
-            "IPv6",
-            savedFinding,
-            savedSimulation,
-            null,
-            savedEndpoint,
-            null);
-
-    performCallbackRequest(FINDING_URI + "/exercises/" + savedSimulation.getId() + "/search", input)
-        .andExpect(jsonPath("$.content.[0].finding_type").value(savedFinding.getType().label))
-        .andExpect(
-            jsonPath("$.content.[0].finding_value")
-                .value("2001:0000:130F:0000:0000:09C0:876A:130B"));
-  }
+    private Hashtable<String, InjectComposer.Composer> attachSimulationToScenario(
+        ScenarioComposer.Composer scenarioWrapper, Exercise simulationFixture) {
+      // create arbitrary injects
+      Hashtable<String, InjectComposer.Composer> injects = new Hashtable<>();
+      injects.put(
+          firstInjectName,
+          injectComposer
+              .forInject(InjectFixture.getDefaultInject())
+              .withInjectorContract(
+                  injectorContractComposer
+                      .forInjectorContract(InjectorContractFixture.createDefaultInjectorContract())
+                      .withInjector(injectorFixture.getWellKnownObasImplantInjector())));
+      injects.put(
+          secondInjectName,
+          injectComposer
+              .forInject(InjectFixture.getDefaultInject())
+              .withInjectorContract(
+                  injectorContractComposer
+                      .forInjectorContract(InjectorContractFixture.createDefaultInjectorContract())
+                      .withInjector(injectorFixture.getWellKnownObasImplantInjector())));
+      injects.put(
+          thirdInjectName,
+          injectComposer
+              .forInject(InjectFixture.getDefaultInject())
+              .withInjectorContract(
+                  injectorContractComposer
+                      .forInjectorContract(InjectorContractFixture.createDefaultInjectorContract())
+                      .withInjector(injectorFixture.getWellKnownObasImplantInjector())));
+      injects.put(
+          fourthInjectName,
+          injectComposer
+              .forInject(InjectFixture.getDefaultInject())
+              .withInjectorContract(
+                  injectorContractComposer
+                      .forInjectorContract(InjectorContractFixture.createDefaultInjectorContract())
+                      .withInjector(injectorFixture.getWellKnownObasImplantInjector())));
+
+      ExerciseComposer.Composer simulationWrapper =
+          simulationComposer.forExercise(simulationFixture);
+      for (Map.Entry<String, InjectComposer.Composer> entry : injects.entrySet()) {
+        simulationWrapper.withInject(entry.getValue());
+      }
+
+      scenarioWrapper.withSimulation(simulationWrapper);
+
+      return injects;
+    }
+
+    private List<FindingComposer.Composer> getDefaultFindings() {
+      return new ArrayList<>(
+          List.of(
+              findingComposer.forFinding(FindingFixture.createDefaultTextFindingWithRandomValue()),
+              findingComposer.forFinding(FindingFixture.createDefaultTextFindingWithRandomValue()),
+              findingComposer.forFinding(FindingFixture.createDefaultTextFindingWithRandomValue()),
+              findingComposer.forFinding(
+                  FindingFixture.createDefaultTextFindingWithRandomValue())));
+    }
+
+    @Nested
+    @DisplayName("When searching globally for findings")
+    class WhenSearchingGloballyForFindings {
+      @Test
+      @DisplayName("Returns only findings for latest simulation of each scenario")
+      public void ReturnsOnlyFindingsForLatestSimulationOfEachScenario() throws Exception {
+        List<ScenarioComposer.Composer> scenarioWrappers =
+            List.of(getScenarioWithSimulationsWrapper(), getScenarioWithSimulationsWrapper());
+
+        // latest findings
+        List<FindingComposer.Composer> latestFindingWrappers = new ArrayList<>();
+
+        // add latest simulation to each scenario
+        for (ScenarioComposer.Composer scenarioWrapper : scenarioWrappers) {
+          Hashtable<String, InjectComposer.Composer> latestSimulationInjectWrappers =
+              attachSimulationToScenario(
+                  scenarioWrapper, ExerciseFixture.createFinishedAttackExercise());
+          for (Map.Entry<String, InjectComposer.Composer> entry :
+              latestSimulationInjectWrappers.entrySet()) {
+            FindingComposer.Composer findingWrapper =
+                findingComposer.forFinding(
+                    FindingFixture.createDefaultTextFindingWithRandomValue());
+            latestFindingWrappers.add(findingWrapper);
+            entry.getValue().withFinding(findingWrapper);
+          }
+          scenarioWrapper.persist();
+        }
+
+        // add injects (atomic testing) with findings too
+        for (int i = 0; i < 2; i++) {
+          FindingComposer.Composer findingWrapper =
+              findingComposer.forFinding(FindingFixture.createDefaultTextFindingWithRandomValue());
+          latestFindingWrappers.add(findingWrapper);
+          injectComposer
+              .forInject(InjectFixture.getDefaultInject())
+              .withFinding(findingWrapper)
+              .persist();
+        }
+
+        SearchPaginationInput input = PaginationFixture.getDefault().build();
+
+        entityManager.flush();
+        entityManager.clear();
+
+        String response =
+            performCallbackRequest(FINDING_URI + "/search", input)
+                .andReturn()
+                .getResponse()
+                .getContentAsString();
+
+        List<FindingOutput> expectedFindings =
+            fromIterable(
+                    findingRepository.findAllById(
+                        latestFindingWrappers.stream()
+                            .map(wrapper -> wrapper.get().getId())
+                            .toList()))
+                .stream()
+                .map(findingMapper::toFindingOutput)
+                .limit(input.getSize())
+                .toList();
+
+        assertThatJson(response)
+            .when(Option.IGNORING_ARRAY_ORDER)
+            .node("content")
+            .isEqualTo(mapper.writeValueAsString(expectedFindings));
+      }
+
+      @Test
+      @DisplayName("Returns only findings for latest finished simulation of each scenario")
+      public void ReturnsOnlyFindingsForLatestFinishedSimulationOfEachScenario() throws Exception {
+        List<ScenarioComposer.Composer> scenarioWrappers =
+            List.of(getScenarioWithSimulationsWrapper(), getScenarioWithSimulationsWrapper());
+
+        // latest findings
+        List<FindingComposer.Composer> latestFindingWrappers = new ArrayList<>();
+
+        // add latest simulations to each scenario
+        for (ScenarioComposer.Composer scenarioWrapper : scenarioWrappers) {
+          ///  FINISHED simulation
+          Hashtable<String, InjectComposer.Composer> latestSimulationInjectWrappers =
+              attachSimulationToScenario(
+                  scenarioWrapper, ExerciseFixture.createFinishedAttackExercise());
+          for (Map.Entry<String, InjectComposer.Composer> entry :
+              latestSimulationInjectWrappers.entrySet()) {
+            FindingComposer.Composer findingWrapper =
+                findingComposer.forFinding(
+                    FindingFixture.createDefaultTextFindingWithRandomValue());
+            latestFindingWrappers.add(findingWrapper);
+            entry.getValue().withFinding(findingWrapper);
+          }
+
+          /// RUNNING simulation with no findings
+          attachSimulationToScenario(
+              scenarioWrapper, ExerciseFixture.createRunningAttackExercise());
+          scenarioWrapper.persist();
+        }
+
+        // add injects (atomic testing) with findings too
+        for (int i = 0; i < 2; i++) {
+          FindingComposer.Composer findingWrapper =
+              findingComposer.forFinding(FindingFixture.createDefaultTextFindingWithRandomValue());
+          latestFindingWrappers.add(findingWrapper);
+          injectComposer
+              .forInject(InjectFixture.getDefaultInject())
+              .withFinding(findingWrapper)
+              .persist();
+        }
+
+        SearchPaginationInput input = PaginationFixture.getDefault().build();
+
+        entityManager.flush();
+        entityManager.clear();
+
+        String response =
+            performCallbackRequest(FINDING_URI + "/search", input)
+                .andReturn()
+                .getResponse()
+                .getContentAsString();
+
+        List<FindingOutput> expectedFindings =
+            fromIterable(
+                    findingRepository.findAllById(
+                        latestFindingWrappers.stream()
+                            .map(wrapper -> wrapper.get().getId())
+                            .toList()))
+                .stream()
+                .map(findingMapper::toFindingOutput)
+                .limit(input.getSize())
+                .toList();
+
+        assertThatJson(response)
+            .when(Option.IGNORING_ARRAY_ORDER)
+            .node("content")
+            .isEqualTo(mapper.writeValueAsString(expectedFindings));
+      }
+    }
+
+    @Nested
+    @DisplayName("When searching for findings on scenario")
+    class WhenSearchingForFindingsOnScenario {
+      @Test
+      @DisplayName("Returns only findings for latest simulation")
+      public void ReturnsOnlyFindingsForLatestSimulation() throws Exception {
+        ScenarioComposer.Composer scenarioWrapper = getScenarioWithSimulationsWrapper();
+
+        // latest findings
+        List<FindingComposer.Composer> latestFindingWrappers = new ArrayList<>();
+
+        // add latest simulation to scenario
+        Hashtable<String, InjectComposer.Composer> latestSimulationInjectWrappers =
+            attachSimulationToScenario(
+                scenarioWrapper, ExerciseFixture.createFinishedAttackExercise());
+        for (Map.Entry<String, InjectComposer.Composer> entry :
+            latestSimulationInjectWrappers.entrySet()) {
+          FindingComposer.Composer findingWrapper =
+              findingComposer.forFinding(FindingFixture.createDefaultTextFindingWithRandomValue());
+          latestFindingWrappers.add(findingWrapper);
+          entry.getValue().withFinding(findingWrapper);
+        }
+        scenarioWrapper.persist();
+
+        SearchPaginationInput input = PaginationFixture.getDefault().build();
+
+        entityManager.flush();
+        entityManager.clear();
+
+        String response =
+            performCallbackRequest(
+                    FINDING_URI + "/scenarios/" + scenarioWrapper.get().getId() + "/search", input)
+                .andReturn()
+                .getResponse()
+                .getContentAsString();
+
+        List<FindingOutput> expectedFindings =
+            fromIterable(
+                    findingRepository.findAllById(
+                        latestFindingWrappers.stream()
+                            .map(wrapper -> wrapper.get().getId())
+                            .toList()))
+                .stream()
+                .map(findingMapper::toFindingOutput)
+                .limit(input.getSize())
+                .toList();
+
+        assertThatJson(response)
+            .when(Option.IGNORING_ARRAY_ORDER)
+            .node("content")
+            .isEqualTo(mapper.writeValueAsString(expectedFindings));
+      }
+    }
+
+    @Nested
+    @DisplayName("When searching for findings on simulation")
+    class WhenSearchingForFindingsOnSimulation {
+      @Test
+      @DisplayName("Returns all findings for observed simulation")
+      public void ReturnsAllFindingsForObservedSimulation() throws Exception {
+        ScenarioComposer.Composer scenarioWrapper = getScenarioWithSimulationsWrapper();
+        scenarioWrapper.persist();
+
+        Exercise ex = scenarioWrapper.get().getExercises().getFirst();
+
+        SearchPaginationInput input = PaginationFixture.getDefault().build();
+
+        entityManager.flush();
+        entityManager.clear();
+
+        String response =
+            performCallbackRequest(FINDING_URI + "/exercises/" + ex.getId() + "/search", input)
+                .andReturn()
+                .getResponse()
+                .getContentAsString();
+
+        List<FindingOutput> expectedFindings =
+            fromIterable(
+                    findingRepository.findAllById(
+                        ex.getInjects().stream()
+                            .flatMap(inject -> inject.getFindings().stream().map(Finding::getId))
+                            .toList()))
+                .stream()
+                .map(findingMapper::toFindingOutput)
+                .limit(input.getSize())
+                .toList();
+
+        assertThatJson(response)
+            .node("content")
+            .isEqualTo(mapper.writeValueAsString(expectedFindings));
+      }
+    }
 
-  @Test
-  @DisplayName("Search findings by scenario")
-  void should_return_findings_by_scenario() throws Exception {
-    Finding savedFinding =
-        findingComposer
-            .forFinding(FindingFixture.createDefaultFindingCredentials())
-            .withEndpoint(endpointComposer.forEndpoint(savedEndpoint))
-            .withInject(injectComposer1)
-            .withTag(tagComposer.forTag(TagFixture.getTagWithText("Finding")))
-            .persist()
-            .get();
-
-    SearchPaginationInput input =
-        buildDefaultFilters(
-            ContractOutputType.Credentials,
-            "Credentials",
-            savedFinding,
-            null,
-            savedScenario,
-            savedEndpoint,
-            savedAssetGroup);
-
-    entityManager.flush();
-    entityManager.clear();
-
-    performCallbackRequest(FINDING_URI + "/scenarios/" + savedScenario.getId() + "/search", input)
-        .andExpect(
-            jsonPath("$.content.[0].finding_scenario.scenario_id").value(savedScenario.getId()))
-        .andExpect(jsonPath("$.content.[0].finding_type").value(savedFinding.getType().label))
-        .andExpect(jsonPath("$.content.[0].finding_value").value("admin:admin"));
+    @Nested
+    @DisplayName("When searching for findings on inject")
+    class WhenSearchingForFindingsOnInject {
+      @Test
+      @DisplayName("Returns all findings for observed inject")
+      public void ReturnsAllFindingsForObservedInject() throws Exception {
+        ScenarioComposer.Composer scenarioWrapper = getScenarioWithSimulationsWrapper();
+        scenarioWrapper.persist();
+
+        Inject inject = scenarioWrapper.get().getExercises().getFirst().getInjects().getFirst();
+
+        SearchPaginationInput input = PaginationFixture.getDefault().build();
+
+        entityManager.flush();
+        entityManager.clear();
+
+        String response =
+            performCallbackRequest(FINDING_URI + "/injects/" + inject.getId() + "/search", input)
+                .andReturn()
+                .getResponse()
+                .getContentAsString();
+
+        List<FindingOutput> expectedFindings =
+            fromIterable(
+                    findingRepository.findAllById(
+                        inject.getFindings().stream().map(Finding::getId).toList()))
+                .stream()
+                .map(findingMapper::toFindingOutput)
+                .limit(input.getSize())
+                .toList();
+
+        assertThatJson(response)
+            .node("content")
+            .isEqualTo(mapper.writeValueAsString(expectedFindings));
+      }
+    }
+
+    @Nested
+    @DisplayName("When searching for findings on Endpoint")
+    class WhenSearchingForFindingsOnEndpoint {
+      @Test
+      @DisplayName("Returns all findings for latest simulations involving endpoint")
+      public void ReturnsAllFindingsForLatestSimulationsInvolvingEndpoint() throws Exception {
+        EndpointComposer.Composer endpointWrapper =
+            endpointComposer.forEndpoint(EndpointFixture.createEndpoint()).persist();
+        ScenarioComposer.Composer scenarioWrapper = getScenarioWithSimulationsWrapper();
+
+        // hack findings to attach to endpoint
+        for (Exercise ex : scenarioWrapper.get().getExercises()) {
+          for (Inject inject : ex.getInjects()) {
+            for (Finding finding : inject.getFindings()) {
+              finding.setAssets(List.of(endpointWrapper.get()));
+            }
+          }
+        }
+
+        List<FindingComposer.Composer> latestFindingWrappers = new ArrayList<>();
+        // add latest simulation to scenario
+        Hashtable<String, InjectComposer.Composer> latestSimulationInjectWrappers =
+            attachSimulationToScenario(
+                scenarioWrapper, ExerciseFixture.createFinishedAttackExercise());
+        for (Map.Entry<String, InjectComposer.Composer> entry :
+            latestSimulationInjectWrappers.entrySet()) {
+          FindingComposer.Composer findingWrapper =
+              findingComposer
+                  .forFinding(FindingFixture.createDefaultTextFindingWithRandomValue())
+                  .withEndpoint(endpointWrapper);
+          entry.getValue().withFinding(findingWrapper);
+          latestFindingWrappers.add(findingWrapper);
+        }
+        scenarioWrapper.persist();
+
+        // add injects (atomic testing) with findings too
+        for (int i = 0; i < 2; i++) {
+          FindingComposer.Composer findingWrapper =
+              findingComposer.forFinding(FindingFixture.createDefaultTextFindingWithRandomValue());
+          latestFindingWrappers.add(findingWrapper);
+          injectComposer
+              .forInject(InjectFixture.getDefaultInject())
+              .withFinding(findingWrapper.withEndpoint(endpointWrapper))
+              .persist();
+        }
+
+        SearchPaginationInput input = PaginationFixture.getDefault().build();
+
+        entityManager.flush();
+        entityManager.clear();
+
+        String response =
+            performCallbackRequest(
+                    FINDING_URI + "/endpoints/" + endpointWrapper.get().getId() + "/search", input)
+                .andReturn()
+                .getResponse()
+                .getContentAsString();
+
+        List<FindingOutput> expectedFindings =
+            fromIterable(
+                    findingRepository.findAllById(
+                        latestFindingWrappers.stream()
+                            .map(wrapper -> wrapper.get().getId())
+                            .toList()))
+                .stream()
+                .map(findingMapper::toFindingOutput)
+                .limit(input.getSize())
+                .toList();
+
+        assertThatJson(response)
+            .when(Option.IGNORING_ARRAY_ORDER)
+            .node("content")
+            .isEqualTo(mapper.writeValueAsString(expectedFindings));
+      }
+
+      @Test
+      @DisplayName(
+          "Returns all unsolved findings for latest finished simulations involving endpoint")
+      public void ReturnsAllUnsolvedFindingsForLatestFinishedSimulationsInvolvingEndpoint()
+          throws Exception {
+        EndpointComposer.Composer endpointWrapper =
+            endpointComposer.forEndpoint(EndpointFixture.createEndpoint()).persist();
+        ScenarioComposer.Composer scenarioWrapper = getScenarioWithSimulationsWrapper();
+
+        // hack findings to attach to endpoint
+        for (Exercise ex : scenarioWrapper.get().getExercises()) {
+          for (Inject inject : ex.getInjects()) {
+            for (Finding finding : inject.getFindings()) {
+              finding.setAssets(List.of(endpointWrapper.get()));
+            }
+          }
+        }
+
+        List<FindingComposer.Composer> latestFindingWrappers = new ArrayList<>();
+        // add finished simulation to scenario with no findings (= all previous findings solved)
+        attachSimulationToScenario(scenarioWrapper, ExerciseFixture.createFinishedAttackExercise());
+
+        scenarioWrapper.persist();
+
+        // add injects (atomic testing) with findings too
+        for (int i = 0; i < 2; i++) {
+          FindingComposer.Composer findingWrapper =
+              findingComposer.forFinding(FindingFixture.createDefaultTextFindingWithRandomValue());
+          latestFindingWrappers.add(findingWrapper);
+          injectComposer
+              .forInject(InjectFixture.getDefaultInject())
+              .withFinding(findingWrapper.withEndpoint(endpointWrapper))
+              .persist();
+        }
+
+        SearchPaginationInput input = PaginationFixture.getDefault().build();
+
+        entityManager.flush();
+        entityManager.clear();
+
+        String response =
+            performCallbackRequest(
+                    FINDING_URI + "/endpoints/" + endpointWrapper.get().getId() + "/search", input)
+                .andReturn()
+                .getResponse()
+                .getContentAsString();
+
+        List<FindingOutput> expectedFindings =
+            fromIterable(
+                    findingRepository.findAllById(
+                        latestFindingWrappers.stream()
+                            .map(wrapper -> wrapper.get().getId())
+                            .toList()))
+                .stream()
+                .map(findingMapper::toFindingOutput)
+                .limit(input.getSize())
+                .toList();
+
+        assertThatJson(response)
+            .when(Option.IGNORING_ARRAY_ORDER)
+            .node("content")
+            .isEqualTo(mapper.writeValueAsString(expectedFindings));
+      }
+
+      @Test
+      @DisplayName("Returns all findings for latest finished simulations involving endpoint")
+      public void ReturnsAllFindingsForLatestFinishedSimulationsInvolvingEndpoint()
+          throws Exception {
+        EndpointComposer.Composer endpointWrapper =
+            endpointComposer.forEndpoint(EndpointFixture.createEndpoint()).persist();
+        ScenarioComposer.Composer scenarioWrapper = getScenarioWithSimulationsWrapper();
+
+        // hack findings to attach to endpoint
+        for (Exercise ex : scenarioWrapper.get().getExercises()) {
+          for (Inject inject : ex.getInjects()) {
+            for (Finding finding : inject.getFindings()) {
+              finding.setAssets(List.of(endpointWrapper.get()));
+            }
+          }
+        }
+
+        List<FindingComposer.Composer> latestFindingWrappers = new ArrayList<>();
+        // add latest simulation to scenario
+        Hashtable<String, InjectComposer.Composer> latestSimulationInjectWrappers =
+            attachSimulationToScenario(
+                scenarioWrapper, ExerciseFixture.createFinishedAttackExercise());
+        for (Map.Entry<String, InjectComposer.Composer> entry :
+            latestSimulationInjectWrappers.entrySet()) {
+          FindingComposer.Composer findingWrapper =
+              findingComposer
+                  .forFinding(FindingFixture.createDefaultTextFindingWithRandomValue())
+                  .withEndpoint(endpointWrapper);
+          entry.getValue().withFinding(findingWrapper);
+          latestFindingWrappers.add(findingWrapper);
+        }
+
+        attachSimulationToScenario(scenarioWrapper, ExerciseFixture.createRunningAttackExercise());
+
+        scenarioWrapper.persist();
+
+        // add injects (atomic testing) with findings too
+        for (int i = 0; i < 2; i++) {
+          FindingComposer.Composer findingWrapper =
+              findingComposer.forFinding(FindingFixture.createDefaultTextFindingWithRandomValue());
+          latestFindingWrappers.add(findingWrapper);
+          injectComposer
+              .forInject(InjectFixture.getDefaultInject())
+              .withFinding(findingWrapper.withEndpoint(endpointWrapper))
+              .persist();
+        }
+
+        SearchPaginationInput input = PaginationFixture.getDefault().build();
+
+        entityManager.flush();
+        entityManager.clear();
+
+        String response =
+            performCallbackRequest(
+                    FINDING_URI + "/endpoints/" + endpointWrapper.get().getId() + "/search", input)
+                .andReturn()
+                .getResponse()
+                .getContentAsString();
+
+        List<FindingOutput> expectedFindings =
+            fromIterable(
+                    findingRepository.findAllById(
+                        latestFindingWrappers.stream()
+                            .map(wrapper -> wrapper.get().getId())
+                            .toList()))
+                .stream()
+                .map(findingMapper::toFindingOutput)
+                .limit(input.getSize())
+                .toList();
+
+        assertThatJson(response)
+            .when(Option.IGNORING_ARRAY_ORDER)
+            .node("content")
+            .isEqualTo(mapper.writeValueAsString(expectedFindings));
+      }
+    }
   }
 
-  @Test
-  @DisplayName("Search findings by endpoint")
-  void should_return_findings_by_endpoint() throws Exception {
-    Finding savedFinding =
-        findingComposer
-            .forFinding(FindingFixture.createDefaultTextFinding())
-            .withEndpoint(endpointComposer.forEndpoint(savedEndpoint))
-            .withInject(injectComposer1)
-            .withTag(tagComposer.forTag(TagFixture.getTagWithText("Finding Text")))
-            .persist()
-            .get();
-    SearchPaginationInput input =
-        buildDefaultFilters(
-            ContractOutputType.Text, "Text", savedFinding, null, null, savedEndpoint, null);
-
-    performCallbackRequest(FINDING_URI + "/endpoints/" + savedEndpoint.getId() + "/search", input)
-        .andExpect(
-            jsonPath("$.content.[0].finding_assets.[0].asset_id").value(savedEndpoint.getId()))
-        .andExpect(jsonPath("$.content.[0].finding_type").value(savedFinding.getType().label))
-        .andExpect(jsonPath("$.content.[0].finding_value").value("text_value"));
+  @Nested
+  @DisplayName("Basic tests")
+  class BasicTests {
+    private Exercise savedSimulation;
+    private Scenario savedScenario;
+    private AssetGroup savedAssetGroup;
+    private Endpoint savedEndpoint;
+    private InjectComposer.Composer injectWrapper;
+
+    @BeforeEach
+    void setup() {
+      EndpointComposer.Composer endpointWrapper =
+          endpointComposer.forEndpoint(EndpointFixture.createEndpoint());
+
+      savedEndpoint =
+          endpointWrapper
+              .withAgent(agentComposer.forAgent(AgentFixture.createDefaultAgentService()))
+              .get();
+
+      AssetGroupComposer.Composer assetGroupWrapper =
+          assetGroupComposer
+              .forAssetGroup(AssetGroupFixture.createDefaultAssetGroup("asset-group"))
+              .withAsset(endpointWrapper);
+
+      savedAssetGroup = assetGroupWrapper.get();
+
+      injectWrapper =
+          injectComposer
+              .forInject(InjectFixture.getDefaultInject())
+              .withAssetGroup(assetGroupWrapper);
+
+      ExerciseComposer.Composer simulationWrapper =
+          simulationComposer
+              .forExercise(ExerciseFixture.createFinishedAttackExercise())
+              .withInject(injectWrapper);
+
+      savedScenario =
+          scenarioComposer
+              .forScenario(ScenarioFixture.createDefaultCrisisScenario())
+              .withSimulation(simulationWrapper)
+              .persist()
+              .get();
+
+      savedSimulation = savedScenario.getExercises().getFirst();
+    }
+
+    @DisplayName("Search global findings")
+    @Test
+    public void given_a_search_input_should_return_page_of_findings() throws Exception {
+      Finding savedFinding =
+          findingComposer
+              .forFinding(FindingFixture.createDefaultTextFinding())
+              .withEndpoint(endpointComposer.forEndpoint(savedEndpoint))
+              .withInject(injectWrapper)
+              .withTag(tagComposer.forTag(TagFixture.getTagWithText("Finding")))
+              .persist()
+              .get();
+      SearchPaginationInput input =
+          buildDefaultFilters(
+              ContractOutputType.Text,
+              "Text",
+              savedFinding,
+              savedSimulation,
+              savedScenario,
+              savedEndpoint,
+              savedAssetGroup);
+
+      entityManager.flush();
+      entityManager.clear();
+
+      performCallbackRequest(FINDING_URI + "/search", input)
+          .andExpect(jsonPath("$.content.[0].finding_type").value(savedFinding.getType().label))
+          .andExpect(jsonPath("$.content.[0].finding_name").value("Text"))
+          .andExpect(jsonPath("$.content.[0].finding_field").value(TEXT_FIELD))
+          .andExpect(jsonPath("$.content.[0].finding_value").value("text_value"))
+          .andExpect(
+              jsonPath("$.content.[0].finding_assets.[0].asset_id").value(savedEndpoint.getId()))
+          .andExpect(
+              jsonPath("$.content.[0].finding_inject.inject_id")
+                  .value(savedFinding.getInject().getId()))
+          .andExpect(
+              jsonPath("$.content.[0].finding_simulation.exercise_id")
+                  .value(savedSimulation.getId()))
+          .andExpect(
+              jsonPath("$.content.[0].finding_scenario.scenario_id").value(savedScenario.getId()));
+    }
+
+    @Test
+    @DisplayName("Search findings by simulation")
+    void should_return_findings_by_simulation() throws Exception {
+      Finding savedFinding =
+          findingComposer
+              .forFinding(FindingFixture.createDefaultIPV6Finding())
+              .withEndpoint(endpointComposer.forEndpoint(savedEndpoint))
+              .withInject(injectWrapper)
+              .withTag(tagComposer.forTag(TagFixture.getTagWithText("Finding IPv6")))
+              .persist()
+              .get();
+      SearchPaginationInput input =
+          buildDefaultFilters(
+              ContractOutputType.IPv6,
+              "IPv6",
+              savedFinding,
+              savedSimulation,
+              null,
+              savedEndpoint,
+              null);
+
+      performCallbackRequest(
+              FINDING_URI + "/exercises/" + savedSimulation.getId() + "/search", input)
+          .andExpect(jsonPath("$.content.[0].finding_type").value(savedFinding.getType().label))
+          .andExpect(
+              jsonPath("$.content.[0].finding_value")
+                  .value("2001:0000:130F:0000:0000:09C0:876A:130B"));
+    }
+
+    @Test
+    @DisplayName("Search findings by scenario")
+    void should_return_findings_by_scenario() throws Exception {
+      Finding savedFinding =
+          findingComposer
+              .forFinding(FindingFixture.createDefaultFindingCredentials())
+              .withEndpoint(endpointComposer.forEndpoint(savedEndpoint))
+              .withInject(injectWrapper)
+              .withTag(tagComposer.forTag(TagFixture.getTagWithText("Finding")))
+              .persist()
+              .get();
+
+      SearchPaginationInput input =
+          buildDefaultFilters(
+              ContractOutputType.Credentials,
+              "Credentials",
+              savedFinding,
+              null,
+              savedScenario,
+              savedEndpoint,
+              savedAssetGroup);
+
+      entityManager.flush();
+      entityManager.clear();
+
+      performCallbackRequest(FINDING_URI + "/scenarios/" + savedScenario.getId() + "/search", input)
+          .andExpect(
+              jsonPath("$.content.[0].finding_scenario.scenario_id").value(savedScenario.getId()))
+          .andExpect(jsonPath("$.content.[0].finding_type").value(savedFinding.getType().label))
+          .andExpect(jsonPath("$.content.[0].finding_value").value("admin:admin"));
+    }
+
+    @Test
+    @DisplayName("Search findings by endpoint")
+    void should_return_findings_by_endpoint() throws Exception {
+      Finding savedFinding =
+          findingComposer
+              .forFinding(FindingFixture.createDefaultTextFinding())
+              .withEndpoint(endpointComposer.forEndpoint(savedEndpoint))
+              .withInject(injectWrapper)
+              .withTag(tagComposer.forTag(TagFixture.getTagWithText("Finding Text")))
+              .persist()
+              .get();
+      SearchPaginationInput input =
+          buildDefaultFilters(
+              ContractOutputType.Text, "Text", savedFinding, null, null, savedEndpoint, null);
+
+      performCallbackRequest(FINDING_URI + "/endpoints/" + savedEndpoint.getId() + "/search", input)
+          .andExpect(
+              jsonPath("$.content.[0].finding_assets.[0].asset_id").value(savedEndpoint.getId()))
+          .andExpect(jsonPath("$.content.[0].finding_type").value(savedFinding.getType().label))
+          .andExpect(jsonPath("$.content.[0].finding_value").value("text_value"));
+    }
   }
 
   private SearchPaginationInput buildDefaultFilters(
diff --git a/openbas-api/src/test/java/io/openbas/utils/fixtures/ExerciseFixture.java b/openbas-api/src/test/java/io/openbas/utils/fixtures/ExerciseFixture.java
--- a/openbas-api/src/test/java/io/openbas/utils/fixtures/ExerciseFixture.java
+++ b/openbas-api/src/test/java/io/openbas/utils/fixtures/ExerciseFixture.java
@@ -99,6 +99,10 @@ public static Exercise createCanceledAttackExercise(Instant startTime) {
     return exercise;
   }
 
+  public static Exercise createFinishedAttackExercise() {
+    return createFinishedAttackExercise(Instant.now());
+  }
+
   public static Exercise createFinishedAttackExercise(Instant startTime) {
     Exercise exercise = createDefaultExerciseWithName("Draft incident response exercise");
     exercise.setDescription("An incident response exercise for my enterprise");
diff --git a/openbas-api/src/test/java/io/openbas/rest/finding/FindingFixture.java b/openbas-api/src/test/java/io/openbas/utils/fixtures/FindingFixture.java
similarity index 82%
rename from openbas-api/src/test/java/io/openbas/rest/finding/FindingFixture.java
rename to openbas-api/src/test/java/io/openbas/utils/fixtures/FindingFixture.java
--- a/openbas-api/src/test/java/io/openbas/rest/finding/FindingFixture.java
+++ b/openbas-api/src/test/java/io/openbas/utils/fixtures/FindingFixture.java
@@ -1,7 +1,8 @@
-package io.openbas.rest.finding;
+package io.openbas.utils.fixtures;
 
 import io.openbas.database.model.ContractOutputType;
 import io.openbas.database.model.Finding;
+import java.util.UUID;
 
 public class FindingFixture {
 
@@ -19,6 +20,12 @@ public static Finding createDefaultTextFinding() {
     return finding;
   }
 
+  public static Finding createDefaultTextFindingWithRandomValue() {
+    Finding finding = createDefaultTextFinding();
+    finding.setValue(UUID.randomUUID().toString());
+    return finding;
+  }
+
   public static Finding createDefaultIPV6Finding() {
     Finding finding = new Finding();
     finding.setType(ContractOutputType.IPv6);
diff --git a/openbas-api/src/test/java/io/openbas/utils/fixtures/composers/InjectComposer.java b/openbas-api/src/test/java/io/openbas/utils/fixtures/composers/InjectComposer.java
--- a/openbas-api/src/test/java/io/openbas/utils/fixtures/composers/InjectComposer.java
+++ b/openbas-api/src/test/java/io/openbas/utils/fixtures/composers/InjectComposer.java
@@ -25,6 +25,7 @@ public class Composer extends InnerComposerBase<Inject> {
     private final List<TeamComposer.Composer> teamComposers = new ArrayList<>();
     private final List<AssetGroupComposer.Composer> assetGroupComposers = new ArrayList<>();
     private final List<InjectExpectationComposer.Composer> expectationComposers = new ArrayList<>();
+    private final List<FindingComposer.Composer> findingComposers = new ArrayList<>();
 
     public Composer(Inject inject) {
       this.inject = inject;
@@ -111,6 +112,15 @@ public Composer withDependsOn(Inject injectParent) {
       return this;
     }
 
+    public Composer withFinding(FindingComposer.Composer findingComposer) {
+      findingComposers.add(findingComposer);
+      List<Finding> tmpFindings = this.inject.getFindings();
+      tmpFindings.add(findingComposer.get());
+      findingComposer.get().setInject(this.inject);
+      this.inject.setFindings(tmpFindings);
+      return this;
+    }
+
     @Override
     public Composer persist() {
       this.injectorContractComposer.ifPresent(