fix(military): narrow ICAO hex ranges to stop civilian false positives (#627)

* fix(military): narrow hex ranges and callsign regex to stop civilian false positives (#462)

MILITARY_HEX_RANGES used entire country ICAO allocations instead of
military-specific sub-ranges (sourced from tar1090-db/ranges.json).
This flagged ALL commercial aircraft from Italy, Spain, Japan, India,
South Korea, etc. as military activity.

Key changes:
- Remove A00000-A3FFFF (US civilian N-numbers) — military starts at ADF7C8
- Italy 300000-33FFFF → 33FF00-33FFFF (top 256 codes only)
- Spain 340000-37FFFF → 350000-37FFFF (upper 3/4 confirmed military)
- Japan 840000-87FFFF removed (no confirmed JASDF sub-range)
- France narrowed to 3AA000-3AFFFF + 3B7000-3BFFFF
- Germany narrowed to 3EA000-3EBFFF + 3F4000-3FBFFF
- India 800000-83FFFF → 800200-8002FF (256 codes)
- Canada C00000-C0FFFF → C20000-C3FFFF (upper half)
- Remove unconfirmed: South Korea, Sweden, Singapore, Pakistan
- Add confirmed: Austria, Belgium, Switzerland, Brazil
- Drop overly broad /^[A-Z]{4,}\d{1,3}$/ callsign regex from server

* fix(military): constrain short prefixes + add classification tests

Move ambiguous 2-letter prefixes (AE, RF, TF, PAT, SAM, OPS, CTF,
IRG, TAF) to SHORT_MILITARY_PREFIXES — these now only match when
followed by a digit (e.g. AE1234=military, AEE123=Aegean Airlines).

Add 97-case test suite covering:
- Military callsign detection (19 known patterns)
- Short prefix digit gating (6 cases)
- Civilian airline non-detection (26 airlines)
- Short prefix letter rejection (6 cases)
- Military hex range boundaries (7 confirmed ranges)
- Civilian hex non-detection (19 codes)
- Boundary precision (ADF7C8 start, 33FF00 start, etc.)
- No-full-allocation guard (10 countries)

* fix: use charAt() instead of bracket indexing for strict TS
This commit is contained in:
Elie Habib
2026-03-01 10:07:38 +04:00
committed by GitHub
parent 0e844b7348
commit 899c20f81f
3 changed files with 303 additions and 51 deletions

View File

@@ -28,17 +28,20 @@ export const MILITARY_PREFIXES = [
'COBRA', 'PYTHON', 'RAPTOR', 'EAGLE', 'HAWK', 'TALON', 'COBRA', 'PYTHON', 'RAPTOR', 'EAGLE', 'HAWK', 'TALON',
'BOXER', 'OMNI', 'TOPCAT', 'SKULL', 'REAPER', 'HUNTER', 'BOXER', 'OMNI', 'TOPCAT', 'SKULL', 'REAPER', 'HUNTER',
'ARMY', 'NAVY', 'USAF', 'USMC', 'USCG', 'ARMY', 'NAVY', 'USAF', 'USMC', 'USCG',
'AE', 'CNV', 'PAT', 'SAM', 'EXEC', 'CNV', 'EXEC',
'OPS', 'CTF', 'TF',
'NATO', 'GAF', 'RRF', 'RAF', 'FAF', 'IAF', 'RNLAF', 'BAF', 'DAF', 'HAF', 'PAF', 'NATO', 'GAF', 'RRF', 'RAF', 'FAF', 'IAF', 'RNLAF', 'BAF', 'DAF', 'HAF', 'PAF',
'SWORD', 'LANCE', 'ARROW', 'SPARTAN', 'SWORD', 'LANCE', 'ARROW', 'SPARTAN',
'RSAF', 'EMIRI', 'UAEAF', 'KAF', 'QAF', 'BAHAF', 'OMAAF', 'RSAF', 'EMIRI', 'UAEAF', 'KAF', 'QAF', 'BAHAF', 'OMAAF',
'IRIAF', 'IRG', 'IRGC', 'IRIAF', 'IRGC',
'TAF', 'TUAF', 'TUAF',
'RSD', 'RF', 'RFF', 'VKS', 'RSD', 'RFF', 'VKS',
'CHN', 'PLAAF', 'PLA', 'CHN', 'PLAAF', 'PLA',
]; ];
// Short prefixes that only match when followed by digits (not letters)
// e.g. AE1234 = military, AEE123 = Aegean Airlines
const SHORT_MILITARY_PREFIXES = ['AE', 'RF', 'TF', 'PAT', 'SAM', 'OPS', 'CTF', 'IRG', 'TAF'];
export const AIRLINE_CODES = new Set([ export const AIRLINE_CODES = new Set([
'SVA', 'QTR', 'THY', 'UAE', 'ETD', 'GFA', 'MEA', 'RJA', 'KAC', 'ELY', 'SVA', 'QTR', 'THY', 'UAE', 'ETD', 'GFA', 'MEA', 'RJA', 'KAC', 'ELY',
'IAW', 'IRA', 'MSR', 'SYR', 'PGT', 'AXB', 'FDB', 'KNE', 'FAD', 'ADY', 'OMA', 'IAW', 'IRA', 'MSR', 'SYR', 'PGT', 'AXB', 'FDB', 'KNE', 'FAD', 'ADY', 'OMA',
@@ -58,7 +61,9 @@ export function isMilitaryCallsign(callsign: string | null | undefined): boolean
for (const prefix of MILITARY_PREFIXES) { for (const prefix of MILITARY_PREFIXES) {
if (cs.startsWith(prefix)) return true; if (cs.startsWith(prefix)) return true;
} }
if (/^[A-Z]{4,}\d{1,3}$/.test(cs)) return true; for (const prefix of SHORT_MILITARY_PREFIXES) {
if (cs.startsWith(prefix) && cs.length > prefix.length && /\d/.test(cs.charAt(prefix.length))) return true;
}
if (/^[A-Z]{3}\d{1,2}$/.test(cs)) { if (/^[A-Z]{3}\d{1,2}$/.test(cs)) {
const prefix = cs.slice(0, 3); const prefix = cs.slice(0, 3);
if (!AIRLINE_CODES.has(prefix)) return true; if (!AIRLINE_CODES.has(prefix)) return true;

View File

@@ -281,41 +281,42 @@ export const MILITARY_AIRCRAFT_TYPES: Record<string, { type: MilitaryAircraftTyp
* Reference: https://www.ads-b.nl/icao.php * Reference: https://www.ads-b.nl/icao.php
*/ */
export const MILITARY_HEX_RANGES: { start: string; end: string; operator: MilitaryOperator; country: string }[] = [ export const MILITARY_HEX_RANGES: { start: string; end: string; operator: MilitaryOperator; country: string }[] = [
// United States Military (largest block) // United States DoD — civil N-numbers end at ADF7C7; everything above is military
{ start: 'ADF7C7', end: 'ADF7CF', operator: 'usaf', country: 'USA' }, // Known USAF tankers { start: 'ADF7C8', end: 'AFFFFF', operator: 'usaf', country: 'USA' },
{ start: 'AE0000', end: 'AFFFFF', operator: 'usaf', country: 'USA' }, // Main USAF block
{ start: 'A00000', end: 'A3FFFF', operator: 'usaf', country: 'USA' }, // Additional US military
// UK Military // UK Military (small block at start + main RAF block)
{ start: '400000', end: '40003F', operator: 'raf', country: 'UK' },
{ start: '43C000', end: '43CFFF', operator: 'raf', country: 'UK' }, { start: '43C000', end: '43CFFF', operator: 'raf', country: 'UK' },
// France Military // France Military (two sub-blocks within 380000-3BFFFF)
{ start: '3A0000', end: '3AFFFF', operator: 'faf', country: 'France' }, { start: '3AA000', end: '3AFFFF', operator: 'faf', country: 'France' },
{ start: '3B0000', end: '3BFFFF', operator: 'faf', country: 'France' }, { start: '3B7000', end: '3BFFFF', operator: 'faf', country: 'France' },
// Germany Military // Germany Military (two sub-blocks within 3C0000-3FFFFF)
{ start: '3F0000', end: '3FFFFF', operator: 'gaf', country: 'Germany' }, { start: '3EA000', end: '3EBFFF', operator: 'gaf', country: 'Germany' },
{ start: '3F4000', end: '3FBFFF', operator: 'gaf', country: 'Germany' },
// Israel Military (critical for Middle East) // Israel Military (confirmed IAF sub-range within 738000-73FFFF)
{ start: '738000', end: '73FFFF', operator: 'iaf', country: 'Israel' }, { start: '738A00', end: '738BFF', operator: 'iaf', country: 'Israel' },
// NATO AWACS (Luxembourg registration but NATO operated) // NATO AWACS (Luxembourg registration but NATO operated)
{ start: '4D0000', end: '4D03FF', operator: 'nato', country: 'NATO' }, { start: '4D0000', end: '4D03FF', operator: 'nato', country: 'NATO' },
// Italy Military // Italy Military (top of 300000-33FFFF block)
{ start: '300000', end: '33FFFF', operator: 'other', country: 'Italy' }, { start: '33FF00', end: '33FFFF', operator: 'other', country: 'Italy' },
// Spain Military // Spain Military (upper 3/4 of 340000-37FFFF; civilian in 340000-34FFFF)
{ start: '340000', end: '37FFFF', operator: 'other', country: 'Spain' }, { start: '350000', end: '37FFFF', operator: 'other', country: 'Spain' },
// Netherlands Military // Netherlands Military
{ start: '480000', end: '480FFF', operator: 'other', country: 'Netherlands' }, { start: '480000', end: '480FFF', operator: 'other', country: 'Netherlands' },
// Turkey Military (important for Middle East) // Turkey Military (confirmed sub-range within 4B8000-4BFFFF)
{ start: '4BA000', end: '4BCFFF', operator: 'other', country: 'Turkey' }, { start: '4B8200', end: '4B82FF', operator: 'other', country: 'Turkey' },
// Saudi Arabia Military // Saudi Arabia Military (two small confirmed sub-blocks)
{ start: '710000', end: '717FFF', operator: 'other', country: 'Saudi Arabia' }, { start: '710258', end: '71028F', operator: 'other', country: 'Saudi Arabia' },
{ start: '710380', end: '71039F', operator: 'other', country: 'Saudi Arabia' },
// UAE Military // UAE Military
{ start: '896000', end: '896FFF', operator: 'other', country: 'UAE' }, { start: '896000', end: '896FFF', operator: 'other', country: 'UAE' },
@@ -326,41 +327,38 @@ export const MILITARY_HEX_RANGES: { start: string; end: string; operator: Milita
// Kuwait Military // Kuwait Military
{ start: '706000', end: '706FFF', operator: 'other', country: 'Kuwait' }, { start: '706000', end: '706FFF', operator: 'other', country: 'Kuwait' },
// Japan Self-Defense Forces // Australia Military (confirmed RAAF sub-range)
{ start: '840000', end: '87FFFF', operator: 'other', country: 'Japan' }, { start: '7CF800', end: '7CFAFF', operator: 'other', country: 'Australia' },
// South Korea Military // Canada Military (upper half of C00000-C3FFFF)
{ start: '718000', end: '71FFFF', operator: 'other', country: 'South Korea' }, { start: 'C20000', end: 'C3FFFF', operator: 'other', country: 'Canada' },
// Australia Military // India Military (confirmed IAF sub-range within 800000-83FFFF)
{ start: '7CF800', end: '7CFFFF', operator: 'other', country: 'Australia' }, { start: '800200', end: '8002FF', operator: 'other', country: 'India' },
// Canada Military // Egypt Military (confirmed sub-range)
{ start: 'C00000', end: 'C0FFFF', operator: 'other', country: 'Canada' }, { start: '010070', end: '01008F', operator: 'other', country: 'Egypt' },
// India Military // Poland Military (confirmed sub-range within 488000-48FFFF)
{ start: '800000', end: '83FFFF', operator: 'other', country: 'India' }, { start: '48D800', end: '48D87F', operator: 'other', country: 'Poland' },
// Pakistan Military // Greece Military (confirmed sub-range at start of 468000-46FFFF)
{ start: '760000', end: '767FFF', operator: 'other', country: 'Pakistan' }, { start: '468000', end: '4683FF', operator: 'other', country: 'Greece' },
// Egypt Military // Norway Military (confirmed sub-range within 478000-47FFFF)
{ start: '500000', end: '5003FF', operator: 'other', country: 'Egypt' }, { start: '478100', end: '4781FF', operator: 'other', country: 'Norway' },
// Poland Military // Austria Military
{ start: '488000', end: '48FFFF', operator: 'other', country: 'Poland' }, { start: '444000', end: '446FFF', operator: 'other', country: 'Austria' },
// Greece Military // Belgium Military
{ start: '468000', end: '46FFFF', operator: 'other', country: 'Greece' }, { start: '44F000', end: '44FFFF', operator: 'other', country: 'Belgium' },
// Sweden Military // Switzerland Military
{ start: '4A8000', end: '4AFFFF', operator: 'other', country: 'Sweden' }, { start: '4B7000', end: '4B7FFF', operator: 'other', country: 'Switzerland' },
// Norway Military // Brazil Military
{ start: '478000', end: '47FFFF', operator: 'other', country: 'Norway' }, { start: 'E40000', end: 'E41FFF', operator: 'other', country: 'Brazil' },
// Singapore Military
{ start: '768000', end: '76FFFF', operator: 'other', country: 'Singapore' },
]; ];
/** /**

View File

@@ -0,0 +1,249 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
// ---------------------------------------------------------------------------
// Extract server-side classification data from _shared.ts source
// ---------------------------------------------------------------------------
const sharedSrc = readFileSync(
join(root, 'server/worldmonitor/military/v1/_shared.ts'),
'utf-8',
);
function extractArray(src, name) {
// Match both `const X = [...]` and `const X = new Set([...])`
const re = new RegExp(`(?:export )?const ${name}\\s*=\\s*(?:new Set\\()?\\[([\\s\\S]*?)\\]`);
const m = src.match(re);
if (!m) return [];
return [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
}
const MILITARY_PREFIXES = extractArray(sharedSrc, 'MILITARY_PREFIXES');
const SHORT_MILITARY_PREFIXES = extractArray(sharedSrc, 'SHORT_MILITARY_PREFIXES');
const AIRLINE_CODES = new Set(extractArray(sharedSrc, 'AIRLINE_CODES'));
function isMilitaryCallsign(callsign) {
if (!callsign) return false;
const cs = callsign.toUpperCase().trim();
for (const prefix of MILITARY_PREFIXES) {
if (cs.startsWith(prefix)) return true;
}
for (const prefix of SHORT_MILITARY_PREFIXES) {
if (cs.startsWith(prefix) && cs.length > prefix.length && /\d/.test(cs[prefix.length]))
return true;
}
if (/^[A-Z]{3}\d{1,2}$/.test(cs)) {
const prefix = cs.slice(0, 3);
if (!AIRLINE_CODES.has(prefix)) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Extract client-side hex ranges from military.ts
// ---------------------------------------------------------------------------
const clientSrc = readFileSync(join(root, 'src/config/military.ts'), 'utf-8');
function extractHexRanges(src) {
const ranges = [];
const re = /start:\s*'([0-9A-Fa-f]+)',\s*end:\s*'([0-9A-Fa-f]+)'/g;
let m;
while ((m = re.exec(src)) !== null) {
ranges.push({ start: m[1].toUpperCase(), end: m[2].toUpperCase() });
}
return ranges;
}
const HEX_RANGES = extractHexRanges(clientSrc);
function isKnownMilitaryHex(hexCode) {
const hex = hexCode.toUpperCase();
for (const range of HEX_RANGES) {
if (hex >= range.start && hex <= range.end) return true;
}
return false;
}
// ===========================================================================
// Tests
// ===========================================================================
describe('Military callsign classifier (server-side)', () => {
describe('correctly identifies military callsigns', () => {
const military = [
'RCH1234', 'REACH01', 'MOOSE55', 'NAVY1', 'ARMY22',
'COBRA11', 'DUKE01', 'SHELL22', 'RAPTOR1', 'REAPER01',
'NATO01', 'GAF123', 'RAF01', 'FAF55', 'IAF01',
'RSAF01', 'IRGC1', 'VKS01', 'PLAAF1',
];
for (const cs of military) {
it(`marks ${cs} as military`, () => {
assert.ok(isMilitaryCallsign(cs), `${cs} should be military`);
});
}
});
describe('correctly identifies short-prefix military callsigns', () => {
const shortMilitary = [
'AE1234', 'RF01', 'TF122', 'PAT01', 'SAM1', 'OPS22',
];
for (const cs of shortMilitary) {
it(`marks ${cs} as military (short prefix + digit)`, () => {
assert.ok(isMilitaryCallsign(cs), `${cs} should be military`);
});
}
});
describe('does NOT flag commercial airline callsigns', () => {
const civilian = [
'AEE123', // Aegean Airlines
'AEA456', // Air Europa
'THY1234', // Turkish Airlines
'SVA123', // Saudia
'QTR456', // Qatar Airways
'UAE789', // Emirates
'BAW123', // British Airways
'AFR456', // Air France
'DLH789', // Lufthansa
'KLM12', // KLM
'AAL1234', // American Airlines
'DAL5678', // Delta
'UAL901', // United
'SWA1234', // Southwest
'JAL123', // Japan Airlines
'ANA456', // All Nippon Airways
'KAL789', // Korean Air
'CCA123', // Air China
'AIC456', // Air India
'SIA789', // Singapore Airlines
'ELY123', // El Al
'RYR456', // Ryanair
'EZY789', // easyJet
'WZZ123', // Wizz Air
'FDX456', // FedEx
'UPS789', // UPS
];
for (const cs of civilian) {
it(`does NOT mark ${cs} as military`, () => {
assert.ok(!isMilitaryCallsign(cs), `${cs} should NOT be military`);
});
}
});
describe('short prefixes do NOT match when followed by letters', () => {
const civilianShort = [
'AEE123', // Aegean — starts with AE but next char is E (letter)
'AERO1', // Generic — starts with AE but not short-prefix match
'RFAIR', // hypothetical — RF followed by letter
'TFLIGHT', // hypothetical — TF followed by letter
'PATROL1', // starts with PAT but next char is R (letter)
'SAMPLE', // starts with SAM but next char is P (letter)
];
for (const cs of civilianShort) {
it(`does NOT mark ${cs} as military via short prefix`, () => {
assert.ok(!isMilitaryCallsign(cs), `${cs} should NOT be military`);
});
}
});
});
describe('Military hex range classifier (client-side)', () => {
describe('correctly identifies military hex codes', () => {
const military = [
'AE0000', // US DoD start
'AF0000', // US DoD mid
'AFFFFF', // US DoD end
'43C000', // RAF start
'43CFFF', // RAF end
'3AA000', // French military start
'3F4000', // German military start
];
for (const hex of military) {
it(`marks ${hex} as military`, () => {
assert.ok(isKnownMilitaryHex(hex), `${hex} should be military`);
});
}
});
describe('does NOT flag civilian ICAO hex codes', () => {
const civilian = [
'A00001', // US civilian N-number (N1)
'A0B0C0', // US civilian mid-range
'A3FFFF', // US civilian — was incorrectly flagged before fix
'ADF7C7', // Last US civilian N-number (N99999)
'300000', // Italian civilian (Alitalia range start)
'330000', // Italian civilian
'33FE00', // Italian civilian (just below military)
'340000', // Spanish civilian
'34FFFF', // Spanish civilian (just below military at 350000)
'840000', // Japanese civilian (JAL/ANA) — entire block removed
'870000', // Japanese civilian
'800000', // Indian civilian (Air India)
'800100', // Indian civilian
'718000', // South Korean civilian — no confirmed military range
'3C0000', // German civilian (Lufthansa range)
'380000', // French civilian (Air France range)
'C00000', // Canadian civilian (Air Canada)
'C10000', // Canadian civilian
'7C0000', // Australian civilian (Qantas)
];
for (const hex of civilian) {
it(`does NOT mark ${hex} as military`, () => {
assert.ok(!isKnownMilitaryHex(hex), `${hex} should NOT be military`);
});
}
});
describe('validates range boundaries are tight', () => {
it('US military starts at ADF7C8, not A00000', () => {
assert.ok(!isKnownMilitaryHex('ADF7C7'), 'ADF7C7 (last N-number) should be civilian');
assert.ok(isKnownMilitaryHex('ADF7C8'), 'ADF7C8 should be military');
});
it('Italy military is only top 256 codes (33FF00-33FFFF)', () => {
assert.ok(!isKnownMilitaryHex('33FEFF'), '33FEFF should be civilian');
assert.ok(isKnownMilitaryHex('33FF00'), '33FF00 should be military');
});
it('Spain military starts at 350000 (civilian below)', () => {
assert.ok(!isKnownMilitaryHex('34FFFF'), '34FFFF should be civilian');
assert.ok(isKnownMilitaryHex('350000'), '350000 should be military');
});
it('Canada military starts at C20000 (civilian below)', () => {
assert.ok(!isKnownMilitaryHex('C1FFFF'), 'C1FFFF should be civilian');
assert.ok(isKnownMilitaryHex('C20000'), 'C20000 should be military');
});
});
describe('no range spans an entire country ICAO allocation', () => {
const countryAllocations = [
{ country: 'USA', start: 'A00000', end: 'AFFFFF' },
{ country: 'Italy', start: '300000', end: '33FFFF' },
{ country: 'Spain', start: '340000', end: '37FFFF' },
{ country: 'Japan', start: '840000', end: '87FFFF' },
{ country: 'India', start: '800000', end: '83FFFF' },
{ country: 'France', start: '380000', end: '3BFFFF' },
{ country: 'Germany', start: '3C0000', end: '3FFFFF' },
{ country: 'UK', start: '400000', end: '43FFFF' },
{ country: 'Canada', start: 'C00000', end: 'C3FFFF' },
{ country: 'Australia', start: '7C0000', end: '7FFFFF' },
];
for (const alloc of countryAllocations) {
it(`no single range covers all of ${alloc.country} (${alloc.start}-${alloc.end})`, () => {
const fullRange = HEX_RANGES.find(
(r) => r.start <= alloc.start && r.end >= alloc.end,
);
assert.ok(
!fullRange,
`Range ${fullRange?.start}-${fullRange?.end} spans entire ${alloc.country} allocation`,
);
});
}
});
});