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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.tracepcap.insights.controller;

import com.tracepcap.common.exception.ResourceNotFoundException;
import com.tracepcap.insights.dto.HostIdentityDto;
import com.tracepcap.insights.dto.HostIdentityEvidenceDto;
import com.tracepcap.insights.dto.NodeRoleDto;
import com.tracepcap.insights.repository.HostIdentityRepository;
import com.tracepcap.insights.service.HostIdentityEvidenceService;
import com.tracepcap.insights.service.HostIdentityService;
import com.tracepcap.insights.service.NodeRoleService;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -28,6 +31,7 @@ public class HostIdentitiesController {

private final HostIdentityRepository hostIdentityRepository;
private final HostIdentityService hostIdentityService;
private final HostIdentityEvidenceService hostIdentityEvidenceService;
private final NodeRoleService nodeRoleService;

@GetMapping("/{fileId}/host-identities")
Expand Down Expand Up @@ -70,6 +74,24 @@ public ResponseEntity<List<HostIdentityDto>> getHostIdentities(@PathVariable UUI
return ResponseEntity.ok(result);
}

@GetMapping("/{fileId}/hosts/{ip}/identity")
@Operation(
summary = "Adjudicated identity plus evidence axes for one host",
description =
"The full explainable classification for a single IP — the verdict (label/basis/confidence/"
+ "contested/candidates) and the measured facts behind it (hardware, service, behaviour) —"
+ " so any surface holding a fileId+ip can render 'the verdict, and why' without the graph.")
public ResponseEntity<HostIdentityEvidenceDto> getHostIdentityEvidence(
@PathVariable UUID fileId, @PathVariable String ip) {
return ResponseEntity.ok(
hostIdentityEvidenceService
.evidenceFor(fileId, ip)
.orElseThrow(
() ->
new ResourceNotFoundException(
"No host " + ip + " in file " + fileId)));
}

@GetMapping("/{fileId}/node-roles")
@Operation(
summary = "All human-confirmed entity roles in a file",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.tracepcap.insights.dto;

import java.util.List;
import java.util.Map;
import lombok.Builder;
import lombok.Value;

/**
* The full explainable-classification payload for one host in one file (#556 follow-up).
*
* <p>Composes the adjudicated identity (the verdict) with the measured evidence axes (the facts that
* fed the vote), so any host-inspection surface — the network-graph node, the conversation host
* click, the monitor drift panels — can render the same "verdict, and why" experience from just a
* {@code fileId + ip}. Before this, that experience lived only in the network graph, which had the
* axis facts because it derived them frontend-side from the conversation list; a bare IP had no way
* to obtain them.
*
* <p>Grades, deliberately kept distinct (mirrors the SPI split):
*
* <ul>
* <li><b>Verdict</b> — {@code primaryLabel}/{@code basis}/{@code confidence}/{@code contested}/
* {@code candidates}: the adjudicated identity and its per-candidate "why" breakdown.
* <li><b>Hardware facts</b> — {@code manufacturer}, {@code ttl}: physical fingerprint.
* <li><b>Service facts</b> — {@code serviceRoles} (confirmed roles) and {@code ndpiApps} (apps nDPI
* identified in the host's traffic).
* <li><b>Behaviour facts</b> — {@code initiatedConversations}/{@code answeredConversations}:
* measured from who opened each connection (#496). These are direction-gated: when nDPI could
* not measure who opened a flow, both stay 0. {@code conversationCount}/{@code peerCount} are
* the same behaviour axis counted <em>without</em> that gate — they are the raw fan-out the
* classifier's traffic-pattern signal actually weighs (≥15 peers → router), so the panel can
* show real evidence even on a capture where direction was never measured.
* </ul>
*
* The axes carry facts only — scores belong to {@code candidates}, never to a fact (#499).
*/
@Value
@Builder
public class HostIdentityEvidenceDto {
String ip;

// ── Verdict (adjudicated identity) ──────────────────────────────────────────
/** The one answer to "what is this host?" — a device type, or the human's label verbatim. */
String primaryLabel;
/** HUMAN (confirmed node-role/override label) or MACHINE (classification vote). */
String basis;
int confidence;
/** True when machine candidates were too close to call; render the contest, not the winner. */
boolean contested;
/** Competing candidates when contested; each is {@code {label, source, score, reasons[]}}. */
List<Map<String, Object>> candidates;

// ── Hardware facts ──────────────────────────────────────────────────────────
String manufacturer;
Integer ttl;

// ── Service facts ───────────────────────────────────────────────────────────
/** Confirmed service roles (e.g. ["dns"]); never null, may be empty. */
List<String> serviceRoles;
/** nDPI application names identified in this host's traffic; never null, may be empty. */
List<String> ndpiApps;

// ── Behaviour facts (#496) ──────────────────────────────────────────────────
/** Connections this host opened (sent SYN without ACK). */
int initiatedConversations;
/** Connections opened by peers that this host answered. */
int answeredConversations;
/** Total conversations this host took part in, regardless of measured direction. */
int conversationCount;
/** Distinct peers this host talked to — the fan-out the router signal keys on. */
int peerCount;

// ── Geolocation (external hosts only) ───────────────────────────────────────
// From the persisted GeoIP cache (offline MMDB or ipinfo, per the offline requirement). All null
// for private/internal IPs, which are never geolocated — the panel omits the whole block then.
/** Country name, e.g. "United States". */
String country;
/** ISO country code, e.g. "US" — drives the flag emoji. */
String countryCode;
/** Autonomous-system number, e.g. "AS8075" (ipinfo only; null under the offline MMDB). */
String asn;
/** Owning organisation, e.g. "Microsoft Corporation". */
String org;
/** Which resolver produced this — "ipinfo" (online) or "mmdb" (offline DB). */
String geoSource;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package com.tracepcap.insights.service;

import com.tracepcap.analysis.spi.ConversationLookup;
import com.tracepcap.analysis.spi.ConversationLookup.ConversationFacts;
import com.tracepcap.analysis.spi.GeoOrgLookup;
import com.tracepcap.analysis.spi.GeoOrgLookup.IpPlace;
import com.tracepcap.analysis.spi.HostClassificationLookup;
import com.tracepcap.analysis.spi.HostClassificationLookup.HostFacts;
import com.tracepcap.insights.dto.HostIdentityEvidenceDto;
import com.tracepcap.insights.entity.HostIdentityEntity;
import com.tracepcap.insights.repository.HostIdentityRepository;
import org.springframework.dao.DataIntegrityViolationException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

/**
* Composes the full explainable-classification payload for one host (#556 follow-up): the
* adjudicated identity plus the measured evidence axes, so any surface holding a {@code fileId + ip}
* can render the "verdict, and why" experience that used to live only in the network graph.
*
* <p>The verdict comes from the adjudicated {@link HostIdentityEntity}; hardware/service facts from
* {@link HostClassificationLookup}; and the behaviour facts (who opened the connection) plus the
* nDPI app list are derived here from {@link ConversationLookup#conversationFactsForIp}, the same
* per-IP fact base the frontend previously folded by hand off the whole graph.
*/
@Service
@RequiredArgsConstructor
public class HostIdentityEvidenceService {

private final HostIdentityRepository hostIdentityRepository;
private final HostIdentityService hostIdentityService;
private final HostClassificationLookup hostClassificationLookup;
private final ConversationLookup conversationLookup;
private final GeoOrgLookup geoOrgLookup;

/**
* The identity + evidence for one host, or empty when the file has no such host. Lazily backfills
* identities for legacy files (mirrors {@code HostIdentitiesController}) so a host classified
* before the adjudicator existed still resolves a verdict.
*/
public Optional<HostIdentityEvidenceDto> evidenceFor(UUID fileId, String ip) {
Optional<HostFacts> factsOpt = hostClassificationLookup.hostFactsByIp(fileId, ip);
if (factsOpt.isEmpty()) {
return Optional.empty();
}
HostFacts facts = factsOpt.get();

// Lazy backfill (#521): files analysed before the adjudicator existed have classifications but no
// identities. Adjudicate on first read. This mirrors HostIdentitiesController#getHostIdentities,
// including the concurrent-first-read recovery: two panels opened at once can both find it empty
// and both delete-and-regenerate; the loser hits a unique (file_id, ip) violation, which is fine
// as long as the winner's rows are now present — otherwise it was a real failure and propagates.
List<HostIdentityEntity> identities = hostIdentityRepository.findByFileId(fileId);
if (identities.isEmpty()) {
try {
hostIdentityService.adjudicateFile(fileId);
} catch (DataIntegrityViolationException raced) {
if (hostIdentityRepository.findByFileId(fileId).isEmpty()) {
throw raced;
}
}
identities = hostIdentityRepository.findByFileId(fileId);
}
HostIdentityEntity identity =
identities.stream().filter(h -> ip.equals(h.getIp())).findFirst().orElse(null);

// Behaviour + nDPI apps, folded from this IP's conversations (#496).
//
// initiated/answered are direction-gated (require a measured initiator); conversationCount and
// the distinct-peer set are NOT — they are the raw fan-out the traffic-pattern signal weighs, so
// the panel still shows real evidence on a capture where nDPI never measured flow direction.
int initiated = 0;
int answered = 0;
int conversationCount = 0;
Set<String> peers = new LinkedHashSet<>();
Set<String> apps = new LinkedHashSet<>();
for (ConversationFacts c : conversationLookup.conversationFactsForIp(fileId, ip)) {
conversationCount++;
String src = c.flow().srcIp();
String dst = c.flow().dstIp();
String peer = ip.equals(src) ? dst : src;
if (peer != null && !peer.isBlank() && !ip.equals(peer)) {
peers.add(peer);
}
String initiator = c.flow().initiatorIp();
if (initiator != null) {
if (ip.equals(initiator)) {
initiated++;
} else {
answered++;
}
}
String app = c.findings().appName();
if (app != null && !app.isBlank()) {
apps.add(app);
}
}

HostIdentityEvidenceDto.HostIdentityEvidenceDtoBuilder b =
HostIdentityEvidenceDto.builder()
.ip(ip)
.manufacturer(facts.manufacturer())
.ttl(facts.ttl())
.serviceRoles(new ArrayList<>(facts.serviceRoles()))
.ndpiApps(new ArrayList<>(apps))
.initiatedConversations(initiated)
.answeredConversations(answered)
.conversationCount(conversationCount)
.peerCount(peers.size());

// Geolocation for external hosts (INFERRED, provenance-carrying). Absent from the map for
// private/internal IPs, which are never geolocated — the builder stays null and the panel omits
// the block. This reads the persisted geo cache; it does not trigger a fresh lookup on view.
IpPlace place = geoOrgLookup.placesFor(List.of(ip)).get(ip);
if (place != null) {
b.country(place.country())
.countryCode(place.countryCode())
.asn(place.asn())
.org(place.org())
.geoSource(place.geoSource());
}

if (identity != null) {
b.primaryLabel(identity.getPrimaryLabel())
.basis(identity.getBasis())
.confidence(identity.getConfidence())
.contested(identity.isContested())
.candidates(identity.getCandidates());
} else {
// No adjudicated identity (e.g. a host with no conversations the adjudicator saw): fall back to
// the raw classification so the surface still names something rather than blank.
b.primaryLabel(facts.deviceType())
.basis(HostIdentityEntity.BASIS_MACHINE)
.confidence(facts.confidence())
.contested(false)
.candidates(List.of());
}

return Optional.of(b.build());
}
}
Loading
Loading