feat(ui): wire icmp_error / icmp6_error fingerprint probes into AttackerDetail

This commit is contained in:
2026-05-21 15:12:39 -04:00
parent 2af46ed102
commit 946636d8f4
5 changed files with 205 additions and 3 deletions

View File

@@ -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));

View File

@@ -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(
<FingerprintGroup
fpType="icmp_error"
items={[{
payload: {
fingerprint_type: 'icmp_error',
fp_hash: 'aabbccdd11223344',
matrix: 'PTFP',
errors: {
port_unreachable: { returned: true, rtt_ms: '12.3' },
time_exceeded: { returned: true, rtt_ms: '8.1', src_ip: '10.0.0.1' },
frag_needed: { returned: false },
param_problem: { returned: false },
},
},
}]}
/>,
);
expect(screen.getByText('ICMP ERROR LEAK')).toBeInTheDocument();
expect(screen.getByText('PTFP')).toBeInTheDocument();
});
it('dispatches icmp6_error to FpIcmp6Error', () => {
render(
<FingerprintGroup
fpType="icmp6_error"
items={[{
payload: {
fingerprint_type: 'icmp6_error',
fp_hash: 'ff00112233445566',
matrix: 'PHUB',
errors: {
port_unreachable_v6: { returned: true, rtt_ms: '5.2' },
hop_limit_exceeded: { returned: true, rtt_ms: '3.7', src_ip: 'fe80::1' },
unknown_next_header: { returned: false },
bad_dest_option: { returned: false },
},
},
}]}
/>,
);
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(
<FpIcmpError p={{
fp_hash: 'aabbccdd11223344',
matrix: 'PTFP',
errors: {
port_unreachable: { returned: true, rtt_ms: '12.3' },
time_exceeded: { returned: true, rtt_ms: '8.1', src_ip: '10.0.0.1' },
frag_needed: { returned: false },
param_problem: { returned: false },
},
target_ip: '198.51.100.7',
}} />,
);
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(
<FpIcmpError p={{
fp_hash: 'deadbeef',
matrix: 'P---',
errors: {
port_unreachable: { returned: true, rtt_ms: '5.0' },
time_exceeded: { returned: false },
frag_needed: { returned: false },
param_problem: { returned: false },
},
}} />,
);
expect(screen.queryByText('FIRST HOP')).not.toBeInTheDocument();
});
});
describe('FpIcmp6Error', () => {
it('renders hash, matrix, returned tags, and first-hop IP', () => {
render(
<FpIcmp6Error p={{
fp_hash: 'ff00112233445566',
matrix: 'PHUB',
errors: {
port_unreachable_v6: { returned: true, rtt_ms: '5.2' },
hop_limit_exceeded: { returned: true, rtt_ms: '3.7', src_ip: 'fe80::1' },
unknown_next_header: { returned: false },
bad_dest_option: { returned: false },
},
target_ip: '2001:db8::1',
}} />,
);
expect(screen.getByText('PHUB')).toBeInTheDocument();
expect(screen.getByText('fe80::1')).toBeInTheDocument();
expect(screen.getByText('2001:db8::1')).toBeInTheDocument();
});
});

View File

