Skip to content

v3.5.0 (2026-08-28)

Latest

Choose a tag to compare

@github-actions github-actions released this 28 Aug 11:54

Added

  • .dnsignore — skip dig confirmation on known-dead domains. A user-maintained file in the project root (one domain per line; # comments and blanks ignored), loaded at startup. A dig-gated candidate that equals, or is a subdomain of, any listed entry (so xptidujgjktk.com also covers www.xptidujgjktk.com) is skipped before any lookup — no dig, no SERVFAIL/failure count, not captured. Purpose: kill the repeat noise from cloak/ad domains that were captured while live, added to your list, and have since been taken down (they SERVFAIL every run because the pages still reference them). Distinct from ignoreDomains, which runs the dig and only drops the capture — this skips the dig itself. Gitignored (per-clone user data); absent file is a no-op.
  • --dnsignore-auto (dnsignore_auto in .nwssconfig), default off — after the run, auto-append newly-detected dead domains to .dnsignore so they're skipped next run. Only SERVFAIL/REFUSED (a resolver reached the authoritative NS and got nothing valid — genuinely dead); a timeout is never added (flaky link, possibly live). Deduped against existing entries (equals-or-subdomain aware, so an already-covered domain isn't re-added) — only new domains are appended, stamped with the date. Opt-in because it mutates a user file; the manual workflow (copy from the Failed dig: line) still works without it.
  • --dig-retry-failed [n] (dig_retry_failed in .nwssconfig), default off — when a dig lookup exhausts its normal UDP failover + TCP fallback without an answer, make n extra TCP attempts (default 2, capped 5), each after a ~3s backoff, before giving up. Targets bursty/flaky resolvers where the first pass fails but a fresh attempt a few seconds later succeeds (the digFailures case). The recovered result flows through the normal match path, so the domain is captured this run — no re-run needed and no dependency on the (possibly rotated-away) domain reappearing. Backoff before each attempt is configurable via --dig-retry-backoff <ms> (dig_retry_backoff, default 3000, capped 60000) — raise it to outlast longer resolver bursts (a short pause only clears a brief drop; a longer one waits out a resolver that stays flaky for tens of seconds). The drain/per-URL budgets scale with the configured backoff so a late-completing retry is still captured. Bounded (retries capped at 5) and only reached on total failure, so extra latency applies to already-failing lookups only; healthy lookups are unaffected. To ensure a recovered late-firing lookup is actually captured (not cut off), the end-of-URL nettools drain ceiling and the per-URL timeout budget both grow by the retry latency only when the flag is set — the drain still resolves the instant all lookups finish, so the higher ceiling costs time only while a dig is genuinely still retrying. Off by default.
  • --dig-max-concurrent <n> (dig_max_concurrent in .nwssconfig), default 6 — caps how many dig subprocesses run at once. A high --max-concurrent (e.g. 15) otherwise lets the scanner fire that many simultaneous lookups at the same handful of public resolvers; the burst gets rate-limited / dropped (and is rough on WSL2's UDP-through-NAT path), which is a common cause of dig timeouts on Cloudflare-fronted ad domains. A counting semaphore (mirroring the DNS pre-check's) paces the burst — excess lookups queue and drain as slots free. Only genuine cache-miss lookups contend for a slot: in-memory/disk-cache hits and single-flight-deduped callers bypass it entirely, and the cap is bounded by --max-concurrent, not the total URL count, so it never backs up a large batch. Set 0 (or negative) to disable the cap.

