Repository: OpenAEV-Platform/openaev (java)

Issue:
UI: Update Findings view to show aggregate findings values
## Context
The findings can be found repeatedly in different simulations, so we want to have an aggregated view of these values ​​and have the option of a drawer where the details of these findings would be displayed.


Discussion:

Patch:
diff --git a/openbas-api/src/main/java/io/openbas/migration/V4_01__Cves_Default_data.java b/openbas-api/src/main/java/io/openbas/migration/V4_01__Cves_Default_data.java
new file mode 100644
--- /dev/null
+++ b/openbas-api/src/main/java/io/openbas/migration/V4_01__Cves_Default_data.java
@@ -0,0 +1,202 @@
+package io.openbas.migration;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.util.UUID;
+import org.flywaydb.core.api.migration.BaseJavaMigration;
+import org.flywaydb.core.api.migration.Context;
+import org.springframework.stereotype.Component;
+
+@Component
+public class V4_01__Cves_Default_data extends BaseJavaMigration {
+
+  @Override
+  public void migrate(Context context) throws Exception {
+    Connection connection = context.getConnection();
+
+    try (PreparedStatement insertCve =
+            connection.prepareStatement(
+                "INSERT INTO cves (cve_id, cve_external_id, cve_source_identifier, cve_published, "
+                    + "cve_description, cve_cvss, cve_cisa_exploit_add, cve_cisa_action_due, "
+                    + "cve_cisa_required_action, cve_cisa_vulnerability_name) "
+                    + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (cve_id) DO NOTHING");
+        PreparedStatement insertCwe =
+            connection.prepareStatement(
+                "INSERT INTO cwes (cwe_id, cwe_external_id, cwe_source) "
+                    + "VALUES (?, ?, ?) ON CONFLICT (cwe_id) DO NOTHING");
+        PreparedStatement linkCveCwe =
+            connection.prepareStatement(
+                "INSERT INTO cves_cwes (cve_id, cwe_id) VALUES (?, ?) ON CONFLICT DO NOTHING");
+        PreparedStatement insertRef =
+            connection.prepareStatement(
+                "INSERT INTO cve_reference_urls (cve_id, cve_reference_url) "
+                    + "VALUES (?, ?) ON CONFLICT DO NOTHING")) {
+      // --- Helper method for bulk insertion ---
+      insertEntry(
+          insertCve,
+          insertCwe,
+          linkCveCwe,
+          insertRef,
+          "CVE-2021-26855",
+          "2021-03-02T00:00:00Z",
+          "Microsoft Exchange Server ProxyLogon SSRF leading to RCE.",
+          "9.8",
+          "Apply updates per vendor instructions",
+          "Microsoft Exchange Server Remote Code Execution Vulnerability",
+          "CWE-918",
+          "NVD",
+          new String[] {
+            "https://portal.msrc.microsoft.com/en-US/security-guidance/advisory/CVE-2021-26855",
+            "http://packetstormsecurity.com/files/161938/Microsoft-Exchange-ProxyLogon-Remote-Code-Execution.html",
+            "https://nvd.nist.gov/vuln/detail/CVE-2021-26855"
+          });
+
+      insertEntry(
+          insertCve,
+          insertCwe,
+          linkCveCwe,
+          insertRef,
+          "CVE-2023-20198",
+          "2023-10-16T00:00:00Z",
+          "Cisco IOS XE Web UI remote code execution via unauthenticated command injection.",
+          "10.0",
+          "Verify compliance with BOD 23‑02 and apply mitigations.",
+          "Cisco IOS XE Web UI Privilege Escalation Vulnerability",
+          "CWE-420",
+          "NVD",
+          new String[] {
+            "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-iosxe-webui-privesc-j22SaA4z",
+            "https://nvd.nist.gov/vuln/detail/CVE-2023-20198",
+            "https://github.com/W01fh4cker/CVE-2023-20198-RCE"
+          });
+
+      insertEntry(
+          insertCve,
+          insertCwe,
+          linkCveCwe,
+          insertRef,
+          "CVE-2023-46805",
+          "2024-01-12T00:00:00Z",
+          "Ivanti Connect Secure/Policy Secure gateway authentication bypass.",
+          "8.2",
+          "Apply mitigations per vendor instructions or discontinue product if unavailable.",
+          "Ivanti Connect Secure and Policy Secure Authentication Bypass Vulnerability",
+          "CWE-287",
+          "NVD",
+          new String[] {
+            "https://nvd.nist.gov/vuln/detail/CVE-2023-46805",
+            "https://www.twingate.com/blog/tips/cve-2023-46805"
+          });
+
+      insertEntry(
+          insertCve,
+          insertCwe,
+          linkCveCwe,
+          insertRef,
+          "CVE-2023-48788",
+          "2023-12-01T00:00:00Z",
+          "Improper neutralization of SQL elements in Fortinet FortiClientEMS allows RCE/commands.",
+          "9.8",
+          "Apply mitigations per vendor instructions or discontinue product if unavailable.",
+          "Fortinet FortiClient EMS SQL Injection Vulnerability",
+          "CWE-789",
+          "NVD",
+          new String[] {"https://nvd.nist.gov/vuln/detail/CVE-2023-48788"});
+
+      insertEntry(
+          insertCve,
+          insertCwe,
+          linkCveCwe,
+          insertRef,
+          "CVE-2024-20353",
+          "2019-06-10T00:00:00Z",
+          "Use-after-free in mongoose.c (mg_http_get_proto_data) leading to DoS or RCE.",
+          "8.6",
+          "Apply mitigations per vendor instructions or discontinue product if unavailable.",
+          "Cisco ASA and FTD Denial of Service Vulnerability",
+          "CWE-416",
+          "MITRE",
+          new String[] {
+            "https://github.com/insi2304/mongoose-6.13-fuzz/blob/master/Simplest_Web_Server_Use_After_Free-read-mg_http_get_proto_data5932.png"
+          });
+
+      insertEntry(
+          insertCve,
+          insertCwe,
+          linkCveCwe,
+          insertRef,
+          "CVE-2018-0171",
+          "2018-05-14T00:00:00Z",
+          "Cisco Smart Install buffer overflow leading to RCE/DoS.",
+          "9.8",
+          "Apply mitigations per vendor instructions or discontinue product if unavailable.",
+          "Cisco IOS and IOS XE Software Smart Install Remote Code Execution Vulnerability",
+          "CWE-787",
+          "Out-of-bounds Write",
+          new String[] {"https://nvd.nist.gov/vuln/detail/CVE-2018-0171"});
+
+      insertEntry(
+          insertCve,
+          insertCwe,
+          linkCveCwe,
+          insertRef,
+          "CVE-2023-20273",
+          "2023-10-16T00:00:00Z",
+          "Cisco IOS XE Web UI insufficient input validation leading to root command injection.",
+          "7.2",
+          "Verify compliance with BOD 23‑02 and apply mitigations.",
+          "Cisco IOS XE Web UI Command Injection Vulnerability",
+          "CWE-78",
+          "Nist",
+          new String[] {
+            "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-iosxe-webui-privesc-j22SaA4z"
+          });
+    }
+  }
+
+  private void insertEntry(
+      PreparedStatement insertCve,
+      PreparedStatement insertCwe,
+      PreparedStatement linkCveCwe,
+      PreparedStatement insertRef,
+      String cveString,
+      String publishedTs,
+      String description,
+      String cvss,
+      String requiredAction,
+      String vulnerabilityName,
+      String cweString,
+      String cweSource,
+      String[] refUrls)
+      throws Exception {
+    String cveId = UUID.randomUUID().toString();
+    String cweId = UUID.randomUUID().toString();
+
+    insertCve.setString(1, cveId);
+    insertCve.setString(2, cveString);
+    insertCve.setString(3, cveString);
+    insertCve.setTimestamp(4, java.sql.Timestamp.from(java.time.Instant.parse(publishedTs)));
+    insertCve.setString(5, description);
+    insertCve.setBigDecimal(6, new java.math.BigDecimal(cvss));
+    insertCve.setTimestamp(7, java.sql.Timestamp.from(java.time.Instant.parse(publishedTs)));
+    insertCve.setTimestamp(8, java.sql.Timestamp.from(java.time.Instant.parse(publishedTs)));
+    insertCve.setString(9, requiredAction);
+    insertCve.setString(10, vulnerabilityName);
+    insertCve.executeUpdate();
+
+    insertCwe.setString(1, cweId);
+    insertCwe.setString(2, cweString);
+    insertCwe.setString(3, cweSource);
+    insertCwe.executeUpdate();
+
+    linkCveCwe.setString(1, cveId);
+    linkCveCwe.setString(2, cweId);
+    linkCveCwe.executeUpdate();
+
+    for (String url : refUrls) {
+      insertRef.setString(1, cveId);
+      insertRef.setString(2, url);
+      insertRef.executeUpdate();
+    }
+  }
+}
diff --git a/openbas-api/src/main/java/io/openbas/rest/cve/service/CveService.java b/openbas-api/src/main/java/io/openbas/rest/cve/service/CveService.java
--- a/openbas-api/src/main/java/io/openbas/rest/cve/service/CveService.java
+++ b/openbas-api/src/main/java/io/openbas/rest/cve/service/CveService.java
@@ -11,8 +11,8 @@
 import io.openbas.rest.cve.form.CveCreateInput;
 import io.openbas.rest.cve.form.CveUpdateInput;
 import io.openbas.rest.cve.form.CweInput;
+import io.openbas.rest.exception.ElementNotFoundException;
 import io.openbas.utils.pagination.SearchPaginationInput;
-import jakarta.persistence.EntityNotFoundException;
 import jakarta.validation.Valid;
 import java.util.Collections;
 import java.util.List;
@@ -66,13 +66,13 @@ public Cve updateCve(final String cveId, final @Valid CveUpdateInput input) {
   public Cve findById(final String cveId) {
     return cveRepository
         .findById(cveId)
-        .orElseThrow(() -> new EntityNotFoundException(CVE_NOT_FOUND_MSG + cveId));
+        .orElseThrow(() -> new ElementNotFoundException(CVE_NOT_FOUND_MSG + cveId));
   }
 
   public Cve findByExternalId(String externalId) {
     return cveRepository
         .findByExternalId(externalId)
-        .orElseThrow(() -> new EntityNotFoundException(CVE_NOT_FOUND_MSG + externalId));
+        .orElseThrow(() -> new ElementNotFoundException(CVE_NOT_FOUND_MSG + externalId));
   }
 
   public void deleteById(final String cveId) {
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
@@ -1,122 +1,25 @@
 package io.openbas.rest.finding;
 
-import static io.openbas.utils.pagination.PaginationUtils.buildPaginationJPA;
-
-import io.openbas.aop.LogExecutionTime;
 import io.openbas.database.model.Finding;
-import io.openbas.database.repository.FindingRepository;
-import io.openbas.database.specification.FindingSpecification;
 import io.openbas.rest.finding.form.FindingInput;
-import io.openbas.rest.finding.form.FindingOutput;
 import io.openbas.rest.helper.RestBehavior;
-import io.openbas.utils.FindingMapper;
-import io.openbas.utils.pagination.SearchPaginationInput;
 import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotNull;
 import lombok.RequiredArgsConstructor;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.Pageable;
-import org.springframework.data.jpa.domain.Specification;
 import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 @RestController
-@RequestMapping("/api/findings")
+@RequestMapping(FindingApi.FINDING_URI)
 @RequiredArgsConstructor
 public class FindingApi extends RestBehavior {
 
-  private final FindingRepository findingRepository;
+  public static final String FINDING_URI = "/api/findings";
 
   private final FindingService findingService;
-  private final FindingMapper findingMapper;
 
   // -- CRUD --
 
-  @LogExecutionTime
-  @PostMapping("/search")
-  public Page<FindingOutput> findings(
-      @RequestBody @Valid final SearchPaginationInput searchPaginationInput) {
-    return buildPaginationJPA(
-            (specification, pageable) ->
-                this.findingRepository.findAll(
-                    FindingSpecification.forLatestSimulations().and(specification), pageable),
-            searchPaginationInput,
-            Finding.class)
-        .map(findingMapper::toFindingOutput);
-  }
-
-  @LogExecutionTime
-  @PostMapping("/injects/{injectId}/search")
-  @PreAuthorize("isObserver()")
-  public Page<FindingOutput> findingsByInject(
-      @PathVariable @NotNull final String injectId,
-      @RequestBody @Valid final SearchPaginationInput searchPaginationInput) {
-    return buildPaginationJPA(
-            (Specification<Finding> specification, Pageable pageable) ->
-                this.findingRepository.findAll(
-                    FindingSpecification.findFindingsForInject(injectId).and(specification),
-                    pageable),
-            searchPaginationInput,
-            Finding.class)
-        .map(findingMapper::toFindingOutput);
-  }
-
-  @LogExecutionTime
-  @PostMapping("/exercises/{simulationId}/search")
-  @PreAuthorize("isExerciseObserver(#exerciseId)")
-  public Page<FindingOutput> findingsBySimulation(
-      @PathVariable @NotNull final String simulationId,
-      @RequestBody @Valid final SearchPaginationInput searchPaginationInput) {
-    return buildPaginationJPA(
-            (Specification<Finding> specification, Pageable pageable) ->
-                this.findingRepository.findAll(
-                    FindingSpecification.findFindingsForSimulation(simulationId).and(specification),
-                    pageable),
-            searchPaginationInput,
-            Finding.class)
-        .map(findingMapper::toFindingOutput);
-  }
-
-  @LogExecutionTime
-  @PostMapping("/scenarios/{scenarioId}/search")
-  @PreAuthorize("isScenarioObserver(#scenarioId)")
-  public Page<FindingOutput> findingsByScenario(
-      @PathVariable @NotNull final String scenarioId,
-      @RequestBody @Valid final SearchPaginationInput searchPaginationInput) {
-    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
-  @PostMapping("/endpoints/{endpointId}/search")
-  @PreAuthorize("isObserver()")
-  public Page<FindingOutput> findingsByEndpoint(
-      @PathVariable @NotNull final String endpointId,
-      @RequestBody @Valid final SearchPaginationInput searchPaginationInput) {
-    return buildPaginationJPA(
-            (Specification<Finding> specification, Pageable pageable) ->
-                this.findingRepository.findAll(
-                    FindingSpecification.findFindingsForEndpoint(endpointId)
-                        .and(FindingSpecification.forLatestSimulations())
-                        .and(specification),
-                    pageable),
-            searchPaginationInput,
-            Finding.class)
-        .map(findingMapper::toFindingOutput);
-  }
-
   @GetMapping("/{id}")
   public ResponseEntity<Finding> finding(@PathVariable @NotNull final String id) {
     return ResponseEntity.ok(this.findingService.finding(id));
diff --git a/openbas-api/src/main/java/io/openbas/rest/finding/FindingDistinctSearchService.java b/openbas-api/src/main/java/io/openbas/rest/finding/FindingDistinctSearchService.java
new file mode 100644
--- /dev/null
+++ b/openbas-api/src/main/java/io/openbas/rest/finding/FindingDistinctSearchService.java
@@ -0,0 +1,169 @@
+package io.openbas.rest.finding;
+
+import static io.openbas.utils.pagination.PaginationUtils.buildPaginationJPA;
+
+import io.openbas.database.model.Asset;
+import io.openbas.database.model.ContractOutputType;
+import io.openbas.database.model.Finding;
+import io.openbas.database.model.TypeValueKey;
+import io.openbas.database.repository.FindingRepository;
+import io.openbas.database.specification.FindingSpecification;
+import io.openbas.rest.finding.form.AggregatedFindingOutput;
+import io.openbas.utils.FindingMapper;
+import io.openbas.utils.pagination.SearchPaginationInput;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class FindingDistinctSearchService {
+
+  private final FindingRepository findingRepository;
+  private final FindingMapper findingMapper;
+
+  public Page<AggregatedFindingOutput> searchDistinctFindings(
+      SearchPaginationInput searchPaginationInput) {
+    Page<Finding> page =
+        buildPaginationJPA(
+            (specification, pageable) ->
+                findingRepository.findAll(
+                    FindingSpecification.distinctTypeValueWithFilter(
+                        FindingSpecification.forLatestSimulations().and(specification)),
+                    pageable),
+            searchPaginationInput,
+            Finding.class);
+
+    return searchDistinctBySpecification(FindingSpecification.forLatestSimulations(), page);
+  }
+
+  public Page<AggregatedFindingOutput> searchDistinctFindingsByInject(
+      String injectId, SearchPaginationInput searchPaginationInput) {
+    Page<Finding> page =
+        buildPaginationJPA(
+            (Specification<Finding> specification, Pageable pageable) ->
+                this.findingRepository.findAll(
+                    FindingSpecification.distinctTypeValueWithFilter(
+                        FindingSpecification.findFindingsForInject(injectId).and(specification)),
+                    pageable),
+            searchPaginationInput,
+            Finding.class);
+
+    return searchDistinctBySpecification(
+        FindingSpecification.findFindingsForInject(injectId), page);
+  }
+
+  public Page<AggregatedFindingOutput> searchDistinctFindingsBySimulation(
+      String simulationId, SearchPaginationInput searchPaginationInput) {
+    Page<Finding> page =
+        buildPaginationJPA(
+            (Specification<Finding> specification, Pageable pageable) ->
+                this.findingRepository.findAll(
+                    FindingSpecification.distinctTypeValueWithFilter(
+                        FindingSpecification.findFindingsForSimulation(simulationId)
+                            .and(specification)),
+                    pageable),
+            searchPaginationInput,
+            Finding.class);
+
+    return searchDistinctBySpecification(
+        FindingSpecification.findFindingsForSimulation(simulationId), page);
+  }
+
+  public Page<AggregatedFindingOutput> searchDistinctFindingsByScenario(
+      String scenarioId, SearchPaginationInput searchPaginationInput) {
+    Page<Finding> page =
+        buildPaginationJPA(
+            (Specification<Finding> specification, Pageable pageable) ->
+                this.findingRepository.findAll(
+                    FindingSpecification.distinctTypeValueWithFilter(
+                        FindingSpecification.findFindingsForScenario(scenarioId)
+                            .and(FindingSpecification.forLatestSimulations())
+                            .and(specification)),
+                    pageable),
+            searchPaginationInput,
+            Finding.class);
+
+    return searchDistinctBySpecification(
+        FindingSpecification.findFindingsForScenario(scenarioId)
+            .and(FindingSpecification.forLatestSimulations()),
+        page);
+  }
+
+  public Page<AggregatedFindingOutput> searchDistinctFindingsByEndpoint(
+      String endpointId, SearchPaginationInput searchPaginationInput) {
+    Page<Finding> page =
+        buildPaginationJPA(
+            (Specification<Finding> specification, Pageable pageable) ->
+                this.findingRepository.findAll(
+                    FindingSpecification.distinctTypeValueWithFilter(
+                        FindingSpecification.findFindingsForEndpoint(endpointId)
+                            .and(FindingSpecification.forLatestSimulations())
+                            .and(specification)),
+                    pageable),
+            searchPaginationInput,
+            Finding.class);
+
+    return searchDistinctBySpecification(
+        FindingSpecification.findFindingsForEndpoint(endpointId)
+            .and(FindingSpecification.forLatestSimulations()),
+        page);
+  }
+
+  public Page<AggregatedFindingOutput> searchDistinctBySpecification(
+      Specification<Finding> baseFilterSpec, Page<Finding> page) {
+
+    // Step 1: Extract distinct (type, value) keys
+    List<TypeValueKey> typeValueKeys =
+        page.getContent().stream()
+            .map(f -> new TypeValueKey(f.getType(), f.getValue()))
+            .distinct()
+            .toList();
+
+    if (typeValueKeys.isEmpty()) {
+      return Page.empty(page.getPageable());
+    }
+
+    // Step 2: Fetch all findings with assets for those values/types
+    List<ContractOutputType> types = typeValueKeys.stream().map(TypeValueKey::getType).toList();
+    List<String> values = typeValueKeys.stream().map(TypeValueKey::getValue).toList();
+
+    List<Finding> findingsWithAssets =
+        findingRepository.findAll(
+            FindingSpecification.findAllWithAssetsByTypeValueIn(types, values, baseFilterSpec));
+
+    // Step 3: Group assets by (type, value)
+    Map<TypeValueKey, List<Asset>> groupedAssets =
+        findingsWithAssets.stream()
+            .filter(f -> typeValueKeys.contains(new TypeValueKey(f.getType(), f.getValue())))
+            .flatMap(
+                finding ->
+                    finding.getAssets().stream()
+                        .map(
+                            asset ->
+                                Map.entry(
+                                    new TypeValueKey(finding.getType(), finding.getValue()),
+                                    asset)))
+            .collect(
+                Collectors.groupingBy(
+                    Map.Entry::getKey,
+                    Collectors.mapping(Map.Entry::getValue, Collectors.toList())));
+
+    // Step 4: Map page findings + grouped assets to AggregatedFindingOutput
+    return page.map(
+        finding -> {
+          TypeValueKey key = new TypeValueKey(finding.getType(), finding.getValue());
+          List<Asset> relatedAssets = groupedAssets.getOrDefault(key, List.of());
+          return findingMapper.toAggregatedFindingOutput(finding, relatedAssets);
+        });
+  }
+}
diff --git a/openbas-api/src/main/java/io/openbas/rest/finding/FindingSearchApi.java b/openbas-api/src/main/java/io/openbas/rest/finding/FindingSearchApi.java
new file mode 100644
--- /dev/null
+++ b/openbas-api/src/main/java/io/openbas/rest/finding/FindingSearchApi.java
@@ -0,0 +1,181 @@
+package io.openbas.rest.finding;
+
+import static io.openbas.utils.pagination.PaginationUtils.buildPaginationJPA;
+
+import io.openbas.aop.LogExecutionTime;
+import io.openbas.database.model.Finding;
+import io.openbas.database.repository.FindingRepository;
+import io.openbas.database.specification.FindingSpecification;
+import io.openbas.rest.finding.form.AggregatedFindingOutput;
+import io.openbas.rest.finding.form.PageAggregatedFindingOutput;
+import io.openbas.rest.finding.form.PageRelatedFindingOutput;
+import io.openbas.rest.helper.RestBehavior;
+import io.openbas.utils.FindingMapper;
+import io.openbas.utils.pagination.SearchPaginationInput;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping(FindingApi.FINDING_URI)
+@RequiredArgsConstructor
+public class FindingSearchApi extends RestBehavior {
+
+  private final FindingRepository findingRepository;
+  private final FindingDistinctSearchService findingDistinctSearchService;
+
+  private final FindingMapper findingMapper;
+
+  @LogExecutionTime
+  @PostMapping("/search")
+  @ApiResponse(
+      responseCode = "200",
+      content =
+          @Content(
+              schema =
+                  @Schema(
+                      oneOf = {PageAggregatedFindingOutput.class, PageRelatedFindingOutput.class})))
+  public Page<AggregatedFindingOutput> findings(
+      @RequestBody @Valid final SearchPaginationInput searchPaginationInput,
+      @RequestParam(value = "distinct", required = false, defaultValue = "false")
+          boolean distinct) {
+    if (distinct) {
+      return findingDistinctSearchService.searchDistinctFindings(searchPaginationInput);
+    }
+    return buildPaginationJPA(
+            (specification, pageable) ->
+                this.findingRepository.findAll(
+                    FindingSpecification.forLatestSimulations().and(specification), pageable),
+            searchPaginationInput,
+            Finding.class)
+        .map(findingMapper::toRelatedFindingOutput);
+  }
+
+  @LogExecutionTime
+  @PostMapping("/injects/{injectId}/search")
+  @ApiResponse(
+      responseCode = "200",
+      content =
+          @Content(
+              schema =
+                  @Schema(
+                      oneOf = {PageAggregatedFindingOutput.class, PageRelatedFindingOutput.class})))
+  @PreAuthorize("isObserver()")
+  public Page<AggregatedFindingOutput> findingsByInject(
+      @PathVariable @NotNull final String injectId,
+      @RequestBody @Valid final SearchPaginationInput searchPaginationInput,
+      @RequestParam(value = "distinct", required = false, defaultValue = "false")
+          boolean distinct) {
+    if (distinct) {
+      return findingDistinctSearchService.searchDistinctFindingsByInject(
+          injectId, searchPaginationInput);
+    }
+    return buildPaginationJPA(
+            (Specification<Finding> specification, Pageable pageable) ->
+                this.findingRepository.findAll(
+                    FindingSpecification.findFindingsForInject(injectId).and(specification),
+                    pageable),
+            searchPaginationInput,
+            Finding.class)
+        .map(findingMapper::toRelatedFindingOutput);
+  }
+
+  @LogExecutionTime
+  @PostMapping("/exercises/{simulationId}/search")
+  @ApiResponse(
+      responseCode = "200",
+      content =
+          @Content(
+              schema =
+                  @Schema(
+                      oneOf = {PageAggregatedFindingOutput.class, PageRelatedFindingOutput.class})))
+  @PreAuthorize("isExerciseObserver(#exerciseId)")
+  public Page<AggregatedFindingOutput> findingsBySimulation(
+      @PathVariable @NotNull final String simulationId,
+      @RequestBody @Valid final SearchPaginationInput searchPaginationInput,
+      @RequestParam(value = "distinct", required = false, defaultValue = "false")
+          boolean distinct) {
+    if (distinct) {
+      return findingDistinctSearchService.searchDistinctFindingsBySimulation(
+          simulationId, searchPaginationInput);
+    }
+    return buildPaginationJPA(
+            (Specification<Finding> specification, Pageable pageable) ->
+                this.findingRepository.findAll(
+                    FindingSpecification.findFindingsForSimulation(simulationId).and(specification),
+                    pageable),
+            searchPaginationInput,
+            Finding.class)
+        .map(findingMapper::toRelatedFindingOutput);
+  }
+
+  @LogExecutionTime
+  @PostMapping("/scenarios/{scenarioId}/search")
+  @ApiResponse(
+      responseCode = "200",
+      content =
+          @Content(
+              schema =
+                  @Schema(
+                      oneOf = {PageAggregatedFindingOutput.class, PageRelatedFindingOutput.class})))
+  @PreAuthorize("isScenarioObserver(#scenarioId)")
+  public Page<AggregatedFindingOutput> findingsByScenario(
+      @PathVariable @NotNull final String scenarioId,
+      @RequestBody @Valid final SearchPaginationInput searchPaginationInput,
+      @RequestParam(value = "distinct", required = false, defaultValue = "false")
+          boolean distinct) {
+    if (distinct) {
+      return findingDistinctSearchService.searchDistinctFindingsByScenario(
+          scenarioId, searchPaginationInput);
+    }
+    return buildPaginationJPA(
+            (Specification<Finding> specification, Pageable pageable) ->
+                this.findingRepository.findAll(
+                    FindingSpecification.findFindingsForScenario(scenarioId)
+                        .and(FindingSpecification.forLatestSimulations())
+                        .and(specification),
+                    pageable),
+            searchPaginationInput,
+            Finding.class)
+        .map(findingMapper::toRelatedFindingOutput);
+  }
+
+  @LogExecutionTime
+  @PostMapping("/endpoints/{endpointId}/search")
+  @ApiResponse(
+      responseCode = "200",
+      content =
+          @Content(
+              schema =
+                  @Schema(
+                      oneOf = {PageAggregatedFindingOutput.class, PageRelatedFindingOutput.class})))
+  @PreAuthorize("isObserver()")
+  public Page<AggregatedFindingOutput> findingsByEndpoint(
+      @PathVariable @NotNull final String endpointId,
+      @RequestBody @Valid final SearchPaginationInput searchPaginationInput,
+      @RequestParam(value = "distinct", required = false, defaultValue = "false")
+          boolean distinct) {
+    if (distinct) {
+      return findingDistinctSearchService.searchDistinctFindingsByEndpoint(
+          endpointId, searchPaginationInput);
+    }
+    return buildPaginationJPA(
+            (Specification<Finding> specification, Pageable pageable) ->
+                this.findingRepository.findAll(
+                    FindingSpecification.findFindingsForEndpoint(endpointId)
+                        .and(FindingSpecification.forLatestSimulations())
+                        .and(specification),
+                    pageable),
+            searchPaginationInput,
+            Finding.class)
+        .map(findingMapper::toRelatedFindingOutput);
+  }
+}
diff --git a/openbas-api/src/main/java/io/openbas/rest/finding/form/FindingOutput.java b/openbas-api/src/main/java/io/openbas/rest/finding/form/AggregatedFindingOutput.java
similarity index 58%
rename from openbas-api/src/main/java/io/openbas/rest/finding/form/FindingOutput.java
rename to openbas-api/src/main/java/io/openbas/rest/finding/form/AggregatedFindingOutput.java
--- a/openbas-api/src/main/java/io/openbas/rest/finding/form/FindingOutput.java
+++ b/openbas-api/src/main/java/io/openbas/rest/finding/form/AggregatedFindingOutput.java
@@ -7,35 +7,27 @@
 import io.openbas.database.model.ContractOutputType;
 import io.openbas.rest.asset.endpoint.form.EndpointSimple;
 import io.openbas.rest.asset_group.form.AssetGroupSimple;
-import io.openbas.rest.exercise.form.ExerciseSimple;
-import io.openbas.rest.inject.output.InjectSimple;
-import io.openbas.rest.scenario.form.ScenarioSimple;
 import io.swagger.v3.oas.annotations.media.Schema;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 import java.time.Instant;
 import java.util.Set;
-import lombok.Builder;
 import lombok.Data;
+import lombok.experimental.SuperBuilder;
 
 @Data
-@Builder
+@SuperBuilder
 @JsonInclude(NON_NULL)
-public class FindingOutput {
+public class AggregatedFindingOutput {
 
   @Schema(description = "Finding Id")
   @JsonProperty("finding_id")
   @NotBlank
   private String id;
 
-  @Schema(description = "Finding field that corresponds to the key of the output parser")
-  @JsonProperty("finding_field")
-  @NotBlank
-  private String field;
-
   @Schema(
       description = "Represents the data type being extracted.",
-      example = "text, number, port, portscan, ipv4, ipv6, credentials")
+      example = "text, number, port, portscan, ipv4, ipv6, credentials, cve")
   @JsonProperty("finding_type")
   @NotNull
   private ContractOutputType type;
@@ -45,32 +37,10 @@ public class FindingOutput {
   @NotBlank
   private String value;
 
-  @Schema(description = "Finding Name")
-  @JsonProperty("finding_name")
-  @NotBlank
-  private String name;
-
   @JsonProperty("finding_created_at")
   @NotNull
   private Instant creationDate;
 
-  @Schema(description = "Tags that correspond to the output parser tags")
-  @JsonProperty("finding_tags")
-  private Set<String> tagIds;
-
-  @Schema(description = "Inject linked to finding")
-  @JsonProperty("finding_inject")
-  @NotBlank
-  private InjectSimple inject;
-
-  @Schema(description = "Simulation linked to inject")
-  @JsonProperty("finding_simulation")
-  private ExerciseSimple simulation;
-
-  @Schema(description = "Scenario linked to inject")
-  @JsonProperty("finding_scenario")
-  private ScenarioSimple scenario;
-
   @Schema(description = "Endpoint linked to finding")
   @JsonProperty("finding_assets")
   @NotNull
diff --git a/openbas-api/src/main/java/io/openbas/rest/finding/form/PageAggregatedFindingOutput.java b/openbas-api/src/main/java/io/openbas/rest/finding/form/PageAggregatedFindingOutput.java
new file mode 100644
--- /dev/null
+++ b/openbas-api/src/main/java/io/openbas/rest/finding/form/PageAggregatedFindingOutput.java
@@ -0,0 +1,5 @@
+package io.openbas.rest.finding.form;
+
+import org.springframework.data.domain.Page;
+
+public interface PageAggregatedFindingOutput extends Page<AggregatedFindingOutput> {}
diff --git a/openbas-api/src/main/java/io/openbas/rest/finding/form/PageRelatedFindingOutput.java b/openbas-api/src/main/java/io/openbas/rest/finding/form/PageRelatedFindingOutput.java
new file mode 100644
--- /dev/null
+++ b/openbas-api/src/main/java/io/openbas/rest/finding/form/PageRelatedFindingOutput.java
@@ -0,0 +1,5 @@
+package io.openbas.rest.finding.form;
+
+import org.springframework.data.domain.Page;
+
+public interface PageRelatedFindingOutput extends Page<RelatedFindingOutput> {}
diff --git a/openbas-api/src/main/java/io/openbas/rest/finding/form/RelatedFindingOutput.java b/openbas-api/src/main/java/io/openbas/rest/finding/form/RelatedFindingOutput.java
new file mode 100644
--- /dev/null
+++ b/openbas-api/src/main/java/io/openbas/rest/finding/form/RelatedFindingOutput.java
@@ -0,0 +1,32 @@
+package io.openbas.rest.finding.form;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.openbas.rest.exercise.form.ExerciseSimple;
+import io.openbas.rest.inject.output.InjectSimple;
+import io.openbas.rest.scenario.form.ScenarioSimple;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+import lombok.experimental.SuperBuilder;
+
+@Data
+@SuperBuilder
+@JsonInclude(NON_NULL)
+public class RelatedFindingOutput extends AggregatedFindingOutput {
+
+  @Schema(description = "Inject linked to finding")
+  @JsonProperty("finding_inject")
+  @NotBlank
+  private InjectSimple inject;
+
+  @Schema(description = "Simulation linked to inject")
+  @JsonProperty("finding_simulation")
+  private ExerciseSimple simulation;
+
+  @Schema(description = "Scenario linked to inject")
+  @JsonProperty("finding_scenario")
+  private ScenarioSimple scenario;
+}
diff --git a/openbas-api/src/main/java/io/openbas/utils/FindingMapper.java b/openbas-api/src/main/java/io/openbas/utils/FindingMapper.java
--- a/openbas-api/src/main/java/io/openbas/utils/FindingMapper.java
+++ b/openbas-api/src/main/java/io/openbas/utils/FindingMapper.java
@@ -1,9 +1,10 @@
 package io.openbas.utils;
 
-import io.openbas.database.model.Endpoint;
-import io.openbas.database.model.Exercise;
-import io.openbas.database.model.Finding;
-import io.openbas.rest.finding.form.FindingOutput;
+import io.openbas.database.model.*;
+import io.openbas.database.repository.FindingRepository;
+import io.openbas.rest.finding.form.AggregatedFindingOutput;
+import io.openbas.rest.finding.form.RelatedFindingOutput;
+import java.util.List;
 import java.util.Optional;
 import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
@@ -15,19 +16,33 @@
 @Slf4j
 public class FindingMapper {
 
+  private final FindingRepository findingRepository;
   private final EndpointMapper endpointMapper;
   private final AssetGroupMapper assetGroupMapper;
   private final ExerciseMapper exerciseMapper;
   private final ScenarioMapper scenarioMapper;
   private final InjectMapper injectMapper;
 
-  public FindingOutput toFindingOutput(Finding finding) {
-    return FindingOutput.builder()
+  public AggregatedFindingOutput toAggregatedFindingOutput(
+      Finding finding, List<Asset> relatedAssets) {
+    return AggregatedFindingOutput.builder()
+        .id(finding.getId())
+        .value(finding.getValue())
+        .type(finding.getType())
+        .creationDate(finding.getCreationDate())
+        .endpoints(
+            relatedAssets.stream()
+                .filter(asset -> asset instanceof Endpoint)
+                .map(endpointMapper::toEndpointSimple)
+                .collect(Collectors.toSet()))
+        .build();
+  }
+
+  public RelatedFindingOutput toRelatedFindingOutput(Finding finding) {
+    return RelatedFindingOutput.builder()
         .id(finding.getId())
-        .field(finding.getField())
         .value(finding.getValue())
         .type(finding.getType())
-        .name(finding.getName())
         .endpoints(
             finding.getAssets().stream()
                 .filter(asset -> asset instanceof Endpoint)
@@ -47,7 +62,6 @@ public FindingOutput toFindingOutput(Finding finding) {
                 .map(Exercise::getScenario)
                 .map(scenario -> scenarioMapper.toScenarioSimple(scenario))
                 .orElse(null))
-        .tagIds(finding.getTags().stream().map(tag -> tag.getId()).collect(Collectors.toSet()))
         .creationDate(finding.getCreationDate())
         .build();
   }
diff --git a/openbas-model/src/main/java/io/openbas/database/model/TypeValueKey.java b/openbas-model/src/main/java/io/openbas/database/model/TypeValueKey.java
new file mode 100644
--- /dev/null
+++ b/openbas-model/src/main/java/io/openbas/database/model/TypeValueKey.java
@@ -0,0 +1,34 @@
+package io.openbas.database.model;
+
+import java.util.Objects;
+
+public class TypeValueKey {
+  private final ContractOutputType type;
+  private final String value;
+
+  public TypeValueKey(ContractOutputType type, String value) {
+    this.type = type;
+    this.value = value;
+  }
+
+  public ContractOutputType getType() {
+    return type;
+  }
+
+  public String getValue() {
+    return value;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof TypeValueKey)) return false;
+    TypeValueKey that = (TypeValueKey) o;
+    return type == that.type && Objects.equals(value, that.value);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(type, value);
+  }
+}
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,8 +1,10 @@
 package io.openbas.database.specification;
 
+import io.openbas.database.model.ContractOutputType;
 import io.openbas.database.model.ExerciseStatus;
 import io.openbas.database.model.Finding;
 import jakarta.persistence.criteria.*;
+import java.util.List;
 import org.jetbrains.annotations.NotNull;
 import org.springframework.data.jpa.domain.Specification;
 
@@ -56,4 +58,47 @@ public static Specification<Finding> forLatestSimulations() {
               cb.isNull(exerciseJoin1.get("id"))));
     };
   }
+
+  public static Specification<Finding> distinctTypeValueWithFilter(
+      Specification<Finding> baseSpec) {
+    return (root, query, cb) -> {
+      query.distinct(true);
+
+      Subquery<String> subquery = query.subquery(String.class);
+      Root<Finding> subRoot = subquery.from(Finding.class);
+
+      Predicate specPredicate = null;
+      if (baseSpec != null) {
+        specPredicate = baseSpec.toPredicate(subRoot, query, cb);
+      }
+
+      subquery.select(cb.least(subRoot.<String>get("id")));
+      if (specPredicate != null) {
+        subquery.where(specPredicate);
+      }
+      subquery.groupBy(subRoot.get("type"), subRoot.get("value"));
+
+      return root.get("id").in(subquery);
+    };
+  }
+
+  public static Specification<Finding> withAssets() {
+    return (root, query, cb) -> {
+      root.fetch("assets", JoinType.LEFT);
+      query.distinct(true);
+      return null;
+    };
+  }
+
+  public static Specification<Finding> findAllWithAssetsByTypeValueIn(
+      List<ContractOutputType> types, List<String> values, Specification<Finding> specification) {
+    return Specification.where(specification)
+        .and(withAssets())
+        .and(
+            (root, query, cb) -> {
+              Predicate typeIn = root.get("type").in(types);
+              Predicate valueIn = root.get("value").in(values);
+              return cb.and(typeIn, valueIn);
+            });
+  }
 }


Test patch:
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
@@ -2,8 +2,8 @@
 
 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.assertj.core.api.Assertions.assertThat;
 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;
@@ -11,9 +11,17 @@
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import io.openbas.IntegrationTest;
-import io.openbas.database.model.*;
+import io.openbas.database.model.AssetGroup;
+import io.openbas.database.model.ContractOutputType;
+import io.openbas.database.model.Endpoint;
+import io.openbas.database.model.Exercise;
+import io.openbas.database.model.Filters;
+import io.openbas.database.model.Finding;
+import io.openbas.database.model.Inject;
+import io.openbas.database.model.Scenario;
 import io.openbas.database.repository.FindingRepository;
-import io.openbas.rest.finding.form.FindingOutput;
+import io.openbas.database.specification.FindingSpecification;
+import io.openbas.rest.finding.form.RelatedFindingOutput;
 import io.openbas.utils.FindingMapper;
 import io.openbas.utils.fixtures.*;
 import io.openbas.utils.fixtures.composers.*;
@@ -26,9 +34,11 @@
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.*;
+import java.util.stream.Collectors;
 import net.javacrumbs.jsonunit.core.Option;
 import org.junit.jupiter.api.*;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.jpa.domain.Specification;
 import org.springframework.http.MediaType;
 import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.ResultActions;
@@ -207,14 +217,14 @@ public void ReturnsOnlyFindingsForLatestSimulationOfEachScenario() throws Except
                 .getResponse()
                 .getContentAsString();
 
-        List<FindingOutput> expectedFindings =
+        List<RelatedFindingOutput> expectedFindings =
             fromIterable(
                     findingRepository.findAllById(
                         latestFindingWrappers.stream()
                             .map(wrapper -> wrapper.get().getId())
                             .toList()))
                 .stream()
-                .map(findingMapper::toFindingOutput)
+                .map(findingMapper::toRelatedFindingOutput)
                 .limit(input.getSize())
                 .toList();
 
@@ -276,14 +286,14 @@ public void ReturnsOnlyFindingsForLatestFinishedSimulationOfEachScenario() throw
                 .getResponse()
                 .getContentAsString();
 
-        List<FindingOutput> expectedFindings =
+        List<RelatedFindingOutput> expectedFindings =
             fromIterable(
                     findingRepository.findAllById(
                         latestFindingWrappers.stream()
                             .map(wrapper -> wrapper.get().getId())
                             .toList()))
                 .stream()
-                .map(findingMapper::toFindingOutput)
+                .map(findingMapper::toRelatedFindingOutput)
                 .limit(input.getSize())
                 .toList();
 
@@ -330,14 +340,14 @@ public void ReturnsOnlyFindingsForLatestSimulation() throws Exception {
                 .getResponse()
                 .getContentAsString();
 
-        List<FindingOutput> expectedFindings =
+        List<RelatedFindingOutput> expectedFindings =
             fromIterable(
                     findingRepository.findAllById(
                         latestFindingWrappers.stream()
                             .map(wrapper -> wrapper.get().getId())
                             .toList()))
                 .stream()
-                .map(findingMapper::toFindingOutput)
+                .map(findingMapper::toRelatedFindingOutput)
                 .limit(input.getSize())
                 .toList();
 
@@ -371,15 +381,15 @@ public void ReturnsAllFindingsForObservedSimulation() throws Exception {
                 .getResponse()
                 .getContentAsString();
 
-        List<FindingOutput> expectedFindings =
+        List<RelatedFindingOutput> expectedFindings =
             fromIterable(
                     findingRepository.findAllById(
                         ex.getInjects().stream()
                             .flatMap(inject -> inject.getFindings().stream().map(Finding::getId))
                             .toList()))
                 .stream()
-                .map(findingMapper::toFindingOutput)
-                .sorted(Comparator.comparing(FindingOutput::getCreationDate))
+                .map(findingMapper::toRelatedFindingOutput)
+                .sorted(Comparator.comparing(RelatedFindingOutput::getCreationDate))
                 .limit(input.getSize())
                 .toList();
 
@@ -412,12 +422,12 @@ public void ReturnsAllFindingsForObservedInject() throws Exception {
                 .getResponse()
                 .getContentAsString();
 
-        List<FindingOutput> expectedFindings =
+        List<RelatedFindingOutput> expectedFindings =
             fromIterable(
                     findingRepository.findAllById(
                         inject.getFindings().stream().map(Finding::getId).toList()))
                 .stream()
-                .map(findingMapper::toFindingOutput)
+                .map(findingMapper::toRelatedFindingOutput)
                 .limit(input.getSize())
                 .toList();
 
@@ -486,14 +496,14 @@ public void ReturnsAllFindingsForLatestSimulationsInvolvingEndpoint() throws Exc
                 .getResponse()
                 .getContentAsString();
 
-        List<FindingOutput> expectedFindings =
+        List<RelatedFindingOutput> expectedFindings =
             fromIterable(
                     findingRepository.findAllById(
                         latestFindingWrappers.stream()
                             .map(wrapper -> wrapper.get().getId())
                             .toList()))
                 .stream()
-                .map(findingMapper::toFindingOutput)
+                .map(findingMapper::toRelatedFindingOutput)
                 .limit(input.getSize())
                 .toList();
 
@@ -550,14 +560,14 @@ public void ReturnsAllUnsolvedFindingsForLatestFinishedSimulationsInvolvingEndpo
                 .getResponse()
                 .getContentAsString();
 
-        List<FindingOutput> expectedFindings =
+        List<RelatedFindingOutput> expectedFindings =
             fromIterable(
                     findingRepository.findAllById(
                         latestFindingWrappers.stream()
                             .map(wrapper -> wrapper.get().getId())
                             .toList()))
                 .stream()
-                .map(findingMapper::toFindingOutput)
+                .map(findingMapper::toRelatedFindingOutput)
                 .limit(input.getSize())
                 .toList();
 
@@ -626,14 +636,14 @@ public void ReturnsAllFindingsForLatestFinishedSimulationsInvolvingEndpoint()
                 .getResponse()
                 .getContentAsString();
 
-        List<FindingOutput> expectedFindings =
+        List<RelatedFindingOutput> expectedFindings =
             fromIterable(
                     findingRepository.findAllById(
                         latestFindingWrappers.stream()
                             .map(wrapper -> wrapper.get().getId())
                             .toList()))
                 .stream()
-                .map(findingMapper::toFindingOutput)
+                .map(findingMapper::toRelatedFindingOutput)
                 .limit(input.getSize())
                 .toList();
 
@@ -653,6 +663,7 @@ class BasicTests {
     private AssetGroup savedAssetGroup;
     private Endpoint savedEndpoint;
     private InjectComposer.Composer injectWrapper;
+    private InjectComposer.Composer injectWrapper2;
 
     @BeforeEach
     void setup() {
@@ -676,6 +687,8 @@ void setup() {
               .forInject(InjectFixture.getDefaultInject())
               .withAssetGroup(assetGroupWrapper);
 
+      injectWrapper2 = injectComposer.forInject(InjectFixture.getDefaultInject());
+
       ExerciseComposer.Composer simulationWrapper =
           simulationComposer
               .forExercise(ExerciseFixture.createFinishedAttackExercise())
@@ -705,7 +718,6 @@ public void given_a_search_input_should_return_page_of_findings() throws Excepti
       SearchPaginationInput input =
           buildDefaultFilters(
               ContractOutputType.Text,
-              "Text",
               savedFinding,
               savedSimulation,
               savedScenario,
@@ -717,8 +729,6 @@ public void given_a_search_input_should_return_page_of_findings() throws Excepti
 
       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()))
@@ -745,13 +755,7 @@ void should_return_findings_by_simulation() throws Exception {
               .get();
       SearchPaginationInput input =
           buildDefaultFilters(
-              ContractOutputType.IPv6,
-              "IPv6",
-              savedFinding,
-              savedSimulation,
-              null,
-              savedEndpoint,
-              null);
+              ContractOutputType.IPv6, savedFinding, savedSimulation, null, savedEndpoint, null);
 
       performCallbackRequest(
               FINDING_URI + "/exercises/" + savedSimulation.getId() + "/search", input)
@@ -776,7 +780,6 @@ void should_return_findings_by_scenario() throws Exception {
       SearchPaginationInput input =
           buildDefaultFilters(
               ContractOutputType.Credentials,
-              "Credentials",
               savedFinding,
               null,
               savedScenario,
@@ -806,7 +809,7 @@ void should_return_findings_by_endpoint() throws Exception {
               .get();
       SearchPaginationInput input =
           buildDefaultFilters(
-              ContractOutputType.Text, "Text", savedFinding, null, null, savedEndpoint, null);
+              ContractOutputType.Text, savedFinding, null, null, savedEndpoint, null);
 
       performCallbackRequest(FINDING_URI + "/endpoints/" + savedEndpoint.getId() + "/search", input)
           .andExpect(
@@ -814,11 +817,58 @@ void should_return_findings_by_endpoint() throws Exception {
           .andExpect(jsonPath("$.content.[0].finding_type").value(savedFinding.getType().label))
           .andExpect(jsonPath("$.content.[0].finding_value").value("text_value"));
     }
+
+    @Test
+    void distinctTypeValueWithFilter_returnsDistinctFindings() {
+      // Create two findings with the same type and value (duplicates)
+      Finding f1 =
+          findingComposer
+              .forFinding(FindingFixture.createDefaultTextFinding())
+              .withInject(injectWrapper)
+              .withEndpoint(endpointComposer.forEndpoint(savedEndpoint))
+              .persist()
+              .get();
+
+      Finding f2 =
+          findingComposer
+              .forFinding(FindingFixture.createDefaultTextFinding())
+              .withInject(injectWrapper2)
+              .withEndpoint(endpointComposer.forEndpoint(savedEndpoint))
+              .persist()
+              .get();
+
+      // Create a unique finding with different type or value
+      Finding f3 =
+          findingComposer
+              .forFinding(FindingFixture.createDefaultIPV6Finding())
+              .withInject(injectWrapper)
+              .persist()
+              .get();
+
+      // base specification can be null (no additional filtering)
+      Specification<Finding> baseSpec = null;
+
+      Specification<Finding> distinctSpec =
+          FindingSpecification.distinctTypeValueWithFilter(baseSpec);
+
+      List<Finding> results = findingRepository.findAll(distinctSpec);
+
+      // Should return only 2 distinct findings (f1/f2 collapse to one)
+      assertThat(results).hasSize(2);
+
+      Set<String> distinctPairs =
+          results.stream()
+              .map(f -> f.getType().label + "::" + f.getValue())
+              .collect(Collectors.toSet());
+
+      assertThat(distinctPairs)
+          .containsExactlyInAnyOrder(
+              f1.getType().label + "::" + f1.getValue(), f3.getType().label + "::" + f3.getValue());
+    }
   }
 
   private SearchPaginationInput buildDefaultFilters(
       ContractOutputType type,
-      String name,
       Finding finding,
       Exercise simulation,
       Scenario scenario,
@@ -833,7 +883,6 @@ private SearchPaginationInput buildDefaultFilters(
     List<Filters.Filter> filters = new ArrayList<>();
 
     filters.add(buildFilter("finding_type", Filters.FilterOperator.contains, List.of(type.label)));
-    filters.add(buildFilter("finding_name", Filters.FilterOperator.contains, List.of(name)));
     filters.add(
         buildFilter("finding_created_at", Filters.FilterOperator.gt, List.of(now.toString())));
     filters.add(
diff --git a/openbas-front/src/admin/components/atomic_testings/atomic_testing/AtomicTestingFindings.tsx b/openbas-front/src/admin/components/atomic_testings/atomic_testing/AtomicTestingFindings.tsx
--- a/openbas-front/src/admin/components/atomic_testings/atomic_testing/AtomicTestingFindings.tsx
+++ b/openbas-front/src/admin/components/atomic_testings/atomic_testing/AtomicTestingFindings.tsx
@@ -1,7 +1,7 @@
 import { type FunctionComponent } from 'react';
 import { useParams } from 'react-router';
 
-import { searchFindingsForInjects } from '../../../../actions/findings/finding-actions';
+import { searchDistinctFindingsForInjects, searchFindingsForInjects } from '../../../../actions/findings/finding-actions';
 import { type InjectResultOverviewOutput, type SearchPaginationInput } from '../../../../utils/api-types';
 import FindingList from '../../findings/FindingList';
 
@@ -12,8 +12,12 @@ const AtomicTestingFindings: FunctionComponent = () => {
     return searchFindingsForInjects(injectId, input);
   };
 
+  const searchDistinct = (input: SearchPaginationInput) => {
+    return searchDistinctFindingsForInjects(injectId, input);
+  };
+
   return (
-    <FindingList filterLocalStorageKey="atm-findings" searchFindings={search} contextId={injectId} />
+    <FindingList filterLocalStorageKey="atm-findings" searchDistinctFindings={searchDistinct} searchFindings={search} contextId={injectId} />
   );
 };