@@ -21,6 +21,8 @@ export const fpTypeLabel: Record<string, string> = {
jarm: 'JARM',
hassh_server: 'HASSH SERVER',
tcpfp: 'TCP/IP STACK',
icmp_error: 'ICMP ERROR LEAK',
icmp6_error: 'ICMPv6 ERROR LEAK',
};
export const fpTypeIcon: Record<string, React.ReactNode> = {
@@ -41,6 +43,8 @@ export const fpTypeIcon: Record<string, React.ReactNode> = {
jarm: <Crosshair size={14} />,
hassh_server: <Lock size={14} />,
tcpfp: <Wifi size={14} />,
icmp_error: <Wifi size={14} />,
icmp6_error: <Crosshair size={14} />,
};
export const UA_CATEGORY_COLOR: Record<string, string> = {

View File

@@ -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,

View File

@@ -391,6 +391,98 @@ export const FpHttpQuirks: React.FC<{ p: any }> = ({ p }) => {
);
};
export const FpIcmpError: React.FC<{ p: any }> = ({ p }) => {
const errors: Record<string, { returned?: boolean; rtt_ms?: string | null; src_ip?: string | null }> =
p.errors ?? {};
const ERROR_LABELS: Record<string, string> = {
port_unreachable: 'PORT UNREACH',
time_exceeded: 'TIME EXCEEDED',
frag_needed: 'FRAG NEEDED',
param_problem: 'PARAM PROBLEM',
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<HashRow label="HASH" value={p.fp_hash} />
{p.matrix && (
<span className="matrix-text" style={{ fontFamily: 'monospace', fontSize: '0.85rem', letterSpacing: '2px' }}>
{p.matrix}
</span>
)}
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{Object.entries(ERROR_LABELS).map(([key, label]) => {
const e = errors[key];
const hit = e?.returned === true;
return (
<Tag key={key} color={hit ? 'var(--warn, #e0a040)' : undefined}>
<span style={{ opacity: hit ? 1 : 0.4 }}>
{label}{hit && e?.rtt_ms ? ` ${e.rtt_ms}ms` : ''}
</span>
</Tag>
);
})}
</div>
{errors.time_exceeded?.src_ip && (
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', marginTop: '2px' }}>
<span className="dim" style={{ fontSize: '0.7rem' }}>FIRST HOP</span>
<Tag color="var(--accent-color)">{errors.time_exceeded.src_ip}</Tag>
</div>
)}
{(p.target_ip || p.target_port) && (
<div style={{ display: 'flex', gap: '8px', marginTop: '2px', flexWrap: 'wrap' }}>
{p.target_ip && <Tag color="var(--accent-color)">{p.target_ip}</Tag>}
{p.target_port && <Tag>:{p.target_port}</Tag>}
</div>
)}
</div>
);
};
export const FpIcmp6Error: React.FC<{ p: any }> = ({ p }) => {
const errors: Record<string, { returned?: boolean; rtt_ms?: string | null; src_ip?: string | null }> =
p.errors ?? {};
const ERROR_LABELS: Record<string, string> = {
port_unreachable_v6: 'PORT UNREACH',
hop_limit_exceeded: 'HOP LIMIT',
unknown_next_header: 'UNKNOWN NXT HDR',
bad_dest_option: 'BAD DEST OPT',
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<HashRow label="HASH" value={p.fp_hash} />
{p.matrix && (
<span className="matrix-text" style={{ fontFamily: 'monospace', fontSize: '0.85rem', letterSpacing: '2px' }}>
{p.matrix}
</span>
)}
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{Object.entries(ERROR_LABELS).map(([key, label]) => {
const e = errors[key];
const hit = e?.returned === true;
return (
<Tag key={key} color={hit ? 'var(--violet)' : undefined}>
<span style={{ opacity: hit ? 1 : 0.4 }}>
{label}{hit && e?.rtt_ms ? ` ${e.rtt_ms}ms` : ''}
</span>
</Tag>
);
})}
</div>
{errors.hop_limit_exceeded?.src_ip && (
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', marginTop: '2px' }}>
<span className="dim" style={{ fontSize: '0.7rem' }}>FIRST HOP</span>
<Tag color="var(--accent-color)">{errors.hop_limit_exceeded.src_ip}</Tag>
</div>
)}
{(p.target_ip || p.target_port) && (
<div style={{ display: 'flex', gap: '8px', marginTop: '2px', flexWrap: 'wrap' }}>
{p.target_ip && <Tag color="var(--accent-color)">{p.target_ip}</Tag>}
{p.target_port && <Tag>:{p.target_port}</Tag>}
</div>
)}
</div>
);
};
export const FingerprintGroup: React.FC<{ fpType: string; items: any[] }> = ({ fpType, items }) => {
const label = fpTypeLabel[fpType] || fpType.toUpperCase().replace(/_/g, ' ');
const icon = fpTypeIcon[fpType] || <Fingerprint size={14} />;
@@ -429,6 +521,8 @@ export const FingerprintGroup: React.FC<{ fpType: string; items: any[] }> = ({ f
case 'http3_settings':
return <FpHttpSettings key={i} p={p} />;
case 'ja4_quic': return <FpJa4Quic key={i} p={p} />;
case 'icmp_error': return <FpIcmpError key={i} p={p} />;
case 'icmp6_error': return <FpIcmp6Error key={i} p={p} />;
default: return <FpGeneric key={i} p={p} />;
}
})}