From 946636d8f472316611b5ff8be838aee5d333c443 Mon Sep 17 00:00:00 2001 From: anti Date: Thu, 21 May 2026 15:12:39 -0400 Subject: [PATCH] feat(ui): wire icmp_error / icmp6_error fingerprint probes into AttackerDetail --- decnet_web/src/components/AttackerDetail.tsx | 2 +- .../fingerprints/fingerprints.test.tsx | 106 +++++++++++++++++- .../AttackerDetail/fingerprints/helpers.tsx | 4 + .../AttackerDetail/fingerprints/index.tsx | 2 +- .../AttackerDetail/fingerprints/renderers.tsx | 94 ++++++++++++++++ 5 files changed, 205 insertions(+), 3 deletions(-) diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 760b7b65..74936480 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -233,7 +233,7 @@ const AttackerDetail: React.FC = () => { }); // Active probes first, then passive, then unknown - const activeTypes = ['jarm', 'hassh_server', 'tcpfp', 'tls_certificate_active']; + const activeTypes = ['jarm', 'hassh_server', 'tcpfp', 'tls_certificate_active', 'icmp_error', 'icmp6_error']; const passiveTypes = ['ja3', 'ja4l', 'tls_resumption', 'tls_certificate_passive', 'http_useragent', 'http_quirks', 'spoofed_source', 'vnc_client_version']; const knownTypes = [...activeTypes, ...passiveTypes]; const unknownTypes = Object.keys(groups).filter((t) => !knownTypes.includes(t)); diff --git a/decnet_web/src/components/AttackerDetail/fingerprints/fingerprints.test.tsx b/decnet_web/src/components/AttackerDetail/fingerprints/fingerprints.test.tsx index 5f10b84a..df8a4fe0 100644 --- a/decnet_web/src/components/AttackerDetail/fingerprints/fingerprints.test.tsx +++ b/decnet_web/src/components/AttackerDetail/fingerprints/fingerprints.test.tsx @@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { FingerprintGroup, FpUserAgent, FpHttpQuirks, FpResumption, - FpCertificate, FpSpoofedSource, FpTcpStack, + FpCertificate, FpSpoofedSource, FpTcpStack, FpIcmpError, FpIcmp6Error, } from './renderers'; describe('FpUserAgent', () => { @@ -146,4 +146,108 @@ describe('FingerprintGroup', () => { expect(screen.getByText('WEIRD UNKNOWN')).toBeInTheDocument(); expect(screen.getByText('mystery-value')).toBeInTheDocument(); }); + + it('dispatches icmp_error to FpIcmpError', () => { + render( + , + ); + expect(screen.getByText('ICMP ERROR LEAK')).toBeInTheDocument(); + expect(screen.getByText('PTFP')).toBeInTheDocument(); + }); + + it('dispatches icmp6_error to FpIcmp6Error', () => { + render( + , + ); + expect(screen.getByText('ICMPv6 ERROR LEAK')).toBeInTheDocument(); + expect(screen.getByText('PHUB')).toBeInTheDocument(); + }); +}); + +describe('FpIcmpError', () => { + it('renders hash, matrix, returned error tags with RTT, and first-hop IP', () => { + render( + , + ); + expect(screen.getByText('PTFP')).toBeInTheDocument(); + expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); + expect(screen.getByText('198.51.100.7')).toBeInTheDocument(); + }); + + it('renders without first-hop when time_exceeded has no src_ip', () => { + render( + , + ); + expect(screen.queryByText('FIRST HOP')).not.toBeInTheDocument(); + }); +}); + +describe('FpIcmp6Error', () => { + it('renders hash, matrix, returned tags, and first-hop IP', () => { + render( + , + ); + expect(screen.getByText('PHUB')).toBeInTheDocument(); + expect(screen.getByText('fe80::1')).toBeInTheDocument(); + expect(screen.getByText('2001:db8::1')).toBeInTheDocument(); + }); }); diff --git a/decnet_web/src/components/AttackerDetail/fingerprints/helpers.tsx b/decnet_web/src/components/AttackerDetail/fingerprints/helpers.tsx index 9076c5e3..edab26d1 100644 --- a/decnet_web/src/components/AttackerDetail/fingerprints/helpers.tsx +++ b/decnet_web/src/components/AttackerDetail/fingerprints/helpers.tsx @@ -21,6 +21,8 @@ export const fpTypeLabel: Record = { jarm: 'JARM', hassh_server: 'HASSH SERVER', tcpfp: 'TCP/IP STACK', + icmp_error: 'ICMP ERROR LEAK', + icmp6_error: 'ICMPv6 ERROR LEAK', }; export const fpTypeIcon: Record = { @@ -41,6 +43,8 @@ export const fpTypeIcon: Record = { jarm: , hassh_server: , tcpfp: , + icmp_error: , + icmp6_error: , }; export const UA_CATEGORY_COLOR: Record = { diff --git a/decnet_web/src/components/AttackerDetail/fingerprints/index.tsx b/decnet_web/src/components/AttackerDetail/fingerprints/index.tsx index 425e0d23..bb93f4e9 100644 --- a/decnet_web/src/components/AttackerDetail/fingerprints/index.tsx +++ b/decnet_web/src/components/AttackerDetail/fingerprints/index.tsx @@ -2,7 +2,7 @@ export { FingerprintGroup } from './renderers'; export { FpTlsHashes, FpLatency, FpResumption, FpCertificate, FpJarm, FpHassh, FpTcpStack, FpGeneric, FpUserAgent, FpSpoofedSource, - FpHttpQuirks, + FpHttpQuirks, FpIcmpError, FpIcmp6Error, } from './renderers'; export { fpTypeLabel, fpTypeIcon, getPayload, seqClassColor, diff --git a/decnet_web/src/components/AttackerDetail/fingerprints/renderers.tsx b/decnet_web/src/components/AttackerDetail/fingerprints/renderers.tsx index a65c3a25..8624e33f 100644 --- a/decnet_web/src/components/AttackerDetail/fingerprints/renderers.tsx +++ b/decnet_web/src/components/AttackerDetail/fingerprints/renderers.tsx @@ -391,6 +391,98 @@ export const FpHttpQuirks: React.FC<{ p: any }> = ({ p }) => { ); }; +export const FpIcmpError: React.FC<{ p: any }> = ({ p }) => { + const errors: Record = + p.errors ?? {}; + const ERROR_LABELS: Record = { + port_unreachable: 'PORT UNREACH', + time_exceeded: 'TIME EXCEEDED', + frag_needed: 'FRAG NEEDED', + param_problem: 'PARAM PROBLEM', + }; + return ( +
+ + {p.matrix && ( + + {p.matrix} + + )} +
+ {Object.entries(ERROR_LABELS).map(([key, label]) => { + const e = errors[key]; + const hit = e?.returned === true; + return ( + + + {label}{hit && e?.rtt_ms ? ` ${e.rtt_ms}ms` : ''} + + + ); + })} +
+ {errors.time_exceeded?.src_ip && ( +
+ FIRST HOP + {errors.time_exceeded.src_ip} +
+ )} + {(p.target_ip || p.target_port) && ( +
+ {p.target_ip && {p.target_ip}} + {p.target_port && :{p.target_port}} +
+ )} +
+ ); +}; + +export const FpIcmp6Error: React.FC<{ p: any }> = ({ p }) => { + const errors: Record = + p.errors ?? {}; + const ERROR_LABELS: Record = { + port_unreachable_v6: 'PORT UNREACH', + hop_limit_exceeded: 'HOP LIMIT', + unknown_next_header: 'UNKNOWN NXT HDR', + bad_dest_option: 'BAD DEST OPT', + }; + return ( +
+ + {p.matrix && ( + + {p.matrix} + + )} +
+ {Object.entries(ERROR_LABELS).map(([key, label]) => { + const e = errors[key]; + const hit = e?.returned === true; + return ( + + + {label}{hit && e?.rtt_ms ? ` ${e.rtt_ms}ms` : ''} + + + ); + })} +
+ {errors.hop_limit_exceeded?.src_ip && ( +
+ FIRST HOP + {errors.hop_limit_exceeded.src_ip} +
+ )} + {(p.target_ip || p.target_port) && ( +
+ {p.target_ip && {p.target_ip}} + {p.target_port && :{p.target_port}} +
+ )} +
+ ); +}; + export const FingerprintGroup: React.FC<{ fpType: string; items: any[] }> = ({ fpType, items }) => { const label = fpTypeLabel[fpType] || fpType.toUpperCase().replace(/_/g, ' '); const icon = fpTypeIcon[fpType] || ; @@ -429,6 +521,8 @@ export const FingerprintGroup: React.FC<{ fpType: string; items: any[] }> = ({ f case 'http3_settings': return ; case 'ja4_quic': return ; + case 'icmp_error': return ; + case 'icmp6_error': return ; default: return ; } })}