feat(ui): wire icmp_error / icmp6_error fingerprint probes into AttackerDetail
This commit is contained in:
@@ -233,7 +233,7 @@ const AttackerDetail: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Active probes first, then passive, then unknown
|
// 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 passiveTypes = ['ja3', 'ja4l', 'tls_resumption', 'tls_certificate_passive', 'http_useragent', 'http_quirks', 'spoofed_source', 'vnc_client_version'];
|
||||||
const knownTypes = [...activeTypes, ...passiveTypes];
|
const knownTypes = [...activeTypes, ...passiveTypes];
|
||||||
const unknownTypes = Object.keys(groups).filter((t) => !knownTypes.includes(t));
|
const unknownTypes = Object.keys(groups).filter((t) => !knownTypes.includes(t));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import {
|
import {
|
||||||
FingerprintGroup, FpUserAgent, FpHttpQuirks, FpResumption,
|
FingerprintGroup, FpUserAgent, FpHttpQuirks, FpResumption,
|
||||||
FpCertificate, FpSpoofedSource, FpTcpStack,
|
FpCertificate, FpSpoofedSource, FpTcpStack, FpIcmpError, FpIcmp6Error,
|
||||||
} from './renderers';
|
} from './renderers';
|
||||||
|
|
||||||
describe('FpUserAgent', () => {
|
describe('FpUserAgent', () => {
|
||||||
@@ -146,4 +146,108 @@ describe('FingerprintGroup', () => {
|
|||||||
expect(screen.getByText('WEIRD UNKNOWN')).toBeInTheDocument();
|
expect(screen.getByText('WEIRD UNKNOWN')).toBeInTheDocument();
|
||||||
expect(screen.getByText('mystery-value')).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const fpTypeLabel: Record<string, string> = {
|
|||||||
jarm: 'JARM',
|
jarm: 'JARM',
|
||||||
hassh_server: 'HASSH SERVER',
|
hassh_server: 'HASSH SERVER',
|
||||||
tcpfp: 'TCP/IP STACK',
|
tcpfp: 'TCP/IP STACK',
|
||||||
|
icmp_error: 'ICMP ERROR LEAK',
|
||||||
|
icmp6_error: 'ICMPv6 ERROR LEAK',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fpTypeIcon: Record<string, React.ReactNode> = {
|
export const fpTypeIcon: Record<string, React.ReactNode> = {
|
||||||
@@ -41,6 +43,8 @@ export const fpTypeIcon: Record<string, React.ReactNode> = {
|
|||||||
jarm: <Crosshair size={14} />,
|
jarm: <Crosshair size={14} />,
|
||||||
hassh_server: <Lock size={14} />,
|
hassh_server: <Lock size={14} />,
|
||||||
tcpfp: <Wifi size={14} />,
|
tcpfp: <Wifi size={14} />,
|
||||||
|
icmp_error: <Wifi size={14} />,
|
||||||
|
icmp6_error: <Crosshair size={14} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UA_CATEGORY_COLOR: Record<string, string> = {
|
export const UA_CATEGORY_COLOR: Record<string, string> = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export { FingerprintGroup } from './renderers';
|
|||||||
export {
|
export {
|
||||||
FpTlsHashes, FpLatency, FpResumption, FpCertificate, FpJarm,
|
FpTlsHashes, FpLatency, FpResumption, FpCertificate, FpJarm,
|
||||||
FpHassh, FpTcpStack, FpGeneric, FpUserAgent, FpSpoofedSource,
|
FpHassh, FpTcpStack, FpGeneric, FpUserAgent, FpSpoofedSource,
|
||||||
FpHttpQuirks,
|
FpHttpQuirks, FpIcmpError, FpIcmp6Error,
|
||||||
} from './renderers';
|
} from './renderers';
|
||||||
export {
|
export {
|
||||||
fpTypeLabel, fpTypeIcon, getPayload, seqClassColor,
|
fpTypeLabel, fpTypeIcon, getPayload, seqClassColor,
|
||||||
|
|||||||
@@ -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 }) => {
|
export const FingerprintGroup: React.FC<{ fpType: string; items: any[] }> = ({ fpType, items }) => {
|
||||||
const label = fpTypeLabel[fpType] || fpType.toUpperCase().replace(/_/g, ' ');
|
const label = fpTypeLabel[fpType] || fpType.toUpperCase().replace(/_/g, ' ');
|
||||||
const icon = fpTypeIcon[fpType] || <Fingerprint size={14} />;
|
const icon = fpTypeIcon[fpType] || <Fingerprint size={14} />;
|
||||||
@@ -429,6 +521,8 @@ export const FingerprintGroup: React.FC<{ fpType: string; items: any[] }> = ({ f
|
|||||||
case 'http3_settings':
|
case 'http3_settings':
|
||||||
return <FpHttpSettings key={i} p={p} />;
|
return <FpHttpSettings key={i} p={p} />;
|
||||||
case 'ja4_quic': return <FpJa4Quic 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} />;
|
default: return <FpGeneric key={i} p={p} />;
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user