From f772d8db6ed7dcff4a445b28f7aa4fc339abc058 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 17 Oct 2026 07:40:47 +0000 Subject: [PATCH 1/2] feat(threat-feeds): refuse listed domains at dnsmasq or unbound (#304) The daemon now rewrites two blocklists in its state dir on every refresh: blocked-domains.hosts (0.0.0.0 and :: per name, for dnsmasq addn-hosts) and blocked-domains.unbound.conf (always_nxdomain zones, covering subdomains). They are full only with [threat_feeds] block_domains = true and remediation enforcing, empty otherwise, so the config stays the one switch. A hooked resolver is reloaded only when the content changed (dnsmasq SIGHUP; unbound-control reload, else systemctl reload). The daemon cannot write /etc (ProtectSystem), so 'sudo threatcrush feeds resolver install' writes the one-line drop-in once (/etc/dnsmasq.d/threatcrush.conf or /etc/unbound/unbound.conf.d/threatcrush.conf), adds an AppArmor read rule for unbound where a local override exists, validates with dnsmasq --test / unbound-checkconf, and only then restarts; a rejected config is rolled back. 'remove' undoes it, 'status' reports. systemd-resolved cannot refuse names from a list and is reported as detection-only. Verified end to end with real resolvers in Docker on the live list (31,653 domains): dnsmasq answers a listed name 0.0.0.0 / :: and picks up a new name on SIGHUP; unbound answers a listed name and its subdomain NXDOMAIN; example.com resolves through both. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- apps/cli/src/commands/feeds-resolver.ts | 151 ++++++++++++++++++ apps/cli/src/commands/feeds.ts | 5 + .../core/__tests__/resolver-blocklist.test.ts | 113 +++++++++++++ apps/cli/src/core/threat-feeds/index.ts | 40 +++++ apps/cli/src/daemon/workers/threat-feeds.ts | 62 ++++++- apps/cli/src/index.ts | 2 +- apps/cli/src/types/config.ts | 6 + 8 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/commands/feeds-resolver.ts create mode 100644 apps/cli/src/core/__tests__/resolver-blocklist.test.ts diff --git a/README.md b/README.md index fbdd10fe..b0da1175 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ dir = "/srv/threatcrush/watchdog" # then pass --dir to `threatcrush watchdog` | 🔀 **Network Monitor** | Polls conntrack/`ss` every 5 s for inbound TCP connections to your listening ports. Flags port scans (10+ ports in 30 s) and SYN floods (50+ half-open from one source). No packet capture. | | 🚫 **Automatic IP Bans** | Bans attacking IPs via nftables, iptables or fail2ban, with longer bans for repeat offenders. Allowlisted ranges and verified Google/Bing crawlers are never auto-banned. | | 🔑 **Privileged Access** | The daemon snapshots who can become root every minute: uid-0 accounts, sudo/wheel/admin members, sudoers rules and every login account's `authorized_keys` (SHA256 fingerprints). A new uid-0 account is critical; a new sudo member, sudoers rule or SSH key is high. Every sudo command is logged locally from the journal (denied ones are medium), and an accepted SSH login from a country that user has never used raises a high alert. `threatcrush privileged` shows the current state and recent record. | -| ☣️ **Threat Feeds** | The daemon pulls abuse.ch ThreatFox, URLhaus and Feodo hourly (~2,500 C2/payload IPs, ~30,000 malware domains), flags any outbound connection from this box to a listed IP (with the process) and any logged DNS lookup of a listed domain. `[threat_feeds] block = true` also drops egress to those IPs in its own nftables table (needs `[remediation]` in enforce mode). Cloudflare, public resolvers, private ranges and your allowlist are never listed. | +| ☣️ **Threat Feeds** | The daemon pulls abuse.ch ThreatFox, URLhaus and Feodo hourly (~2,500 C2/payload IPs, ~30,000 malware domains), flags any outbound connection from this box to a listed IP (with the process) and any logged DNS lookup of a listed domain. `[threat_feeds] block = true` also drops egress to those IPs in its own nftables table (needs `[remediation]` in enforce mode). Cloudflare, public resolvers, private ranges and your allowlist are never listed. To refuse the listed *domains* too, run `sudo threatcrush feeds resolver install` once (dnsmasq or unbound; systemd-resolved cannot) and set `[threat_feeds] block_domains = true`. | | 🩹 **Patch Status** | `threatcrush patches` lists Debian/Ubuntu packages with a security update waiting (from `apt-get -s`, nothing is changed) and the CVEs each update fixes (OSV.dev). The daemon reports it daily; the fleet dashboard totals pending fixes across servers. | | 🧱 **Hardening Checks** | `threatcrush harden` checks SSH password/root login, sshd weaknesses, automatic security updates, an active firewall, risky exposed ports and fail2ban. | | 🔔 **Alerts** | Email, Slack, Discord, PagerDuty and webhook notifications when a threat is detected. | diff --git a/apps/cli/src/commands/feeds-resolver.ts b/apps/cli/src/commands/feeds-resolver.ts new file mode 100644 index 00000000..ad088c5f --- /dev/null +++ b/apps/cli/src/commands/feeds-resolver.ts @@ -0,0 +1,151 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import chalk from 'chalk'; +import { RESOLVER_FILES, RESOLVER_HOOKS, hostsBlocklist, unboundBlocklist } from '../core/threat-feeds/index.js'; + +/** + * `threatcrush feeds resolver install|remove|status` — the one root step that + * lets dnsmasq or unbound refuse the abuse.ch domains (issue #304). + * + * The daemon runs under ProtectSystem and cannot write /etc, so it only + * rewrites its blocklists in its own state dir. This command, run once as + * root, drops a one-line pointer into the resolver's config, validates the + * result with the resolver's own checker, and only then restarts it; a config + * the checker rejects is rolled back. systemd-resolved has no blocklist + * support and is reported as such. + */ + +export type ResolverName = 'dnsmasq' | 'unbound'; + +export interface ResolverIO { + exists(path: string): boolean; + read(path: string): string | null; + write(path: string, content: string): void; + remove(path: string): void; + /** Exit code and combined output. */ + run(cmd: string, args: string[]): { code: number; out: string }; +} + +const realIO: ResolverIO = { + exists: (p) => existsSync(p), + read: (p) => { try { return readFileSync(p, 'utf-8'); } catch { return null; } }, + write: (p, c) => writeFileSync(p, c), + remove: (p) => rmSync(p, { force: true }), + run: (cmd, args) => { + const r = spawnSync(cmd, args, { encoding: 'utf-8', timeout: 30_000 }); + return { code: r.status ?? 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() }; + }, +}; + +const APPARMOR_LOCAL = '/etc/apparmor.d/local/usr.sbin.unbound'; +const APPARMOR_PROFILE = '/etc/apparmor.d/usr.sbin.unbound'; + +export function hookContent(resolver: ResolverName, stateDir: string): string { + const head = '# Installed by `threatcrush feeds resolver install`; `threatcrush feeds resolver remove` takes it out.\n'; + return resolver === 'dnsmasq' + ? `${head}addn-hosts=${join(stateDir, RESOLVER_FILES.hosts)}\n` + : `${head}include: "${join(stateDir, RESOLVER_FILES.unbound)}"\n`; +} + +export function apparmorRule(stateDir: string): string { + return ` ${join(stateDir, RESOLVER_FILES.unbound)} r, # threatcrush`; +} + +/** Which resolvers are installed here and could take a hook. */ +export function detectResolvers(io: ResolverIO = realIO): ResolverName[] { + const found: ResolverName[] = []; + if (io.exists(dirname(RESOLVER_HOOKS.dnsmasq)) && io.run('dnsmasq', ['--version']).code === 0) found.push('dnsmasq'); + if (io.exists(dirname(RESOLVER_HOOKS.unbound)) && io.run('unbound-checkconf', ['-h']).code !== 127) found.push('unbound'); + return found; +} + +function check(resolver: ResolverName, io: ResolverIO): { code: number; out: string } { + return resolver === 'dnsmasq' ? io.run('dnsmasq', ['--test']) : io.run('unbound-checkconf', []); +} + +function restartIfActive(resolver: ResolverName, io: ResolverIO): string { + if (io.run('systemctl', ['is-active', '--quiet', `${resolver}.service`]).code !== 0) return 'not running; takes effect when it starts'; + const r = io.run('systemctl', ['restart', `${resolver}.service`]); + return r.code === 0 ? 'restarted' : `restart failed: ${r.out.split('\n')[0]}`; +} + +export function installHook(resolver: ResolverName, stateDir: string, io: ResolverIO = realIO): { ok: boolean; message: string } { + const hook = RESOLVER_HOOKS[resolver]; + // The resolver must find a file even before the daemon's first refresh. + const file = join(stateDir, resolver === 'dnsmasq' ? RESOLVER_FILES.hosts : RESOLVER_FILES.unbound); + if (!io.exists(file)) io.write(file, resolver === 'dnsmasq' ? hostsBlocklist([]) : unboundBlocklist([])); + + io.write(hook, hookContent(resolver, stateDir)); + if (resolver === 'unbound' && io.exists(APPARMOR_LOCAL)) { + const local = io.read(APPARMOR_LOCAL) ?? ''; + if (!local.includes(apparmorRule(stateDir))) io.write(APPARMOR_LOCAL, `${local.replace(/\n?$/, '\n')}${apparmorRule(stateDir)}\n`); + if (io.exists(APPARMOR_PROFILE)) io.run('apparmor_parser', ['-r', APPARMOR_PROFILE]); + } + + const checked = check(resolver, io); + if (checked.code !== 0) { + removeHook(resolver, stateDir, io, { restart: false }); + return { ok: false, message: `${resolver} rejected the config, so nothing was changed: ${checked.out.split('\n').slice(-2).join(' ')}` }; + } + return { ok: true, message: `${hook} installed; ${resolver} ${restartIfActive(resolver, io)}` }; +} + +export function removeHook(resolver: ResolverName, stateDir: string, io: ResolverIO = realIO, opts = { restart: true }): string { + const hook = RESOLVER_HOOKS[resolver]; + const had = io.exists(hook); + io.remove(hook); + if (resolver === 'unbound' && io.exists(APPARMOR_LOCAL)) { + const local = io.read(APPARMOR_LOCAL) ?? ''; + const kept = local.split('\n').filter((l) => l !== apparmorRule(stateDir)).join('\n'); + if (kept !== local) { + io.write(APPARMOR_LOCAL, kept); + if (io.exists(APPARMOR_PROFILE)) io.run('apparmor_parser', ['-r', APPARMOR_PROFILE]); + } + } + if (!had) return `${resolver}: no hook installed`; + return `${hook} removed${opts.restart ? `; ${resolver} ${restartIfActive(resolver, io)}` : ''}`; +} + +function countListed(text: string | null, pattern: RegExp): number { + return text ? (text.match(pattern) ?? []).length : 0; +} + +export function resolverCommand(sub: string | undefined, stateDir: string, io: ResolverIO = realIO): number { + const found = detectResolvers(io); + const root = typeof process.getuid !== 'function' || process.getuid() === 0; + + if (sub === 'install' || sub === 'remove') { + if (!root) { + console.error(`Run as root: sudo $(which threatcrush) feeds resolver ${sub}`); + return 1; + } + if (sub === 'install' && found.length === 0) { + console.error('No dnsmasq or unbound here. systemd-resolved cannot refuse names from a list; listed domains stay detection-only (dns-monitor), and listed IPs can still be blocked with [threat_feeds] block = true.'); + return 1; + } + let failed = false; + for (const resolver of sub === 'install' ? found : (['dnsmasq', 'unbound'] as ResolverName[])) { + if (sub === 'install') { + const r = installHook(resolver, stateDir, io); + failed ||= !r.ok; + console.log(`${r.ok ? chalk.green('✓') : chalk.red('✗')} ${r.message}`); + } else { + console.log(` ${removeHook(resolver, stateDir, io)}`); + } + } + if (sub === 'install' && !failed) { + console.log(chalk.gray(' Now set [threat_feeds] block_domains = true (with [remediation] in enforce mode) and restart threatcrushd; until then the lists stay empty.')); + } + return failed ? 1 : 0; + } + + const hosts = io.read(join(stateDir, RESOLVER_FILES.hosts)); + const unbound = io.read(join(stateDir, RESOLVER_FILES.unbound)); + console.log(` resolvers here: ${found.join(', ') || 'none that can block (systemd-resolved only?)'}`); + for (const r of ['dnsmasq', 'unbound'] as ResolverName[]) { + console.log(` ${r.padEnd(8)} hook ${io.exists(RESOLVER_HOOKS[r]) ? chalk.green('installed') : chalk.gray('not installed')}`); + } + console.log(` listed now: ${countListed(hosts, /^0\.0\.0\.0 /gm)} names in ${RESOLVER_FILES.hosts}, ${countListed(unbound, /local-zone:/g)} zones in ${RESOLVER_FILES.unbound}`); + return 0; +} diff --git a/apps/cli/src/commands/feeds.ts b/apps/cli/src/commands/feeds.ts index 133a4e3a..823b2a63 100644 --- a/apps/cli/src/commands/feeds.ts +++ b/apps/cli/src/commands/feeds.ts @@ -5,6 +5,7 @@ import { loadConfig } from '../core/config.js'; import { fetchFeeds, matchDomain, matchIp, readFeedCache, type FeedSet } from '../core/threat-feeds/index.js'; import { SYSTEM_PATHS, USER_PATHS } from '../daemon/paths.js'; import { threatFeedSettings } from '../daemon/workers/threat-feeds.js'; +import { resolverCommand } from './feeds-resolver.js'; /** The daemon's cached list, or a fresh fetch when there is none this user can read. */ async function loadSet(): Promise<{ set: FeedSet; from: string }> { @@ -22,6 +23,10 @@ async function loadSet(): Promise<{ set: FeedSet; from: string }> { * against; `threatcrush feeds check ` looks one value up. */ export async function feedsCommand(action: string | undefined, value: string | undefined, opts: { json?: boolean }): Promise { + if (action === 'resolver') { + process.exitCode = resolverCommand(value, SYSTEM_PATHS.stateDir); + return; + } const { set, from } = await loadSet(); if (action === 'check') { diff --git a/apps/cli/src/core/__tests__/resolver-blocklist.test.ts b/apps/cli/src/core/__tests__/resolver-blocklist.test.ts new file mode 100644 index 00000000..757f8878 --- /dev/null +++ b/apps/cli/src/core/__tests__/resolver-blocklist.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { RESOLVER_HOOKS, hostsBlocklist, unboundBlocklist, type FeedSet } from '../threat-feeds/index.js'; +import { ThreatFeeds, threatFeedSettings } from '../../daemon/workers/threat-feeds.js'; +import { apparmorRule, hookContent, installHook, removeHook, type ResolverIO } from '../../commands/feeds-resolver.js'; + +describe('resolver blocklist formats', () => { + it('hosts: both families per name, so AAAA is answered locally too', () => { + expect(hostsBlocklist(['bad.example']).split('\n').slice(1, 3)).toEqual(['0.0.0.0 bad.example', ':: bad.example']); + }); + it('unbound: one always_nxdomain zone per name, under server:', () => { + expect(unboundBlocklist(['bad.example']).split('\n').slice(1, 3)).toEqual(['server:', ' local-zone: "bad.example." always_nxdomain']); + }); +}); + +const SET: FeedSet = { + fetched_at: '2026-10-17T00:00:00Z', sources: [], skipped_shared: 0, ips: {}, + domains: { 'b.example': { malware: 'x', threat: 'botnet_cc', feed: 'threatfox' }, 'a.example': { malware: 'x', threat: 'botnet_cc', feed: 'threatfox' } }, +}; + +function worker(blockDomains: boolean, enforcing: boolean, hooked: boolean) { + const dir = mkdtempSync(join(tmpdir(), 'tc-resolver-')); + const hooks = { dnsmasq: join(dir, 'dnsmasq-hook.conf'), unbound: join(dir, 'unbound-hook.conf') }; + if (hooked) { writeFileSync(hooks.dnsmasq, ''); writeFileSync(hooks.unbound, ''); } + const calls: string[] = []; + const feeds = new ThreatFeeds({ + settings: { ...threatFeedSettings({ block_domains: blockDomains }), allow: [] }, + enforcing, + cachePath: join(dir, 'threat-feeds.json'), + resolverHooks: hooks, + bus: { publish: () => {}, announceModule: () => {} }, + log: () => {}, + run: (cmd, args) => { calls.push(`${cmd} ${args.join(' ')}`); return ''; }, + }); + return { feeds, dir, calls }; +} + +describe('daemon resolver files', () => { + it('lists the domains, sorted, and reloads hooked resolvers only when content changed', () => { + const w = worker(true, true, true); + expect(w.feeds.writeResolverFiles(SET)).toBe('; 2 domains refused via dnsmasq + unbound'); + expect(readFileSync(join(w.dir, 'blocked-domains.hosts'), 'utf-8')).toContain('0.0.0.0 a.example\n:: a.example\n0.0.0.0 b.example'); + expect(w.calls).toEqual(['systemctl kill -s HUP dnsmasq.service', 'unbound-control reload']); + w.feeds.writeResolverFiles(SET); + expect(w.calls).toHaveLength(2); // nothing new, no reload + }); + + it('writes empty lists when not opted in or not enforcing, and says why', () => { + const off = worker(false, true, true); + expect(off.feeds.writeResolverFiles(SET)).toBe(''); + expect(readFileSync(join(off.dir, 'blocked-domains.unbound.conf'), 'utf-8')).not.toContain('local-zone'); + expect(worker(true, false, true).feeds.writeResolverFiles(SET)).toMatch(/remediation is dry_run/); + expect(worker(true, true, false).feeds.writeResolverFiles(SET)).toMatch(/no resolver hook/); + }); +}); + +function fakeIO(over: { checkFails?: boolean; apparmor?: boolean } = {}) { + const files = new Map(); + if (over.apparmor) { + files.set('/etc/apparmor.d/local/usr.sbin.unbound', '# local\n'); + files.set('/etc/apparmor.d/usr.sbin.unbound', 'profile'); + } + const runs: string[] = []; + const io: ResolverIO = { + exists: (p) => files.has(p), + read: (p) => files.get(p) ?? null, + write: (p, c) => { files.set(p, c); }, + remove: (p) => { files.delete(p); }, + run: (cmd, args) => { + runs.push(`${cmd} ${args.join(' ')}`); + if ((cmd === 'dnsmasq' && args[0] === '--test') || cmd === 'unbound-checkconf') { + return over.checkFails ? { code: 1, out: 'syntax error at line 3' } : { code: 0, out: 'ok' }; + } + return { code: 0, out: '' }; + }, + }; + return { io, files, runs }; +} + +describe('feeds resolver install/remove', () => { + const state = '/var/lib/threatcrush'; + + it('installs the dnsmasq hook, validates, then restarts', () => { + const f = fakeIO(); + const r = installHook('dnsmasq', state, f.io); + expect(r.ok).toBe(true); + expect(f.files.get(RESOLVER_HOOKS.dnsmasq)).toBe(hookContent('dnsmasq', state)); + expect(f.files.get(`${state}/blocked-domains.hosts`)).toMatch(/^# Written by threatcrushd/); + expect(f.runs.indexOf('dnsmasq --test')).toBeLessThan(f.runs.indexOf('systemctl restart dnsmasq.service')); + }); + + it('rolls the hook back when the resolver rejects the config, and never restarts', () => { + const f = fakeIO({ checkFails: true }); + const r = installHook('unbound', state, f.io); + expect(r.ok).toBe(false); + expect(r.message).toMatch(/rejected the config, so nothing was changed: syntax error/); + expect(f.files.has(RESOLVER_HOOKS.unbound)).toBe(false); + expect(f.runs.some((c) => c.startsWith('systemctl restart'))).toBe(false); + }); + + it('adds the AppArmor read rule for unbound once, and removes it with the hook', () => { + const f = fakeIO({ apparmor: true }); + installHook('unbound', state, f.io); + installHook('unbound', state, f.io); + const local = f.files.get('/etc/apparmor.d/local/usr.sbin.unbound')!; + expect(local.split(apparmorRule(state)).length - 1).toBe(1); + expect(f.runs).toContain('apparmor_parser -r /etc/apparmor.d/usr.sbin.unbound'); + expect(removeHook('unbound', state, f.io)).toMatch(/removed; unbound restarted/); + expect(f.files.get('/etc/apparmor.d/local/usr.sbin.unbound')).not.toContain('threatcrush'); + }); +}); diff --git a/apps/cli/src/core/threat-feeds/index.ts b/apps/cli/src/core/threat-feeds/index.ts index d72ef11b..66632c31 100644 --- a/apps/cli/src/core/threat-feeds/index.ts +++ b/apps/cli/src/core/threat-feeds/index.ts @@ -77,6 +77,7 @@ const SHARED_DOMAINS = new Set([ export interface ThreatFeedSettings { enabled: boolean; block: boolean; + blockDomains: boolean; refreshMs: number; minConfidence: number; /** IPs, CIDRs or domains never matched. */ @@ -219,6 +220,45 @@ export function writeFeedCache(path: string, set: FeedSet): void { renameSync(tmp, path); } +// ─── Resolver blocklists (dnsmasq, unbound) ─── + +/** + * Files the daemon rewrites in its state dir on every refresh, and the + * drop-ins `threatcrush feeds resolver install` puts in a resolver's config + * to read them. The daemon cannot write /etc (ProtectSystem); the drop-ins are + * written once, as root, and only point at these files. + */ +export const RESOLVER_FILES = { + hosts: 'blocked-domains.hosts', + unbound: 'blocked-domains.unbound.conf', +} as const; + +export const RESOLVER_HOOKS = { + dnsmasq: '/etc/dnsmasq.d/threatcrush.conf', + unbound: '/etc/unbound/unbound.conf.d/threatcrush.conf', +} as const; + +const GENERATED = '# Written by threatcrushd from abuse.ch feeds; rewritten every refresh. Do not edit.'; + +/** + * A hosts file for dnsmasq's `addn-hosts`: both families, so an AAAA lookup is + * answered here instead of forwarded upstream. dnsmasq matches these names + * exactly; subdomains of a listed name still resolve. + */ +export function hostsBlocklist(domains: string[]): string { + const lines = [GENERATED]; + for (const d of domains) lines.push(`0.0.0.0 ${d}`, `:: ${d}`); + return `${lines.join('\n')}\n`; +} + +/** An unbound include: every listed name and everything under it is NXDOMAIN. */ +export function unboundBlocklist(domains: string[]): string { + const lines = [GENERATED, 'server:']; + // normalizeDomain allows only [a-z0-9_.-], so nothing here can break out of the quotes. + for (const d of domains) lines.push(` local-zone: "${d}." always_nxdomain`); + return `${lines.join('\n')}\n`; +} + // ─── The active set, shared in-process with dns-monitor ─── let active: FeedSet | null = null; diff --git a/apps/cli/src/daemon/workers/threat-feeds.ts b/apps/cli/src/daemon/workers/threat-feeds.ts index 4df1163d..2e19d121 100644 --- a/apps/cli/src/daemon/workers/threat-feeds.ts +++ b/apps/cli/src/daemon/workers/threat-feeds.ts @@ -1,11 +1,17 @@ import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { isIP } from 'node:net'; import type { ThreatEvent } from '../../types/events.js'; import type { ThreatFeedsSection } from '../../types/config.js'; import { parseDuration } from '../firewall/backoff.js'; import { + RESOLVER_FILES, + RESOLVER_HOOKS, fetchFeeds, + hostsBlocklist, matchIp, + unboundBlocklist, readFeedCache, setActiveFeeds, writeFeedCache, @@ -37,6 +43,7 @@ export function threatFeedSettings(section: ThreatFeedsSection | undefined): Thr return { enabled: section?.enabled !== false, block: section?.block === true, + blockDomains: section?.block_domains === true, // abuse.ch asks for no more than one fetch per 5 minutes; hourly is plenty. refreshMs: refresh ? Math.max(900, refresh) * 1000 : DEFAULT_REFRESH_MS, minConfidence: typeof section?.min_confidence === 'number' ? section.min_confidence : 75, @@ -129,6 +136,10 @@ export interface ThreatFeedsOptions { /** Remediation is enforcing (not dry_run, and a real firewall exists). */ enforcing: boolean; cachePath: string; + /** Where the resolver blocklists go (default: beside the cache). */ + resolverDir?: string; + /** Drop-in paths whose presence means a resolver reads those files (tests override). */ + resolverHooks?: { dnsmasq: string; unbound: string }; bus: { publish(event: ThreatEvent): void; announceModule(name: string, status: string, detail?: string): void }; log: (line: string) => void; fetchImpl?: FetchLike; @@ -218,11 +229,60 @@ export class ThreatFeeds { this.blocking = ok; mode = ok ? 'blocking egress' : 'detecting (nft load failed)'; } - const detail = `${ips.length} IPs, ${domains} domains, ${mode}`; + const detail = `${ips.length} IPs, ${domains} domains, ${mode}${this.writeResolverFiles(set)}`; this.opts.log(`[threat-feeds] ${from === 'cache' ? 'loaded cached' : 'refreshed'} list: ${detail} (${set.skipped_shared} shared/protected entries skipped)`); this.opts.bus.announceModule('threat-feeds', 'running', detail); } + /** Whether listed domains are refused at the resolver: opted in and enforcing. */ + get wantsDomainBlock(): boolean { + return this.opts.settings.blockDomains && this.opts.enforcing; + } + + /** + * Rewrite the resolver blocklists in the state dir and reload any resolver + * whose hook is installed — only when the content changed, so an hourly + * refresh with nothing new costs no reload. With domain blocking off the + * files are emptied, so an installed hook blocks nothing and the config + * stays the one switch. Returns a status suffix for the log line. + */ + writeResolverFiles(set: FeedSet): string { + const domains = this.wantsDomainBlock ? Object.keys(set.domains).sort() : []; + const dir = this.opts.resolverDir ?? dirname(this.opts.cachePath); + const hooks = this.opts.resolverHooks ?? RESOLVER_HOOKS; + const write = (name: string, content: string): boolean => { + const path = join(dir, name); + try { + if (existsSync(path) && readFileSync(path, 'utf-8') === content) return false; + writeFileSync(path, content); + return true; + } catch (err) { + this.opts.log(`[threat-feeds] could not write ${path}: ${(err as Error).message}`); + return false; + } + }; + const hostsChanged = write(RESOLVER_FILES.hosts, hostsBlocklist(domains)); + const unboundChanged = write(RESOLVER_FILES.unbound, unboundBlocklist(domains)); + + const via: string[] = []; + if (existsSync(hooks.dnsmasq)) { + via.push('dnsmasq'); + // SIGHUP makes dnsmasq re-read its addn-hosts files without a restart. + if (hostsChanged) this.run('systemctl', ['kill', '-s', 'HUP', 'dnsmasq.service']); + } + if (existsSync(hooks.unbound)) { + via.push('unbound'); + if (unboundChanged && this.run('unbound-control', ['reload']) === null) { + this.run('systemctl', ['reload', 'unbound.service']); + } + } + if (!this.opts.settings.blockDomains) return ''; + if (!this.opts.enforcing) return '; domain blocking set but remediation is dry_run'; + return via.length + ? `; ${domains.length} domains refused via ${via.join(' + ')}` + : '; domain blocking set but no resolver hook (run `threatcrush feeds resolver install`)'; + } + private removeBlocking(): void { if (this.run('nft', ['list', 'table', 'inet', EGRESS_TABLE]) !== null) { this.run('nft', ['delete', 'table', 'inet', EGRESS_TABLE]); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 954cae85..82752ab9 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -615,7 +615,7 @@ program program .command("feeds [action] [value]") - .description("Show the abuse.ch C2/malware list the daemon matches; `feeds check ` looks one up (exit 1 when listed)") + .description("Show the abuse.ch C2/malware list the daemon matches; `feeds check ` looks one up (exit 1 when listed); `feeds resolver install|remove|status` lets dnsmasq/unbound refuse the listed domains") .option("--json", "Output as JSON") .action(async (action: string | undefined, value: string | undefined, opts: { json?: boolean }) => { await feedsCommand(action, value, opts); diff --git a/apps/cli/src/types/config.ts b/apps/cli/src/types/config.ts index 6dc38847..7790461a 100644 --- a/apps/cli/src/types/config.ts +++ b/apps/cli/src/types/config.ts @@ -121,6 +121,12 @@ export interface ThreatFeedsSection { enabled?: boolean; /** Default false. Drop egress to listed IPs (needs [remediation] in enforce mode and nftables). */ block?: boolean; + /** + * Default false. Refuse the listed DOMAINS at the local resolver too + * (dnsmasq or unbound, after `threatcrush feeds resolver install`; needs + * [remediation] in enforce mode). systemd-resolved cannot do this. + */ + block_domains?: boolean; /** How often to re-fetch, e.g. "1h" (default; at least 15m). */ refresh?: string; /** ThreatFox confidence floor, 0–100. Default 75. */ From ebfa37fa742068fdb8760fc0e711df9d129e2e8c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 17 Oct 2026 07:43:50 +0000 Subject: [PATCH 2/2] fix(threat-feeds): write resolver blocklists atomically, no check-then-write CodeQL #817 (js/file-system-race): the file was checked and read before being rewritten. Compare against the content this process last wrote instead, and swap the new file in with rename, so a resolver reloading mid-write sees the old list or the new one, never half of one. Co-Authored-By: Claude Opus 5.5 --- apps/cli/src/daemon/workers/threat-feeds.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/daemon/workers/threat-feeds.ts b/apps/cli/src/daemon/workers/threat-feeds.ts index 2e19d121..b7788c88 100644 --- a/apps/cli/src/daemon/workers/threat-feeds.ts +++ b/apps/cli/src/daemon/workers/threat-feeds.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, renameSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { isIP } from 'node:net'; import type { ThreatEvent } from '../../types/events.js'; @@ -158,6 +158,8 @@ export class ThreatFeeds { private set: FeedSet | null = null; private alerted = new Map(); private blocking = false; + /** Resolver blocklist content last written, by file name (see writeResolverFiles). */ + private resolverWritten = new Map(); private readonly run: NonNullable; private readonly now: () => number; @@ -250,11 +252,16 @@ export class ThreatFeeds { const domains = this.wantsDomainBlock ? Object.keys(set.domains).sort() : []; const dir = this.opts.resolverDir ?? dirname(this.opts.cachePath); const hooks = this.opts.resolverHooks ?? RESOLVER_HOOKS; + // Compared with what this process last wrote, never read back, and swapped + // in by rename: a resolver reloading mid-write must see the old list or the + // new one, never half of one. The first write after a start always happens. const write = (name: string, content: string): boolean => { const path = join(dir, name); + if (this.resolverWritten.get(name) === content) return false; try { - if (existsSync(path) && readFileSync(path, 'utf-8') === content) return false; - writeFileSync(path, content); + writeFileSync(`${path}.tmp`, content); + renameSync(`${path}.tmp`, path); + this.resolverWritten.set(name, content); return true; } catch (err) { this.opts.log(`[threat-feeds] could not write ${path}: ${(err as Error).message}`);