@@ -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).
6367const 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.
74141const 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"]);
284349const 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