Changed

  • Fingerprint spoof bumped Chrome 148 → 151, Firefox 151 → 154, Safari 19.5 → 26.6. Chrome targets 151, not the newer 152 puppeteer bundles or the 153 the version-history API lists first — the spoof exists to blend with the real population, and per Google's version-history API (win/stable, endtime=none) the serving split is 151 ≈74.5% (build 7922.174 alone 49%), 152 ≈25%, 153 ≈0.5%, so claiming the newest release would stand out rather than blend in. CHROME_BUILD7922.174. The deterministic UA-CH GREASE is recomputed for the new major — brand Not=A?Brand, version 99, and the brand-list order becomes <grease>, Google Chrome, Chromium (151 % 6 = 1); the order is hardcoded in two places that must agree or a detector cross-checking JS against HTTP sees the mismatch, so both fingerprint.js's brands array and nwss.js's Sec-CH-UA / -Full-Version-List headers were updated. The derivation was verified by reproducing the documented 148 and 150 values before applying it to 151. Firefox per Mozilla's product-details API (LATEST_FIREFOX_VERSION 154.0.1); Safari per Apple's security-content page for 26.6.1 (2026-08-18) — Apple moved Safari to year-aligned versioning with macOS 26 Tahoe, so the 19.x → 26.x jump is real, not a typo. Also fixes a hardcoded fallback UA in nwss.js that had silently drifted a major behind the collection, so curl advertised a different Chrome than the browser did. Verified with scripts/test-stealth.js (sannysoft 29 passed / 0 failed) plus a live browser run confirming UA, brands and uaFullVersion all self-consistent.
  • puppeteer 25.1.0 → 25.9.0 (bundled Chromium → Chrome 152). Lockfile-only bump; package.json range (>=24.0.0) unchanged. The spoof deliberately presents stable 151 regardless of the bundled 152 build — see the spoof entry above.
  • dig result cache enlarged: cap 2000 → 10000 entries, TTL 20h → 28 days. Far more headroom so a large multi-URL run doesn't evict still-hot domains mid-run, and a much longer TTL so recurring domains survive across runs. Entries are tiny (a domain string + short dig output), so the memory cost is negligible. Two answers now stay pinned for the full window: a changed A-record (a domain that moves off a matched IP range, e.g. leaves Cloudflare) and an NXDOMAIN — which arrives as success: true because it is a real DNS answer rather than a failure, so a re-registered or restored domain still reads as dead until the entry expires. Delete .digcache when either case matters. Transient failures (timeout / SERVFAIL / REFUSED) are success: false and still never cached, so a flaky drop never persists.
  • --adblock-engine now defaults to auto — Brave's adblock-rs when it is importable, the pure-JS matcher otherwise. Passing an explicit js or rust still pins the choice. The JS matcher is fast only when a URL's host hits its O(1) domain map; everything else falls through ~1,400 path/script rules linearly, and that miss path is most real traffic since ordinary page requests match no rule at all. Measured over easylist.txt (66,100 rules): 2.5µs for a domain-map hit against 190µs for a miss, and 148µs (js) vs 4µs (rust) on the same miss workload. The result cache masks this until URLs are unique, which cache-busting query strings routinely make them — throughput falls from ~262k to ~6.5k URL/s. Verified as a speed change and not a behaviour change before switching the default: the two engines agreed on 4,118 of 4,118 verdicts over 3,118 real blocked domains sampled from EasyList plus 1,000 non-matching hosts. Selection resolves lazily inside the --block-ads path, so a run without it never attempts the native require; an auto-selected rust that fails to load warns and falls back to js, while an explicit --adblock-engine=rust still errors, so a deliberate choice is never silently downgraded.
  • Dependency refreship-address 10.2.0 → 10.5.0 (clears three Dependabot advisories: octal-octet parsing, CIDR-suffix special-use suppression, IPv4-mapped/NAT64 misclassification — all SSRF / trust-boundary bypasses). It arrives transitively via socks, whose ^10.1.1 range already permitted the fix, so no overrides entry was needed. Also eslint 10.6.0 → 10.9.1, globals 17.7.0 → 17.11.0, lru-cache 11.5.1 → 11.5.2, p-limit 7.3.0 → 7.3.1, adblock-rs 0.12.5 → 0.12.6. npm audit reports 0 vulnerabilities.

Security

  • OpenVPN extra_args deny-list — openvpn runs under sudo, so a config could previously hand it directives that execute code as root. --config is the entry that makes the rest necessary: it points openvpn at a second config file which can itself carry script-security 2 plus up/down, reinstating everything else the list blocks. --iproute names the command openvpn shells out to for route setup. Plus the script hooks (--script-security, --up, --down, --tls-verify, --plugin, --route-up, --ipchange, --auth-user-pass-verify, --auth-user-pass, --client-connect, --client-disconnect, --learn-address, --tls-crypt-v2-verify, --route-pre-down, --askpass). --writepid is blocked for a different reason — not code execution but a root-controlled file create/clobber: openvpn's duplicate-option precedence is last-wins for it, so an extra_args copy overrode the one the scanner sets and pointed the root-written pid file at any path. (--log/--log-append are deliberately not blocked: measured on openvpn 2.6.19 they are first-wins and the scanner emits its own first, so a config copy is already inert.) A config naming a blocked flag is rejected at validation with an error naming it, and buildArgs drops the flag and its value as defence in depth.
  • curl/grep confined to http/https — both lib/curl.js and lib/grep.js now pass --proto '=http,https' alongside --proto-redir. --proto is the load-bearing half: without it a file:// initial URL was read straight off disk (verified — file:///etc/passwd returned 1539 bytes). Measured on curl 8.5, --proto-redir alone mostly restates curl's own defaults (file/dict/gopher/ldap/smb/scp/sftp/imap/tftp redirect hops are already refused) and adds only ftp/ftps. lib/grep.js also gains the -- end-of-options marker lib/curl.js already had, and lib/curl.js drops CR/LF-bearing custom headers (header injection).
  • One connection-name rule for OpenVPNvalidateOvpnConfig and the pkill -f guard previously used two different charsets, so they disagreed and the looser one could never fire for a config-provided name, while names derived from a config filename (which may contain dots) were checked only by the looser one. Unified on the dot-permitting rule, and the name is now regex-escaped where it reaches pkill -f — unescaped, us.east also matched usXeast and could TERM an unrelated openvpn process. Traversal stays impossible: no path separator is permitted and every path.join site appends an extension.
  • Tightened file permissions — the OpenVPN log is pre-created 0o600 (was 0o666, world-readable/tamperable), auth_file is confined to the project directory or system temp (blocking --auth-user-pass /etc/shadow), verbosity is clamped to 1-6, and the smart-cache disk write is 0o600.

