@@ -1400,6 +1400,229 @@ void given_targetedAsset_should_linkFindingToIt() throws Exception {
14001400 assertEquals (endpointSaved .getId (), findings .getLast ().getAssets ().getFirst ().getId ());
14011401 }
14021402
1403+ // Deduplication
1404+
1405+ @ Test
1406+ @ DisplayName (
1407+ "Should consolidate duplicate CVE findings when structured output contains multiple entries with the same id" )
1408+ void shouldConsolidateDuplicateCveFindingsWhenStructuredOutputContainsDuplicates ()
1409+ throws Exception {
1410+ // -- PREPARE --
1411+ InjectExecutionInput input = new InjectExecutionInput ();
1412+ input .setMessage ("Duplicate CVE findings test" );
1413+ input .setOutputStructured (
1414+ """
1415+ {
1416+ "cve": [
1417+ {"id": "CVE-2025-99999", "host": "192.168.1.10", "severity": "critical"},
1418+ {"id": "CVE-2025-99999", "host": "192.168.1.20", "severity": "critical"}
1419+ ]
1420+ }
1421+ """ );
1422+ input .setAction (InjectExecutionAction .complete );
1423+ input .setStatus ("SUCCESS" );
1424+
1425+ Inject inject = getPendingInjectWithAssets ();
1426+ Injector injector = InjectorFixture .createDefaultPayloadInjector ();
1427+ injectTestHelper .forceSaveInjector (injector );
1428+
1429+ ObjectNode convertedContent =
1430+ (ObjectNode )
1431+ mapper .readTree (
1432+ """
1433+ {
1434+ "outputs": [
1435+ {
1436+ "field": "cve",
1437+ "isFindingCompatible": true,
1438+ "isMultiple": true,
1439+ "labels": [],
1440+ "type": "cve"
1441+ }
1442+ ]
1443+ }
1444+ """ );
1445+ convertedContent .set (CONTRACT_CONTENT_FIELDS , objectMapper .valueToTree (List .of ()));
1446+ InjectorContract injectorContract =
1447+ InjectorContractFixture .createInjectorContract (convertedContent );
1448+ injectorContract .setInjector (injector );
1449+ InjectorContract injectorContractSaved =
1450+ injectTestHelper .forceSaveInjectorContract (injectorContract );
1451+ inject .setInjectorContract (injectorContractSaved );
1452+ inject .setContent (convertedContent );
1453+ injectTestHelper .forceSaveInject (inject );
1454+
1455+ // -- EXECUTE --
1456+ performAgentlessCallbackRequest (inject .getId (), input );
1457+
1458+ // -- ASSERT --
1459+ Awaitility .await ()
1460+ .atMost (15 , TimeUnit .SECONDS )
1461+ .with ()
1462+ .pollInterval (1 , TimeUnit .SECONDS )
1463+ .until (() -> !injectTestHelper .findFindingsByInjectId (inject .getId ()).isEmpty ());
1464+
1465+ List <Finding > findings = findingRepository .findAllByInjectId (inject .getId ());
1466+ assertEquals (
1467+ 1 ,
1468+ findings .size (),
1469+ "Duplicate CVE findings with the same id must be consolidated into one" );
1470+ assertEquals (ContractOutputType .CVE , findings .getFirst ().getType ());
1471+ assertEquals ("CVE-2025-99999" , findings .getFirst ().getValue ());
1472+ }
1473+
1474+ @ Test
1475+ @ DisplayName (
1476+ "Should consolidate duplicate Port findings when two scanned hosts both have the same port open" )
1477+ void shouldConsolidateDuplicatePortFindingsWhenTwoHostsHaveSamePortOpen () throws Exception {
1478+ // -- PREPARE --
1479+ InjectExecutionInput input = new InjectExecutionInput ();
1480+ input .setMessage ("nmap TCP connect scan" );
1481+ input .setOutputStructured (
1482+ """
1483+ {
1484+ "ports": [22, 8080, 8080]
1485+ }
1486+ """ );
1487+ input .setAction (InjectExecutionAction .complete );
1488+ input .setStatus ("SUCCESS" );
1489+
1490+ Inject inject = getPendingInjectWithAssets ();
1491+ Injector injector = InjectorFixture .createDefaultPayloadInjector ();
1492+ injectTestHelper .forceSaveInjector (injector );
1493+
1494+ ObjectNode convertedContent =
1495+ (ObjectNode )
1496+ mapper .readTree (
1497+ """
1498+ {
1499+ "outputs": [
1500+ {
1501+ "field": "ports",
1502+ "isFindingCompatible": true,
1503+ "isMultiple": true,
1504+ "labels": ["scan"],
1505+ "type": "port"
1506+ }
1507+ ]
1508+ }
1509+ """ );
1510+ convertedContent .set (CONTRACT_CONTENT_FIELDS , objectMapper .valueToTree (List .of ()));
1511+ InjectorContract injectorContract =
1512+ InjectorContractFixture .createInjectorContract (convertedContent );
1513+ injectorContract .setInjector (injector );
1514+ InjectorContract injectorContractSaved =
1515+ injectTestHelper .forceSaveInjectorContract (injectorContract );
1516+ inject .setInjectorContract (injectorContractSaved );
1517+ inject .setContent (convertedContent );
1518+ injectTestHelper .forceSaveInject (inject );
1519+
1520+ // -- EXECUTE --
1521+ performAgentlessCallbackRequest (inject .getId (), input );
1522+
1523+ // -- ASSERT --
1524+ Awaitility .await ()
1525+ .atMost (15 , TimeUnit .SECONDS )
1526+ .with ()
1527+ .pollInterval (1 , TimeUnit .SECONDS )
1528+ .until (() -> injectTestHelper .findFindingsByInjectId (inject .getId ()).size () >= 2 );
1529+
1530+ List <Finding > findings = findingRepository .findAllByInjectId (inject .getId ());
1531+ assertEquals (
1532+ 2 ,
1533+ findings .size (),
1534+ "Port 22 and port 8080 (deduplicated) must produce exactly 2 findings" );
1535+ assertTrue (
1536+ findings .stream ().anyMatch (f -> f .getValue ().equals ("22" )),
1537+ "Expected finding for port 22" );
1538+ assertTrue (
1539+ findings .stream ().anyMatch (f -> f .getValue ().equals ("8080" )),
1540+ "Expected deduplicated finding for port 8080" );
1541+ findings .forEach (f -> assertEquals (ContractOutputType .Port , f .getType ()));
1542+ }
1543+
1544+ @ Test
1545+ @ DisplayName (
1546+ "Should merge assets of duplicate PortsScan findings when two assets expose the same host/port/service" )
1547+ void shouldMergeAssetsOfDuplicatePortScanFindingsWhenTwoAssetsHaveSameHostPortService ()
1548+ throws Exception {
1549+ // -- PREPARE --
1550+ Endpoint endpointA = EndpointFixture .createEndpoint ();
1551+ Endpoint endpointASaved = injectTestHelper .forceSaveEndpoint (endpointA );
1552+ Endpoint endpointB = EndpointFixture .createEndpoint ();
1553+ Endpoint endpointBSaved = injectTestHelper .forceSaveEndpoint (endpointB );
1554+
1555+ InjectExecutionInput input = new InjectExecutionInput ();
1556+ input .setMessage ("nmap scan_results two assets both expose 192.168.1.10:8080/http" );
1557+ input .setOutputStructured (
1558+ String .format (
1559+ """
1560+ {
1561+ "scan_results": [
1562+ {"asset_id": "%s", "host": "192.168.1.10", "port": "8080", "service": "http"},
1563+ {"asset_id": "%s", "host": "192.168.1.10", "port": "8080", "service": "http"}
1564+ ]
1565+ }
1566+ """ ,
1567+ endpointASaved .getId (), endpointBSaved .getId ()));
1568+ input .setAction (InjectExecutionAction .complete );
1569+ input .setStatus ("SUCCESS" );
1570+
1571+ Inject inject = getPendingInjectWithAssets ();
1572+ Injector injector = InjectorFixture .createDefaultPayloadInjector ();
1573+ injectTestHelper .forceSaveInjector (injector );
1574+
1575+ ObjectNode convertedContent =
1576+ (ObjectNode )
1577+ mapper .readTree (
1578+ """
1579+ {
1580+ "outputs": [
1581+ {
1582+ "field": "scan_results",
1583+ "isFindingCompatible": true,
1584+ "isMultiple": true,
1585+ "labels": ["scan"],
1586+ "type": "portscan"
1587+ }
1588+ ]
1589+ }
1590+ """ );
1591+ convertedContent .set (CONTRACT_CONTENT_FIELDS , objectMapper .valueToTree (List .of ()));
1592+ InjectorContract injectorContract =
1593+ InjectorContractFixture .createInjectorContract (convertedContent );
1594+ injectorContract .setInjector (injector );
1595+ InjectorContract injectorContractSaved =
1596+ injectTestHelper .forceSaveInjectorContract (injectorContract );
1597+ inject .setInjectorContract (injectorContractSaved );
1598+ inject .setContent (convertedContent );
1599+ injectTestHelper .forceSaveInject (inject );
1600+
1601+ // -- EXECUTE --
1602+ performAgentlessCallbackRequest (inject .getId (), input );
1603+
1604+ // -- ASSERT --
1605+ Awaitility .await ()
1606+ .atMost (15 , TimeUnit .SECONDS )
1607+ .with ()
1608+ .pollInterval (1 , TimeUnit .SECONDS )
1609+ .until (() -> !injectTestHelper .findFindingsByInjectId (inject .getId ()).isEmpty ());
1610+
1611+ List <Finding > findings = findingRepository .findAllByInjectId (inject .getId ());
1612+ assertEquals (
1613+ 1 ,
1614+ findings .size (),
1615+ "Two PortsScan entries with same host/port/service must be consolidated into one finding" );
1616+ Finding merged = findings .getFirst ();
1617+ assertEquals (ContractOutputType .PortsScan , merged .getType ());
1618+ assertEquals ("192.168.1.10:8080 (http)" , merged .getValue ());
1619+ List <String > assetIds = merged .getAssets ().stream ().map (Asset ::getId ).toList ();
1620+ assertTrue (
1621+ assetIds .contains (endpointASaved .getId ()), "Merged finding must be linked to asset A" );
1622+ assertTrue (
1623+ assetIds .contains (endpointBSaved .getId ()), "Merged finding must be linked to asset B" );
1624+ }
1625+
14031626 // CVE
14041627
14051628 @ Test
0 commit comments