Skip to content

Commit f611d25

Browse files
Fix ThreatFox false match, VT wording, reserved IP ranges
1 parent dc251aa commit f611d25

1 file changed

Lines changed: 101 additions & 22 deletions

File tree

src/App.jsx

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -55,27 +55,93 @@ const SESSION_ID = getSessionId();
5555
// onto the /fetch, /parse, and /enrich requests the app already makes for
5656
// functional reasons — SESSION_ID is attached to those, but there is no
5757
// dedicated client-initiated logging call. Invisible to browser DevTools.
58-
const APP_VERSION = "v130";
58+
const APP_VERSION = "v131";
5959

6060
// ============================================================
6161
// IOC Whitelist — exact-match auto-removal from parsed results
6262
// ============================================================
63+
// IANA IPv4 Special-Purpose Address Registry (RFC 6890) — every block that's
64+
// never globally routable: private-use, loopback, link-local, CGNAT,
65+
// documentation/test-net, benchmarking, 6to4 relay, multicast, and the
66+
// 240.0.0.0/4 reserved block (which also covers 255.255.255.255 broadcast).
6367
const isPrivateIP = (ip) => {
64-
const m = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
68+
const m = String(ip).match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
6569
if (!m) return false;
66-
const [, a, b] = m.map(Number);
67-
return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) ||
68-
(a === 192 && b === 168) || (a === 169 && b === 254) || (a === 255);
70+
const [a, b, c] = m.slice(1).map(Number);
71+
if (a === 0) return true; // 0.0.0.0/8 — "this network"
72+
if (a === 10) return true; // 10.0.0.0/8 — private-use
73+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 — CGNAT shared space
74+
if (a === 127) return true; // 127.0.0.0/8 — loopback
75+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 — link-local
76+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 — private-use
77+
if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24 — IETF protocol assignments
78+
if (a === 192 && b === 0 && c === 2) return true; // 192.0.2.0/24 — documentation (TEST-NET-1)
79+
if (a === 192 && b === 88 && c === 99) return true; // 192.88.99.0/24 — 6to4 relay anycast
80+
if (a === 192 && b === 168) return true; // 192.168.0.0/16 — private-use
81+
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 — benchmarking
82+
if (a === 198 && b === 51 && c === 100) return true; // 198.51.100.0/24 — documentation (TEST-NET-2)
83+
if (a === 203 && b === 0 && c === 113) return true; // 203.0.113.0/24 — documentation (TEST-NET-3)
84+
if (a >= 224) return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + broadcast
85+
return false;
86+
};
87+
// Expands a compressed IPv6 address ("::") into its 8 canonical 16-bit
88+
// groups (as numbers). Handles an embedded IPv4 tail (e.g. ::ffff:1.2.3.4)
89+
// and a zone index (%eth0). Returns null if the input isn't a plausible
90+
// IPv6 address, so callers can safely treat that as "not IPv6".
91+
const expandIPv6 = (ip) => {
92+
let addr = String(ip).trim().replace(/%.*$/, "").replace(/^\[|\]$/g, "");
93+
if (!addr.includes(":")) return null;
94+
const ipv4Tail = addr.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
95+
if (ipv4Tail) {
96+
const parts = ipv4Tail[1].split(".").map(Number);
97+
if (parts.some((p) => Number.isNaN(p) || p > 255)) return null;
98+
const hex1 = ((parts[0] << 8) | parts[1]).toString(16);
99+
const hex2 = ((parts[2] << 8) | parts[3]).toString(16);
100+
addr = addr.slice(0, addr.length - ipv4Tail[1].length) + hex1 + ":" + hex2;
101+
}
102+
const halves = addr.split("::");
103+
if (halves.length > 2) return null;
104+
const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
105+
const tail = halves.length === 2 && halves[1] ? halves[1].split(":").filter(Boolean) : [];
106+
let groups;
107+
if (halves.length === 2) {
108+
const missing = 8 - head.length - tail.length;
109+
if (missing < 0) return null;
110+
groups = [...head, ...Array(missing).fill("0"), ...tail];
111+
} else {
112+
groups = head;
113+
}
114+
if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/i.test(g))) return null;
115+
return groups.map((g) => parseInt(g, 16));
116+
};
117+
// IANA IPv6 Special-Purpose Address Registry (RFC 6890/8190) — unspecified,
118+
// loopback, the deprecated ::/96 "IPv4-compatible" block (never publicly
119+
// routed — this is what catches an address like "::1201"), IPv4-mapped,
120+
// NAT64, discard-only, documentation, 6to4, unique-local, link-local, and
121+
// multicast.
122+
const isReservedIPv6 = (ip) => {
123+
const g = expandIPv6(ip);
124+
if (!g) return false;
125+
const [g0, g1, g2, g3, g4, g5] = g;
126+
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) return true; // ::/96 (covers :: and ::1 too)
127+
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff) return true; // ::ffff:0:0/96 — IPv4-mapped
128+
if (g0 === 0x64 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) return true; // 64:ff9b::/96 — NAT64
129+
if (g0 === 0x100 && g1 === 0 && g2 === 0 && g3 === 0) return true; // 100::/64 — discard-only
130+
if (g0 === 0x2001 && g1 === 0xdb8) return true; // 2001:db8::/32 — documentation
131+
if (g0 === 0x2002) return true; // 2002::/16 — 6to4
132+
if (g0 >= 0xfc00 && g0 <= 0xfdff) return true; // fc00::/7 — unique-local
133+
if (g0 >= 0xfe80 && g0 <= 0xfebf) return true; // fe80::/10 — link-local
134+
if (g0 >= 0xff00 && g0 <= 0xffff) return true; // ff00::/8 — multicast
135+
return false;
69136
};
70-
// IPv4 private/reserved (via isPrivateIP) + IPv6 loopback/link-local/unique-local —
71-
// used to strip sandbox-artifact addresses (a detonation VM's own NIC, its
137+
// Used to strip sandbox-artifact addresses (a detonation VM's own NIC, its
72138
// gateway, etc.) out of upstream "contacted infrastructure" lists before they
73-
// ever reach a card or the graph. Those addresses aren't attacker infra.
139+
// ever reach a card or the graph, and to keep non-routable addresses out of
140+
// parsing entirely. Those addresses aren't attacker infra.
74141
const isPrivateOrReservedIP = (ip) => {
75142
const v = String(ip).toLowerCase();
76143
if (isPrivateIP(v)) return true;
77-
if (!v.includes(":")) return false;
78-
return v === "::1" || v === "::" || v.startsWith("fe80:") || v.startsWith("fc") || v.startsWith("fd");
144+
return isReservedIPv6(v);
79145
};
80146
// DOMAIN whitelist — github.com IS filtered here (bare github.com domain is noise)
81147
// Bare apex domains only (exact match) — NOT suffixes. workers.dev/pages.dev/
@@ -280,13 +346,12 @@ const WL_FILES = new Set([
280346
"eventvwr.exe","mmc.exe","regedit.exe","tasklist.exe","taskkill.exe",
281347
"\\","/"
282348
]);
283-
const WL_IPS6 = new Set(["::1","::","fe80::1","0:0:0:0:0:0:0:1","0:0:0:0:0:0:0:0"]);
284349
const applyWhitelist = (data) => {
285350
const out = {};
286351
Object.entries(data).forEach(([cat, arr]) => {
287352
let filtered = arr;
288353
if (cat === "IPV4") filtered = arr.filter(v => !isPrivateIP(v));
289-
else if (cat === "IPV6") filtered = arr.filter(v => !WL_IPS6.has(v.toLowerCase()));
354+
else if (cat === "IPV6") filtered = arr.filter(v => !isReservedIPv6(v));
290355
else if (cat === "DOMAIN") filtered = arr.filter(v => {
291356
const vl = v.toLowerCase();
292357
if (WL_DOMAINS.has(vl)) return false;
@@ -2589,16 +2654,30 @@ export default function App() {
25892654
if (["IPV4","IPV6","DOMAIN","URL","MD5","SHA1","SHA256","SHA512"].includes(cat)) {
25902655
try {
25912656
const j = await callEnrich("threatfox");
2657+
// ThreatFox's search_ioc endpoint does a loose match rather than an
2658+
// exact one — querying a bare IP has been observed matching an
2659+
// unrelated "ip:port" record purely because the port digits appear
2660+
// in the ioc string (e.g. querying "::1201" matched
2661+
// "177.22.88.133:1201"). Verify the returned ioc actually IS (or is
2662+
// "ioc:port"/"ioc/path" prefixed by) the value we searched for
2663+
// before trusting it — reject anything else as a false match.
25922664
if (j.query_status === "ok" && Array.isArray(j.data) && j.data.length > 0) {
25932665
const d = j.data[0];
2594-
results.threatfox = {
2595-
malware: d.malware_printable || d.malware || "—",
2596-
threat: d.threat_type_desc || d.threat_type || "—",
2597-
confidence: d.confidence_level,
2598-
first: d.first_seen ? d.first_seen.split(" ")[0] : null,
2599-
last: d.last_seen ? d.last_seen.split(" ")[0] : null,
2600-
tags: Array.isArray(d.tags) ? d.tags.filter((t) => t && !GENERIC_TAGS.has(t.toLowerCase())).slice(0, 4).join(", ") : null,
2601-
};
2666+
const iocLower = String(d.ioc || "").toLowerCase();
2667+
const valueLower = String(value).toLowerCase();
2668+
const isRealMatch = iocLower === valueLower || iocLower.startsWith(`${valueLower}:`) || iocLower.startsWith(`${valueLower}/`);
2669+
if (!isRealMatch) {
2670+
console.warn(`ThreatFox returned unrelated ioc "${d.ioc}" for query "${value}" — discarding as a false match`);
2671+
} else {
2672+
results.threatfox = {
2673+
malware: d.malware_printable || d.malware || "—",
2674+
threat: d.threat_type_desc || d.threat_type || "—",
2675+
confidence: d.confidence_level,
2676+
first: d.first_seen ? d.first_seen.split(" ")[0] : null,
2677+
last: d.last_seen ? d.last_seen.split(" ")[0] : null,
2678+
tags: Array.isArray(d.tags) ? d.tags.filter((t) => t && !GENERIC_TAGS.has(t.toLowerCase())).slice(0, 4).join(", ") : null,
2679+
};
2680+
}
26022681
}
26032682
} catch (e) { console.warn("Enrich ThreatFox failed:", e.message); }
26042683
setPartial();
@@ -7540,7 +7619,7 @@ export default function App() {
75407619
backgroundColor: enr.data._verdict === "Malicious" ? "rgba(255,77,109,0.15)" : enr.data._verdict === "Suspicious" ? "rgba(251,191,36,0.15)" : enr.data._verdict === "Whitelisted" ? "rgba(0,255,156,0.15)" : "rgba(138,160,173,0.15)",
75417620
border: `1px solid ${enr.data._verdict === "Malicious" ? "rgba(255,77,109,0.4)" : enr.data._verdict === "Suspicious" ? "rgba(251,191,36,0.4)" : enr.data._verdict === "Whitelisted" ? "rgba(0,255,156,0.4)" : "rgba(138,160,173,0.3)"}`,
75427621
}}>
7543-
{enr.data._verdict === "Malicious" ? "🔴" : enr.data._verdict === "Suspicious" ? "🟡" : enr.data._verdict === "Whitelisted" ? "🟢" : "⚪"} {enr.data._verdict === "Unknown" ? "Unknown - Check VirusTotal" : enr.data._verdict}
7622+
{enr.data._verdict === "Malicious" ? "🔴" : enr.data._verdict === "Suspicious" ? "🟡" : enr.data._verdict === "Whitelisted" ? "🟢" : "⚪"} {enr.data._verdict === "Unknown" ? (enr.data.virustotal ? "Unknown" : "Unknown - Check VirusTotal") : enr.data._verdict}
75447623
</span>
75457624
)}
75467625
{enr?.data?.domainReg?.state === "active" && enr?.data?.domainReg?.status && /client.?hold|server.?hold/i.test(enr.data.domainReg.status) && (
@@ -8102,7 +8181,7 @@ export default function App() {
81028181
<>
81038182
{d._verdict === "Unknown" && d.domainReg?.state !== "deleted" && (
81048183
<span className="rounded-full px-2 py-0.5 font-bold" style={{ color: "#5d7382", backgroundColor: "rgba(148,163,184,0.08)", border: "1px solid rgba(148,163,184,0.2)" }}>
8105-
⚪ Unknown - Check VirusTotal
8184+
{d.virustotal ? "⚪ Unknown" : "⚪ Unknown - Check VirusTotal"}
81068185
</span>
81078186
)}
81088187
{hasOtx && d._verdict !== "Unknown" && (

0 commit comments

Comments
 (0)