Fixed

  • Subdomain fallback for the dig gate — in default root-dig mode (digSubdomain: false), the dig/dig-or confirmation queried only the registrable root of a requested host. For a domain that serves DNS on a subdomain (dead/parked apex, but abc.example.com resolves — common for CDN/ad infra), the root-dig came back empty/SERVFAIL and the domain was dropped even though the requested subdomain is live and matches. Now, when the root-dig doesn't confirm and the request was to a distinct subdomain, the gate re-digs the exact requested name before giving up. Recovers those captures, and makes the failure report honest — the failure count is now deferred until after the fallback (counted once per candidate, on the root's reason) so a subdomain-recovered domain is no longer mislabeled a SERVFAIL/dead failure. Zero cost on the common path (only fires after an already-unconfirmed root-dig, only when a distinct subdomain exists); output rule is unchanged (||root^, which covers subdomains anyway).
  • End-of-run dig-failure report, with cause codes — a warn line reports how many dig lookups exhausted every attempt (UDP failover + TCP fallback) without an answer, followed by a Failed dig: line naming each domain and its failure reasonSERVFAIL, REFUSED, or timeout (mirrors the Fresh dig: sample). The reason distinguishes a dead/broken domain (SERVFAIL/REFUSED — a resolver reached the authoritative NS and got nothing valid; no capture was lost, retries can't help) from a flaky link (timeout — never got a reply; worth --dig-retry-failed or a caching resolver). Previously a fully-failed dig was silent, and even once surfaced it couldn't tell "the resolver dropped a live domain" from "the domain is dead" — so a rotated-away cloak domain returning SERVFAIL looked identical to real link flakiness. A one-line legend explains the codes. Prints only when non-zero (healthy runs stay quiet) and respects --silent; failures aren't cached, so a re-run retries them.
  • dig lookups fall back to TCP after UDP fails — on flaky links (notably WSL2's UDP-through-NAT path, where datagrams to public resolvers vanish silently and a bare retry then succeeds) a single failed UDP burst made the whole lookup fail, and since transient failures aren't cached the domain silently dropped out of dig/dig-or matching for that run. Each lookup now appends a TCP fallback as its last attempt (+tcp, which retransmits through the NAT where UDP is dropped), preceded by a short 400ms backoff on the already-failing path to let a transient burst clear. UDP is still tried first (fast on a healthy link), so successful lookups are unaffected; the fallback only runs after UDP has failed. Applies to both the system-resolver path and the pinned --dns failover.
  • Cloudflare Promise.race timer leak — five challenge-handling races created a setTimeout that was never cleared when the Puppeteer side won, which is the common, fast path. Each now captures the timer and clears it in .finally(). Buffer ordering verified (10s vs 12s, 3s vs 3.5s), so the Puppeteer promise always rejects first and the timer arm is a pure safety net.
  • .nwssconfig flags matched by substring — the presence check used includes() against the joined argv, so --dns matched inside --dns-cache and a config's dns setting was silently dropped whenever --dns-cache was on the CLI. Now an exact-token match against a snapshot of the args, taken once rather than re-split per settings key, and --flag=value counts as present so a config value isn't appended as a duplicate the parser then ignores.
  • processResults was skipped for most configs — the post-scan pass ran only when at least one site had firstParty: false, despite a comment claiming it always ran, so the ignoreDomains safety net and dedup never fired for any other config. It now always runs; the first-party steps remain internally guarded, so this is a no-op for configs that don't need them.
  • Random-coordinate generation could go out of bounds — the guard against a viewport smaller than its own margins covered only the standard branch, leaving preferEdges/avoidCenter exposed, and the fixed 200px edge zone overflowed narrow viewports (101×101 produced y = -124). The range guard is now hoisted above every branch and the edge zone is clamped to the usable span. Output is byte-identical to before on any viewport with a ≥200px usable span (verified across 3,600 seeded cases).
  • Smart-cache temp files were orphaned on failure — the pid-suffixed temp file is now unlinked on write/rename error. The pid suffix (which fixes concurrent clobbering) meant every failure stranded a file permanently, where the old fixed .tmp name was simply overwritten on the next save.
  • shouldIgnoreSimilarDomain threw on a null listArray.from(undefined) throws, contrary to the comment claiming the guard handled it safely. Null-checked first.
  • Redirect-chain dedup was O(n²) — the chain array was scanned with .includes() on every candidate hop; a parallel Set now backs the membership check.
  • Regex cache cleared wholesale at its cap_compiledRegexCache called .clear() on reaching 2000 entries, forcing every live pattern to recompile at once. Now evicts the single oldest entry (FIFO).
  • Content-type check precompiledshouldAnalyzeContentType scanned a 15-entry array with .some(startsWith) per response; replaced with one ^-anchored regex, verified equivalent across 20 content types including near-misses.