diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a3ea0ecb..e63357a0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -95,4 +95,4 @@ Hoot is an all-in-one app for exploring domain names, providing instant insights - Keep secrets in `.env.local` - Review `server/trpc.ts` when extending routers - Validate all external inputs with Zod -- Use `server-only` imports for sensitive modules \ No newline at end of file +- Use `server-only` imports for sensitive modules diff --git a/README.md b/README.md index cc594e4c..e0190c5c 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,18 @@ ## 🛠️ Tech Stack -- **Next.js 15** with Turbopack +- **Next.js 15** (App Router) - **React 19** - **TypeScript** - **Tailwind CSS v4** - **tRPC** API endpoints - **Upstash Redis** for caching - **Vercel Blob** for favicon & screenshot storage -- **Puppeteer Core + @sparticuz/chromium** for server-side screenshots (fallback to `puppeteer` locally) +- **rdapper** for RDAP registration lookups with WHOIS fallback +- **Puppeteer** for server-side screenshots +- **Mapbox** for embedded IP geolocation maps +- **PostHog** for product analytics +- **Biome** linting and formatting --- @@ -56,6 +60,8 @@ --- -## 🙋‍♂️ About +## 📜 License -Made with ❤️ by [@jakejarvis](https://github.com/jakejarvis). [Licensed under MIT.](LICENSE) Owl logo by Jordy Matsuoka from Noun Project (CC BY 3.0). +[MIT](LICENSE) + +Owl logo by [Jordy Madueño](https://thenounproject.com/creator/jordymadueno/) from [Noun Project](https://thenounproject.com/) (CC BY 3.0). diff --git a/components/domain/domain-report-view.test.tsx b/components/domain/domain-report-view.test.tsx index 5ee196b1..f5bd600e 100644 --- a/components/domain/domain-report-view.test.tsx +++ b/components/domain/domain-report-view.test.tsx @@ -44,7 +44,6 @@ vi.mock("@/hooks/use-domain-queries", () => ({ dnsProvider: { name: "Cloudflare", domain: "cloudflare.com" }, hostingProvider: { name: "Vercel", domain: "vercel.com" }, emailProvider: { name: "Google Workspace", domain: "google.com" }, - ipAddress: null, geo: { city: "", region: "", diff --git a/components/domain/sections/hosting-email-section.test.tsx b/components/domain/sections/hosting-email-section.test.tsx index 04bb19dd..ce76539a 100644 --- a/components/domain/sections/hosting-email-section.test.tsx +++ b/components/domain/sections/hosting-email-section.test.tsx @@ -35,7 +35,6 @@ describe("HostingEmailSection", () => { dnsProvider: { name: "Cloudflare", domain: "cloudflare.com" }, hostingProvider: { name: "Vercel", domain: "vercel.com" }, emailProvider: { name: "Google Workspace", domain: "google.com" }, - ipAddress: "1.2.3.4", geo: { city: "", region: "", diff --git a/server/services/cloudflare.test.ts b/lib/cloudflare.test.ts similarity index 100% rename from server/services/cloudflare.test.ts rename to lib/cloudflare.test.ts diff --git a/server/services/cloudflare.ts b/lib/cloudflare.ts similarity index 99% rename from server/services/cloudflare.ts rename to lib/cloudflare.ts index 1919ae85..91e55b5d 100644 --- a/server/services/cloudflare.ts +++ b/lib/cloudflare.ts @@ -6,8 +6,8 @@ export interface CloudflareIpRanges { ipv6Cidrs: string[]; } -let lastLoadedIpv6Parsed: Array<[ipaddr.IPv6, number]> | undefined; let lastLoadedIpv4Parsed: Array<[ipaddr.IPv4, number]> | undefined; +let lastLoadedIpv6Parsed: Array<[ipaddr.IPv6, number]> | undefined; export const getCloudflareIpRanges = unstable_cache( async (): Promise => { @@ -16,41 +16,44 @@ export const getCloudflareIpRanges = unstable_cache( throw new Error(`Failed to fetch Cloudflare IPs: ${res.status}`); } const data = await res.json(); + const ranges: CloudflareIpRanges = { ipv4Cidrs: data.result?.ipv4_cidrs || [], ipv6Cidrs: data.result?.ipv6_cidrs || [], }; - // Pre-parse IPv6 CIDRs for fast sync/async checks + + // Pre-parse IPv4 CIDRs for fast sync/async checks try { - lastLoadedIpv6Parsed = ranges.ipv6Cidrs + lastLoadedIpv4Parsed = ranges.ipv4Cidrs .map((cidr) => { try { const [net, prefix] = ipaddr.parseCIDR(cidr); - if (net.kind() !== "ipv6") return undefined; - return [net as ipaddr.IPv6, prefix] as [ipaddr.IPv6, number]; + if (net.kind() !== "ipv4") return undefined; + return [net as ipaddr.IPv4, prefix] as [ipaddr.IPv4, number]; } catch { return undefined; } }) - .filter(Boolean) as Array<[ipaddr.IPv6, number]>; + .filter(Boolean) as Array<[ipaddr.IPv4, number]>; } catch { - lastLoadedIpv6Parsed = undefined; + lastLoadedIpv4Parsed = undefined; } - // Pre-parse IPv4 CIDRs for fast sync/async checks + + // Pre-parse IPv6 CIDRs for fast sync/async checks try { - lastLoadedIpv4Parsed = ranges.ipv4Cidrs + lastLoadedIpv6Parsed = ranges.ipv6Cidrs .map((cidr) => { try { const [net, prefix] = ipaddr.parseCIDR(cidr); - if (net.kind() !== "ipv4") return undefined; - return [net as ipaddr.IPv4, prefix] as [ipaddr.IPv4, number]; + if (net.kind() !== "ipv6") return undefined; + return [net as ipaddr.IPv6, prefix] as [ipaddr.IPv6, number]; } catch { return undefined; } }) - .filter(Boolean) as Array<[ipaddr.IPv4, number]>; + .filter(Boolean) as Array<[ipaddr.IPv6, number]>; } catch { - lastLoadedIpv4Parsed = undefined; + lastLoadedIpv6Parsed = undefined; } return ranges; }, diff --git a/lib/providers/README.md b/lib/providers/README.md index bfa50326..ee3342a7 100644 --- a/lib/providers/README.md +++ b/lib/providers/README.md @@ -1,78 +1,84 @@ # Provider Detection System -This system provides extensible and robust provider detection using a clean rule-based approach. +This system provides extensible and robust provider detection using a declarative JSON-serializable logic AST with AND/OR/NOT composition over domain primitives. ## Architecture -The detection system is built around simple, focused types: +### Logic AST -### DetectionRule +```ts +type HeaderEquals = { kind: "headerEquals"; name: string; value: string }; +type HeaderIncludes = { kind: "headerIncludes"; name: string; substr: string }; +type HeaderPresent = { kind: "headerPresent"; name: string }; +type MxSuffix = { kind: "mxSuffix"; suffix: string }; +type NsSuffix = { kind: "nsSuffix"; suffix: string }; +type IssuerEquals = { kind: "issuerEquals"; value: string }; +type IssuerIncludes = { kind: "issuerIncludes"; substr: string }; +type RegistrarEquals = { kind: "registrarEquals"; value: string }; +type RegistrarIncludes = { kind: "registrarIncludes"; substr: string }; + +type Logic = + | { all: Logic[] } + | { any: Logic[] } + | { not: Logic } + | HeaderEquals + | HeaderIncludes + | HeaderPresent + | MxSuffix + | NsSuffix + | IssuerEquals + | IssuerIncludes + | RegistrarEquals + | RegistrarIncludes; +``` -Each rule is specific and focused: +DetectionContext passed to the evaluator: -```typescript -type DetectionRule = - // HTTP header matching - | { type: "header"; name: string; value?: string; present?: boolean } - // DNS record matching - | { type: "dns"; recordType: "MX" | "NS"; value: string }; +```ts +interface DetectionContext { + headers: Record; // normalized lowercased names + mx: string[]; // lowercased FQDNs (no trailing dot) + ns: string[]; // lowercased FQDNs (no trailing dot) + issuer?: string; // lowercased certificate issuer string + registrar?: string; // lowercased registrar name from WHOIS/RDAP +} ``` -### Provider +### Evaluator -Each provider has metadata and an array of detection rules: +See `evalRule` in `lib/providers/detection.ts`. -```typescript -interface Provider { - name: string; // "Vercel" - domain: string; // "vercel.com" - rules: DetectionRule[]; // Array of rules that identify this provider -} +### Provider Catalog + +Providers are defined with a single `rule` per provider and a `category`: + +```ts +type Provider = { + name: string; + domain: string; + category: "hosting" | "email" | "dns" | "ca" | "registrar"; + rule: Logic; +}; ``` ## Usage -### Basic Detection - -```typescript -import { - detectHostingProvider, - detectEmailProvider, +```ts +import { + detectHostingProvider, + detectEmailProvider, detectDnsProvider, detectCertificateAuthority, - resolveRegistrarDomain + detectRegistrar, } from '@/lib/providers/detection'; -// Hosting detection -const headers = [ - { name: 'server', value: 'vercel' }, - { name: 'x-vercel-id', value: 'abc123' } -]; -const hosting = detectHostingProvider(headers); -console.log(hosting); // { name: "Vercel", domain: "vercel.com" } - -// Email detection -const mxRecords = ['mx1.google.com', 'mx2.google.com']; -const email = detectEmailProvider(mxRecords); -console.log(email); // { name: "Google Workspace", domain: "google.com" } - -// DNS detection -const nsRecords = ['ns1.cloudflare.com', 'ns2.cloudflare.com']; -const dns = detectDnsProvider(nsRecords); -console.log(dns); // { name: "Cloudflare", domain: "cloudflare.com" } - -// Certificate Authority detection (alias matching against issuer string) +const hosting = detectHostingProvider([{ name: 'server', value: 'Vercel' }]); +const email = detectEmailProvider(['aspmx.l.google.com.']); +const dns = detectDnsProvider(['ns1.cloudflare.com']); const ca = detectCertificateAuthority("Let's Encrypt R3"); -console.log(ca); // { name: "Let's Encrypt", domain: "letsencrypt.org" } - -// Registrar domain resolution (partial match of registrar names) -const registrarName = 'GoDaddy Inc.'; -const registrarDomain = resolveRegistrarDomain(registrarName); -console.log(registrarDomain); // "godaddy.com" +const registrar = detectRegistrar('GoDaddy Inc.'); ``` -### Adding New Providers - Add to the appropriate section in `catalog.ts`: ```typescript @@ -81,56 +87,53 @@ export const HOSTING_PROVIDERS: Provider[] = [ { name: "Railway", domain: "railway.app", - rules: [ - { type: "header", name: "x-railway-id", present: true }, - { type: "header", name: "server", value: "railway" } - ], + category: "hosting", + rule: { + any: [ + { kind: "headerPresent", name: "x-railway-id" }, + { kind: "headerEquals", name: "server", value: "railway" }, + ], + }, }, ]; ``` ## Rule Types -### Header Rules +### Header primitives -```typescript -// Check if header exists -{ type: "header", name: "cf-ray", present: true } +```ts +{ kind: "headerPresent", name: "cf-ray" } +{ kind: "headerEquals", name: "server", value: "vercel" } +{ kind: "headerIncludes", name: "server", substr: "cloud" } +``` -// Check header value contains substring -{ type: "header", name: "server", value: "vercel" } +### DNS primitives -// Check header exists (shorthand - same as present: true) -{ type: "header", name: "x-vercel-id" } +```ts +{ kind: "mxSuffix", suffix: "aspmx.l.google.com" } +{ kind: "nsSuffix", suffix: "cloudflare.com" } ``` -### DNS Rules - -```typescript -// Check MX record contains substring -{ type: "dns", recordType: "MX", value: "google.com" } +### Certificate Authority primitives -// Check NS record contains substring -{ type: "dns", recordType: "NS", value: "cloudflare.com" } +```ts +{ kind: "issuerIncludes", substr: "let's encrypt" } +{ kind: "issuerEquals", value: "isrg r3" } ``` -### Certificate Authorities - -Modeled via a dedicated provider type with alias matching (no DetectionRule): +### Registrar primitives ```ts -interface CertificateAuthorityProvider { - name: string; // "Let's Encrypt" - domain: string; // "letsencrypt.org" - aliases?: string[]; // ["isrg", "r3", "lets encrypt"] -} +{ kind: "registrarIncludes", substr: "godaddy inc" } +{ kind: "registrarEquals", value: "namecheap" } ``` ## Rule Evaluation -- **Provider-level**: A provider matches if **ANY** of its rules match (OR logic) -- **Rule-level**: Each rule has specific matching criteria -- **Fallback**: Returns "Unknown" if no provider matches, or first hostname for DNS/email +- **Provider-level**: Evaluate the provider's single `rule` with `evalRule`. Compose complex logic using `{ all | any | not }`. +- **Rule primitives**: Header, DNS, issuer, and registrar primitives match against normalized context values. +- **Fallbacks**: Returns "Unknown" if no provider matches; DNS/Email fall back to the registrable domain of the first record when unknown. ## Benefits @@ -140,4 +143,4 @@ interface CertificateAuthorityProvider { 4. **Robust**: Unified evaluation prevents inconsistencies 5. **Fast**: Efficient pre-calculated contexts avoid redundant work -This approach provides a clean, maintainable foundation for provider detection that's easy to understand and extend. \ No newline at end of file +This approach provides a clean, maintainable foundation for provider detection that's easy to understand and extend. diff --git a/lib/providers/catalog.ts b/lib/providers/catalog.ts index e455767b..724fe16f 100644 --- a/lib/providers/catalog.ts +++ b/lib/providers/catalog.ts @@ -1,152 +1,190 @@ -import type { - CertificateAuthorityProvider, - HostingProvider, - RegistrarProvider, -} from "@/lib/schemas"; +import type { Provider } from "@/lib/schemas"; /** * A registry of known hosting providers. The detection algorithm will iterate * through this list to identify the hosting provider from HTTP headers. */ -export const HOSTING_PROVIDERS: HostingProvider[] = [ +export const HOSTING_PROVIDERS: Array< + Omit & { category: "hosting" } +> = [ { name: "Vercel", domain: "vercel.com", - rules: [ - { type: "header", name: "server", value: "vercel" }, - { type: "header", name: "x-vercel-id", present: true }, - ], + category: "hosting", + rule: { + any: [ + { kind: "headerEquals", name: "server", value: "vercel" }, + { kind: "headerPresent", name: "x-vercel-id" }, + ], + }, }, { name: "WP Engine", domain: "wpengine.com", - rules: [{ type: "header", name: "x-powered-by", value: "wp engine" }], + category: "hosting", + rule: { kind: "headerIncludes", name: "x-powered-by", substr: "wp engine" }, + }, + { + name: "WordPress VIP", + domain: "wpvip.com", + category: "hosting", + rule: { + kind: "headerIncludes", + name: "x-powered-by", + substr: "wordpress vip", + }, }, { name: "WordPress.com", domain: "wordpress.com", - rules: [{ type: "header", name: "host-header", value: "wordpress.com" }], + category: "hosting", + rule: { + kind: "headerIncludes", + name: "host-header", + substr: "wordpress.com", + }, }, { name: "Amazon S3", domain: "aws.amazon.com", - rules: [{ type: "header", name: "server", value: "AmazonS3" }], + category: "hosting", + rule: { kind: "headerEquals", name: "server", value: "amazons3" }, }, { name: "Netlify", domain: "netlify.com", - rules: [{ type: "header", name: "server", value: "Netlify" }], + category: "hosting", + rule: { kind: "headerEquals", name: "server", value: "netlify" }, }, { name: "GitHub Pages", domain: "github.com", - rules: [{ type: "header", name: "server", value: "GitHub.com" }], + category: "hosting", + rule: { kind: "headerEquals", name: "server", value: "github.com" }, }, { name: "GitLab Pages", domain: "gitlab.com", - rules: [{ type: "header", name: "server", value: "GitLab Pages" }], + category: "hosting", + rule: { kind: "headerEquals", name: "server", value: "gitlab pages" }, }, { name: "Fly.io", domain: "fly.io", - rules: [{ type: "header", name: "server", value: "Fly/" }], + category: "hosting", + rule: { kind: "headerIncludes", name: "server", substr: "fly/" }, }, { name: "Akamai", domain: "akamai.com", - rules: [{ type: "header", name: "server", value: "AkamaiGHost" }], + category: "hosting", + rule: { kind: "headerEquals", name: "server", value: "akamaighost" }, }, { name: "Amazon CloudFront", domain: "aws.amazon.com", - rules: [ - { type: "header", name: "server", value: "CloudFront" }, - { type: "header", name: "x-amz-cf-id", present: true }, - ], + category: "hosting", + rule: { + all: [ + { kind: "headerEquals", name: "server", value: "cloudfront" }, + { kind: "headerPresent", name: "x-amz-cf-id" }, + ], + }, }, { name: "Heroku", domain: "heroku.com", - rules: [{ type: "header", name: "server", value: "vegur" }], + category: "hosting", + rule: { kind: "headerEquals", name: "server", value: "vegur" }, }, { name: "Render", domain: "render.com", - rules: [{ type: "header", name: "server", value: "Render" }], + category: "hosting", + rule: { kind: "headerEquals", name: "server", value: "render" }, }, { name: "Squarespace", domain: "squarespace.com", - rules: [{ type: "header", name: "x-contextid", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-contextid" }, }, { name: "Shopify", domain: "shopify.com", - rules: [{ type: "header", name: "x-shopify-stage", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-shopify-stage" }, }, { name: "Webflow", domain: "webflow.com", - rules: [{ type: "header", name: "x-wf-page-id", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-wf-page-id" }, }, { name: "Wix", domain: "wix.com", - rules: [{ type: "header", name: "x-wix-request-id", present: true }], - }, - { - name: "Cloudflare", - domain: "cloudflare.com", - rules: [ - { type: "header", name: "server", value: "cloudflare" }, - { type: "header", name: "cf-ray", present: true }, - ], + category: "hosting", + rule: { kind: "headerPresent", name: "x-wix-request-id" }, }, { name: "Azure Front Door", domain: "azure.microsoft.com", - rules: [{ type: "header", name: "x-azure-ref", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-azure-ref" }, }, { name: "Google Cloud Storage", domain: "cloud.google.com", - rules: [{ type: "header", name: "x-goog-generation", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-goog-generation" }, }, { name: "Azure Static Web Apps", domain: "azure.microsoft.com", - rules: [{ type: "header", name: "x-azure-ref", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-azure-ref" }, }, { name: "OVHcloud", domain: "ovhcloud.com", - rules: [{ type: "header", name: "x-ovh-request-id", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-ovh-request-id" }, }, { name: "Pantheon", domain: "pantheon.io", - rules: [{ type: "header", name: "x-pantheon-site", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-pantheon-site" }, }, { name: "Sucuri", domain: "sucuri.net", - rules: [{ type: "header", name: "x-sucuri-id", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-sucuri-id" }, }, { name: "Imperva", domain: "imperva.com", - rules: [{ type: "header", name: "x-iinfo", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-iinfo" }, }, { name: "Kinsta", domain: "kinsta.com", - rules: [{ type: "header", name: "x-kinsta-cache", present: true }], + category: "hosting", + rule: { kind: "headerPresent", name: "x-kinsta-cache" }, }, { - name: "WordPress VIP", - domain: "wpvip.com", - rules: [{ type: "header", name: "x-powered-by", value: "WordPress VIP" }], + name: "Cloudflare", + domain: "cloudflare.com", + category: "hosting", + rule: { + all: [ + { kind: "headerEquals", name: "server", value: "cloudflare" }, + { kind: "headerPresent", name: "cf-ray" }, + ], + }, }, ]; @@ -154,119 +192,149 @@ export const HOSTING_PROVIDERS: HostingProvider[] = [ * A registry of known email providers. The detection algorithm will iterate * through this list to identify the email provider from MX records. */ -export const EMAIL_PROVIDERS: HostingProvider[] = [ - // Google now supports a single MX as well as the legacy multi-MX set. +export const EMAIL_PROVIDERS: Array< + Omit & { category: "email" } +> = [ { name: "Google Workspace", domain: "google.com", - rules: [{ type: "dns", recordType: "MX", value: "smtp.google.com" }], - }, - { - name: "Google Workspace", - domain: "google.com", - rules: [{ type: "dns", recordType: "MX", value: "aspmx.l.google.com" }], + category: "email", + rule: { + any: [ + { kind: "mxSuffix", suffix: "smtp.google.com" }, + { kind: "mxSuffix", suffix: "aspmx.l.google.com" }, + ], + }, }, { name: "Microsoft 365", domain: "office.com", - rules: [ - { type: "dns", recordType: "MX", value: "mail.protection.outlook.com" }, - ], + category: "email", + rule: { kind: "mxSuffix", suffix: "mail.protection.outlook.com" }, }, { name: "Zoho", domain: "zoho.com", - rules: [{ type: "dns", recordType: "MX", value: "mx.zoho.com" }], + category: "email", + rule: { + any: [ + { kind: "mxSuffix", suffix: "mx.zoho.com" }, + { kind: "mxSuffix", suffix: "mx.zoho.eu" }, + ], + }, }, { name: "Proton", domain: "proton.me", - rules: [{ type: "dns", recordType: "MX", value: "protonmail.ch" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "protonmail.ch" }, }, { name: "Fastmail", domain: "fastmail.com", - rules: [{ type: "dns", recordType: "MX", value: "messagingengine.com" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "messagingengine.com" }, }, { name: "Cloudflare Email Routing", domain: "cloudflare.com", - rules: [{ type: "dns", recordType: "MX", value: "mx.cloudflare.net" }], + category: "email", + rule: { + any: [ + { kind: "mxSuffix", suffix: "mx.cloudflare.net" }, + { kind: "mxSuffix", suffix: "inbound.cf-emailsecurity.net" }, + ], + }, }, { name: "Yahoo Mail", domain: "yahoo.com", - rules: [{ type: "dns", recordType: "MX", value: "yahoodns.net" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "yahoodns.net" }, }, { name: "Yandex 360", domain: "yandex.com", - rules: [{ type: "dns", recordType: "MX", value: "mx.yandex.net" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "mx.yandex.net" }, }, { name: "ImprovMX", domain: "improvmx.com", - rules: [{ type: "dns", recordType: "MX", value: "improvmx.com" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "improvmx.com" }, }, { name: "Forward Email", domain: "forwardemail.net", - rules: [{ type: "dns", recordType: "MX", value: "forwardemail.net" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "forwardemail.net" }, }, { name: "Migadu", domain: "migadu.com", - rules: [{ type: "dns", recordType: "MX", value: "migadu.com" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "migadu.com" }, }, { name: "iCloud Mail", domain: "icloud.com", - rules: [{ type: "dns", recordType: "MX", value: "mail.icloud.com" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "mail.icloud.com" }, }, { name: "Mailgun", domain: "mailgun.com", - rules: [{ type: "dns", recordType: "MX", value: "mailgun.org" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "mailgun.org" }, }, { name: "SendGrid", domain: "sendgrid.com", - rules: [{ type: "dns", recordType: "MX", value: "sendgrid.net" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "sendgrid.net" }, }, { name: "Mailjet", domain: "mailjet.com", - rules: [{ type: "dns", recordType: "MX", value: "mailjet.com" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "mailjet.com" }, }, { name: "Postmark", domain: "postmarkapp.com", - rules: [{ type: "dns", recordType: "MX", value: "postmarkapp.com" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "postmarkapp.com" }, }, { name: "Rackspace Email", domain: "rackspace.com", - rules: [{ type: "dns", recordType: "MX", value: "emailsrvr.com" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "emailsrvr.com" }, }, { name: "Proofpoint", domain: "proofpoint.com", - rules: [{ type: "dns", recordType: "MX", value: "pphosted.com" }], - }, - { - name: "Amazon WorkMail", - domain: "aws.amazon.com", - rules: [{ type: "dns", recordType: "MX", value: "inbound-smtp." }], + category: "email", + rule: { kind: "mxSuffix", suffix: "pphosted.com" }, }, + // { + // name: "Amazon WorkMail", + // domain: "aws.amazon.com", + // category: "email", + // rule: { kind: "mxSuffix", suffix: "inbound-smtp." }, + // }, { name: "Titan Email", domain: "titan.email", - rules: [{ type: "dns", recordType: "MX", value: "mx1.titan.email" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "titan.email" }, }, { name: "IONOS Mail", domain: "ionos.com", - rules: [{ type: "dns", recordType: "MX", value: "mx00.ionos.com" }], + category: "email", + rule: { kind: "mxSuffix", suffix: "ionos.com" }, }, ]; @@ -274,293 +342,509 @@ export const EMAIL_PROVIDERS: HostingProvider[] = [ * A registry of known DNS providers. The detection algorithm will iterate * through this list to identify the DNS provider from NS records. */ -export const DNS_PROVIDERS: HostingProvider[] = [ +export const DNS_PROVIDERS: Array< + Omit & { category: "dns" } +> = [ { name: "Cloudflare", domain: "cloudflare.com", - rules: [{ type: "dns", recordType: "NS", value: "cloudflare.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "cloudflare.com" }, }, { name: "Vercel", domain: "vercel.com", - rules: [{ type: "dns", recordType: "NS", value: "vercel-dns.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "vercel-dns.com" }, }, { name: "DNSimple", domain: "dnsimple.com", - rules: [{ type: "dns", recordType: "NS", value: "dnsimple.com" }], + category: "dns", + rule: { + any: [ + { kind: "nsSuffix", suffix: "dnsimple.com" }, + { kind: "nsSuffix", suffix: "dnsimple-edge.net" }, + { kind: "nsSuffix", suffix: "dnsimple-edge.org" }, + ], + }, }, { name: "WordPress.com", domain: "wordpress.com", - rules: [{ type: "dns", recordType: "NS", value: "wordpress.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "wordpress.com" }, }, { name: "DNS Made Easy", domain: "dnsmadeeasy.com", - rules: [{ type: "dns", recordType: "NS", value: "dnsmadeeasy.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "dnsmadeeasy.com" }, }, { name: "DigitalOcean", domain: "digitalocean.com", - rules: [{ type: "dns", recordType: "NS", value: "digitalocean.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "digitalocean.com" }, }, { name: "NS1", domain: "ns1.com", - rules: [ - { type: "dns", recordType: "NS", value: "nsone.net" }, - { type: "dns", recordType: "NS", value: "ns1.com" }, - ], + category: "dns", + rule: { + any: [ + { kind: "nsSuffix", suffix: "nsone.net" }, + { kind: "nsSuffix", suffix: "ns1.com" }, + ], + }, }, { name: "Amazon Route 53", domain: "aws.amazon.com", - rules: [{ type: "dns", recordType: "NS", value: "awsdns" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "awsdns" }, }, { name: "GoDaddy", domain: "godaddy.com", - rules: [{ type: "dns", recordType: "NS", value: "domaincontrol.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "domaincontrol.com" }, }, { name: "Google Cloud DNS", domain: "cloud.google.com", - rules: [ - { type: "dns", recordType: "NS", value: "googledomains.com" }, - { type: "dns", recordType: "NS", value: "ns-cloud" }, - ], + category: "dns", + rule: { kind: "nsSuffix", suffix: "googledomains.com" }, }, { name: "Hurricane Electric", domain: "he.net", - rules: [{ type: "dns", recordType: "NS", value: "he.net" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "he.net" }, }, { name: "Linode", domain: "linode.com", - rules: [{ type: "dns", recordType: "NS", value: "linode.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "linode.com" }, }, { name: "Hetzner", domain: "hetzner.com", - rules: [{ type: "dns", recordType: "NS", value: "hetzner.de" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "hetzner.de" }, }, { name: "OVHcloud", domain: "ovhcloud.com", - rules: [ - { type: "dns", recordType: "NS", value: "ovh.net" }, - { type: "dns", recordType: "NS", value: "ovh.co.uk" }, - ], + category: "dns", + rule: { + any: [ + { kind: "nsSuffix", suffix: "ovh.net" }, + { kind: "nsSuffix", suffix: "ovh.co.uk" }, + ], + }, }, { - name: "IONOS", + name: "1&1 IONOS", domain: "ionos.com", - rules: [ - { type: "dns", recordType: "NS", value: "ionos.com" }, - { type: "dns", recordType: "NS", value: "1and1.com" }, - ], + category: "dns", + rule: { + any: [ + { kind: "nsSuffix", suffix: "ionos.com" }, + { kind: "nsSuffix", suffix: "1and1.com" }, + ], + }, }, { name: "NameSilo", domain: "namesilo.com", - rules: [{ type: "dns", recordType: "NS", value: "namesilo.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "namesilo.com" }, }, { name: "DreamHost", domain: "dreamhost.com", - rules: [{ type: "dns", recordType: "NS", value: "dreamhost.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "dreamhost.com" }, }, { name: "Squarespace", domain: "squarespace.com", - rules: [{ type: "dns", recordType: "NS", value: "squarespacedns.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "squarespacedns.com" }, }, { name: "Wix", domain: "wix.com", - rules: [{ type: "dns", recordType: "NS", value: "wixdns.net" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "wixdns.net" }, }, { name: "Microsoft Azure", domain: "azure.microsoft.com", - rules: [ - { type: "dns", recordType: "NS", value: "azure-dns.com" }, - { type: "dns", recordType: "NS", value: "azure-dns.net" }, - { type: "dns", recordType: "NS", value: "azure-dns.org" }, - { type: "dns", recordType: "NS", value: "azure-dns.info" }, - ], + category: "dns", + rule: { + any: [ + { kind: "nsSuffix", suffix: "azure-dns.com" }, + { kind: "nsSuffix", suffix: "azure-dns.net" }, + { kind: "nsSuffix", suffix: "azure-dns.org" }, + { kind: "nsSuffix", suffix: "azure-dns.info" }, + ], + }, }, { name: "Namecheap FreeDNS", domain: "namecheap.com", - rules: [{ type: "dns", recordType: "NS", value: "registrar-servers.com" }], + category: "dns", + rule: { kind: "nsSuffix", suffix: "registrar-servers.com" }, }, ]; /** Registrar providers registry for WHOIS/RDAP partial name matching */ -export const REGISTRAR_PROVIDERS: RegistrarProvider[] = [ +export const REGISTRAR_PROVIDERS: Array< + Omit & { category: "registrar" } +> = [ { name: "GoDaddy", domain: "godaddy.com", - aliases: ["godaddy inc", "go daddy", "wild west domains"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "godaddy" }, + { kind: "registrarIncludes", substr: "go daddy" }, + { kind: "registrarIncludes", substr: "wild west domains" }, + ], + }, }, { name: "Namecheap", domain: "namecheap.com", - aliases: ["namecheap, inc", "namecheap inc"], + category: "registrar", + rule: { kind: "registrarIncludes", substr: "namecheap" }, }, { name: "Squarespace", domain: "squarespace.domains", - aliases: ["squarespace domains llc", "squarespace domains ii llc"], + category: "registrar", + rule: { + any: [{ kind: "registrarIncludes", substr: "squarespace" }], + }, }, // Keep Google LLC for rare legacy WHOIS text, but do not alias to MarkMonitor (separate registrar). { name: "Google", domain: "google.com", - aliases: ["google llc", "google inc"], + category: "registrar", + rule: { kind: "registrarIncludes", substr: "google" }, }, { name: "Cloudflare", domain: "cloudflare.com", - aliases: ["cloudflare, inc", "cloudflare inc"], + category: "registrar", + rule: { kind: "registrarIncludes", substr: "cloudflare" }, + }, + { + name: "Gandi", + domain: "gandi.net", + category: "registrar", + rule: { kind: "registrarIncludes", substr: "gandi" }, }, - { name: "Gandi", domain: "gandi.net", aliases: ["gandi sas", "gandi sas"] }, { name: "Tucows", domain: "tucows.com", - aliases: ["tucows domains inc", "hover"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "tucows" }, + { kind: "registrarIncludes", substr: "hover" }, + ], + }, }, { name: "OVHcloud", domain: "ovhcloud.com", - aliases: ["ovh sas", "ovhgroup"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "ovhcloud" }, + { kind: "registrarIncludes", substr: "ovhgroup" }, + { kind: "registrarIncludes", substr: "ovh sas" }, + ], + }, }, { name: "1&1 IONOS", domain: "ionos.com", - aliases: ["1&1", "ionos se", "united internet"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "1&1" }, + { kind: "registrarIncludes", substr: "ionos" }, + { kind: "registrarIncludes", substr: "united internet" }, + ], + }, }, { name: "Name.com", domain: "name.com", - aliases: ["rightside", "donuts inc", "identity digital"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "name.com" }, + { kind: "registrarIncludes", substr: "rightside" }, + { kind: "registrarIncludes", substr: "afilias" }, + { kind: "registrarIncludes", substr: "donuts" }, + { kind: "registrarIncludes", substr: "identity digital" }, + ], + }, + }, + { + name: "Dynadot", + domain: "dynadot.com", + category: "registrar", + rule: { kind: "registrarIncludes", substr: "dynadot" }, + }, + { + name: "Porkbun", + domain: "porkbun.com", + category: "registrar", + rule: { kind: "registrarIncludes", substr: "porkbun" }, }, - { name: "Dynadot", domain: "dynadot.com", aliases: ["dynadot llc"] }, - { name: "Porkbun", domain: "porkbun.com", aliases: ["porkbun llc"] }, { name: "Alibaba Cloud", domain: "alibaba.com", - aliases: ["alibaba cloud computing ltd", "aliyun"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "alibaba" }, + { kind: "registrarIncludes", substr: "aliyun" }, + ], + }, + }, + { + name: "Enom", + domain: "enom.com", + category: "registrar", + rule: { kind: "registrarIncludes", substr: "enom" }, }, - { name: "Enom", domain: "enom.com", aliases: ["enom, llc"] }, { name: "GMO Internet", domain: "gmo.jp", - aliases: ["onamae", "gmo internet group"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "gmo internet" }, + { kind: "registrarIncludes", substr: "onamae" }, + ], + }, }, { name: "Network Solutions", domain: "networksolutions.com", - aliases: ["networksolutions inc", "networksolutions llc"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "network solutions" }, + { kind: "registrarIncludes", substr: "networksolutions" }, + { kind: "registrarIncludes", substr: "web.com" }, + ], + }, }, { name: "Register.com", domain: "register.com", - aliases: ["register, inc", "register inc"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "register.com" }, + { kind: "registrarIncludes", substr: "register, inc" }, + { kind: "registrarIncludes", substr: "register inc" }, + ], + }, }, { name: "Automattic", domain: "wordpress.com", - aliases: ["wordpress.com", "wordpress vip"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "automattic" }, + { kind: "registrarIncludes", substr: "wordpress.com" }, + ], + }, }, { name: "MarkMonitor", domain: "markmonitor.com", - aliases: ["markmonitor inc", "markmonitor, inc."], + category: "registrar", + rule: { kind: "registrarIncludes", substr: "markmonitor" }, }, { name: "CSC Corporate Domains", domain: "cscdbs.com", - aliases: ["csc corporate domains", "csc global"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "csc corporate" }, + { kind: "registrarIncludes", substr: "csc global" }, + ], + }, }, { name: "Ascio", domain: "ascio.com", - aliases: ["ascio technologies", "ascio technologies, inc."], + category: "registrar", + rule: { kind: "registrarIncludes", substr: "ascio" }, }, { name: "Key-Systems", domain: "key-systems.net", - aliases: ["key-systems gmbh", "rrpproxy"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "key-systems" }, + { kind: "registrarIncludes", substr: "rrpproxy" }, + ], + }, }, { name: "PublicDomainRegistry (PDR)", domain: "publicdomainregistry.com", - aliases: ["pdr ltd", "publicdomainregistry.com"], + category: "registrar", + rule: { + any: [ + { kind: "registrarIncludes", substr: "publicdomainregistry" }, + { kind: "registrarIncludes", substr: "pdr ltd" }, + ], + }, + }, + { + name: "Wix", + domain: "wix.com", + category: "registrar", + rule: { kind: "registrarIncludes", substr: "wix" }, + }, + { + name: "RegistrarSafe", + domain: "meta.com", + category: "registrar", + rule: { kind: "registrarIncludes", substr: "registrarsafe" }, }, - { name: "Wix", domain: "wix.com", aliases: ["wix.com ltd"] }, - { name: "RegistrarSafe", domain: "meta.com", aliases: ["registrarsafe llc"] }, ]; /** * Certificate Authorities registry. Matches against issuer strings. */ -export const CA_PROVIDERS: CertificateAuthorityProvider[] = [ +export const CA_PROVIDERS: Array< + Omit & { category: "ca" } +> = [ { name: "Let's Encrypt", domain: "letsencrypt.org", - aliases: [ - "let's encrypt", - "lets encrypt", - "isrg", - // legacy intermediates - "r3", - "r4", - "e1", - "e2", - // 2024+ intermediates per LE announcement - "r10", - "r11", - "r12", - "r13", - "r14", - "e5", - "e6", - "e7", - "e8", - ], - }, - { name: "ZeroSSL", domain: "zerossl.com", aliases: ["zerossl"] }, + category: "ca", + rule: { + any: [ + { kind: "issuerIncludes", substr: "let's encrypt" }, + { kind: "issuerIncludes", substr: "lets encrypt" }, + { kind: "issuerIncludes", substr: "isrg" }, + { kind: "issuerEquals", value: "r3" }, + { kind: "issuerEquals", value: "r4" }, + { kind: "issuerEquals", value: "e1" }, + { kind: "issuerEquals", value: "e2" }, + { kind: "issuerEquals", value: "r10" }, + { kind: "issuerEquals", value: "r11" }, + { kind: "issuerEquals", value: "r12" }, + { kind: "issuerEquals", value: "r13" }, + { kind: "issuerEquals", value: "r14" }, + { kind: "issuerEquals", value: "e5" }, + { kind: "issuerEquals", value: "e6" }, + { kind: "issuerEquals", value: "e7" }, + { kind: "issuerEquals", value: "e8" }, + ], + }, + }, + { + name: "ZeroSSL", + domain: "zerossl.com", + category: "ca", + rule: { kind: "issuerIncludes", substr: "zerossl" }, + }, { name: "DigiCert", domain: "digicert.com", - aliases: ["digicert", "baltimore cybertrust", "thawte"], + category: "ca", + rule: { + any: [ + { kind: "issuerIncludes", substr: "digicert" }, + { kind: "issuerIncludes", substr: "baltimore cybertrust" }, + { kind: "issuerIncludes", substr: "thawte" }, + ], + }, }, { name: "Google Trust Services", domain: "pki.goog", - aliases: ["google trust services", "gts"], + category: "ca", + rule: { + any: [ + { kind: "issuerIncludes", substr: "google trust services" }, + { kind: "issuerIncludes", substr: "gts" }, + ], + }, + }, + { + name: "GoDaddy", + domain: "godaddy.com", + category: "ca", + rule: { + any: [ + { kind: "issuerEquals", value: "godaddy" }, + { kind: "issuerEquals", value: "starfield" }, + ], + }, }, - { name: "GoDaddy", domain: "godaddy.com", aliases: ["godaddy", "starfield"] }, { name: "Sectigo (Comodo)", domain: "sectigo.com", - aliases: ["sectigo", "comodo", "usertrust", "aaa"], + category: "ca", + rule: { + any: [ + { kind: "issuerIncludes", substr: "sectigo" }, + { kind: "issuerIncludes", substr: "comodo" }, + { kind: "issuerIncludes", substr: "usertrust" }, + { kind: "issuerIncludes", substr: "aaa" }, + ], + }, + }, + { + name: "GlobalSign", + domain: "globalsign.com", + category: "ca", + rule: { kind: "issuerIncludes", substr: "globalsign" }, + }, + { + name: "GeoTrust", + domain: "geotrust.com", + category: "ca", + rule: { kind: "issuerIncludes", substr: "geotrust" }, + }, + { + name: "Entrust", + domain: "entrust.com", + category: "ca", + rule: { kind: "issuerIncludes", substr: "entrust" }, }, - { name: "GlobalSign", domain: "globalsign.com", aliases: ["globalsign"] }, - { name: "GeoTrust", domain: "geotrust.com", aliases: ["geotrust"] }, - { name: "Entrust", domain: "entrust.com", aliases: ["entrust"] }, { name: "Amazon Trust Services", domain: "amazontrust.com", - aliases: ["amazon trust", "amazon"], + category: "ca", + rule: { kind: "issuerIncludes", substr: "amazon" }, }, { name: "Cloudflare", domain: "cloudflare.com", - aliases: ["cloudflare inc ecc ca", "cloudflare inc rsa ca", "cloudflare"], + category: "ca", + rule: { kind: "issuerIncludes", substr: "cloudflare" }, }, ]; diff --git a/lib/providers/detection.test.ts b/lib/providers/detection.test.ts index b4b8246c..a3f32328 100644 --- a/lib/providers/detection.test.ts +++ b/lib/providers/detection.test.ts @@ -4,7 +4,7 @@ import { detectDnsProvider, detectEmailProvider, detectHostingProvider, - resolveRegistrarDomain, + detectRegistrar, } from "./detection"; describe("provider detection", () => { @@ -19,7 +19,7 @@ describe("provider detection", () => { }); it("detects email from MX (Google)", () => { - const res = detectEmailProvider(["aspmx.l.google.com"]); + const res = detectEmailProvider(["aspmx.l.google.com."]); expect(res.name).toBe("Google Workspace"); expect(res.domain).toBe("google.com"); }); @@ -30,8 +30,9 @@ describe("provider detection", () => { expect(res.domain).toBe("cloudflare.com"); }); - it("resolves registrar domain from aliases", () => { - expect(resolveRegistrarDomain("GoDaddy Inc.")).toBe("godaddy.com"); + it("detects registrar from name (GoDaddy)", () => { + const res = detectRegistrar("GoDaddy Inc."); + expect(res.domain).toBe("godaddy.com"); }); it("detects CA from issuer string (Let's Encrypt)", () => { diff --git a/lib/providers/detection.ts b/lib/providers/detection.ts index 193bae07..19fff6d4 100644 --- a/lib/providers/detection.ts +++ b/lib/providers/detection.ts @@ -7,9 +7,10 @@ import { REGISTRAR_PROVIDERS, } from "@/lib/providers/catalog"; import type { - DetectionRule, - HostingProvider, + DetectionContext, HttpHeader, + Logic, + Provider, ProviderRef, } from "@/lib/schemas"; @@ -43,43 +44,53 @@ function createHeaderContext(headers: HttpHeader[]): HeaderDetectionContext { /** * Evaluate a single detection rule against the provided context. */ -function evaluateRule( - rule: DetectionRule, - headerContext?: HeaderDetectionContext, - mxHosts?: string[], - nsHosts?: string[], -): boolean { - switch (rule.type) { - case "header": { - if (!headerContext) return false; - - const headerName = rule.name.toLowerCase(); - const headerValue = headerContext.headerMap.get(headerName); - - // Check for header presence - if (rule.present) { - return headerContext.headerNames.has(headerName); - } - - // Check for header value match - if (rule.value && headerValue) { - return headerValue.includes(rule.value.toLowerCase()); - } - - // If no specific checks, just check for presence - return headerContext.headerNames.has(headerName); +export function evalRule(rule: Logic, ctx: DetectionContext): boolean { + const get = (name: string) => ctx.headers[name.toLowerCase()]; + const anyDns = (arr: string[], suf: string) => + arr.some((h) => h === suf || h.endsWith(`.${suf}`)); + + if ("all" in rule) return rule.all.every((r) => evalRule(r, ctx)); + if ("any" in rule) return rule.any.some((r) => evalRule(r, ctx)); + if ("not" in rule) return !evalRule(rule.not, ctx); + + switch (rule.kind) { + case "headerEquals": { + const v = get(rule.name); + return ( + typeof v === "string" && v.toLowerCase() === rule.value.toLowerCase() + ); } - - case "dns": { - const hosts = rule.recordType === "MX" ? mxHosts : nsHosts; - if (!hosts) return false; - - const searchValue = rule.value.toLowerCase(); - return hosts.some((host) => host.toLowerCase().includes(searchValue)); + case "headerIncludes": { + const v = get(rule.name); + return ( + typeof v === "string" && + v.toLowerCase().includes(rule.substr.toLowerCase()) + ); + } + case "headerPresent": { + const key = rule.name.toLowerCase(); + return key in ctx.headers; + } + case "mxSuffix": { + return anyDns(ctx.mx, rule.suffix.toLowerCase()); + } + case "nsSuffix": { + return anyDns(ctx.ns, rule.suffix.toLowerCase()); + } + case "issuerEquals": { + return !!ctx.issuer && ctx.issuer === rule.value.toLowerCase(); + } + case "issuerIncludes": { + return !!ctx.issuer?.includes(rule.substr.toLowerCase()); + } + case "registrarEquals": { + return !!ctx.registrar && ctx.registrar === rule.value.toLowerCase(); + } + case "registrarIncludes": { + return ( + !!ctx.registrar && ctx.registrar.includes(rule.substr.toLowerCase()) + ); } - - default: - return false; } } @@ -87,22 +98,27 @@ function evaluateRule( * Detect a provider from a list of providers using the provided context. */ function detectProviderFromList( - providers: HostingProvider[], + providers: Provider[], headerContext?: HeaderDetectionContext, mxHosts?: string[], nsHosts?: string[], ): ProviderRef { + const headersObj: Record = Object.fromEntries( + (headerContext?.headers ?? []).map((h) => [ + h.name.toLowerCase(), + h.value.trim().toLowerCase(), + ]), + ); + const ctx: DetectionContext = { + headers: headersObj, + mx: (mxHosts ?? []).map((h) => h.toLowerCase().replace(/\.$/, "")), + ns: (nsHosts ?? []).map((h) => h.toLowerCase().replace(/\.$/, "")), + }; for (const provider of providers) { - // A provider matches if ANY of its rules match (OR logic) - const matches = provider.rules.some((rule) => - evaluateRule(rule, headerContext, mxHosts, nsHosts), - ); - - if (matches) { + if (evalRule(provider.rule, ctx)) { return { name: provider.name, domain: provider.domain }; } } - return { name: "Unknown", domain: null }; } @@ -147,42 +163,30 @@ export function detectDnsProvider(nsHosts: string[]): ProviderRef { return { name: "Unknown", domain: null }; } -/** Resolve registrar domain from a registrar name using partial matching */ -export function resolveRegistrarDomain(registrarName: string): string | null { +/** Detect registrar provider from registrar name */ +export function detectRegistrar(registrarName: string): ProviderRef { const name = (registrarName || "").toLowerCase(); - if (!name) return null; - - for (const reg of REGISTRAR_PROVIDERS) { - if (reg.name.toLowerCase() === name) return reg.domain; - } - - for (const reg of REGISTRAR_PROVIDERS) { - if (name.includes(reg.name.toLowerCase())) return reg.domain; - } - + if (!name) return { name: "Unknown", domain: null }; + const ctx: DetectionContext = { + headers: {}, + mx: [], + ns: [], + issuer: undefined, + registrar: name, + }; for (const reg of REGISTRAR_PROVIDERS) { - if (reg.aliases?.some((a) => name.includes(a.toLowerCase()))) { - return reg.domain; - } + if (evalRule(reg.rule, ctx)) return { name: reg.name, domain: reg.domain }; } - - return null; + return { name: "Unknown", domain: null }; } /** Detect certificate authority from an issuer string */ export function detectCertificateAuthority(issuer: string): ProviderRef { const name = (issuer || "").toLowerCase(); if (!name) return { name: "Unknown", domain: null }; + const ctx: DetectionContext = { headers: {}, mx: [], ns: [], issuer: name }; for (const ca of CA_PROVIDERS) { - if (ca.name.toLowerCase() === name) - return { name: ca.name, domain: ca.domain }; - } - for (const ca of CA_PROVIDERS) { - if (name.includes(ca.name.toLowerCase())) - return { name: ca.name, domain: ca.domain }; - } - for (const ca of CA_PROVIDERS) { - if (ca.aliases?.some((a) => name.includes(a.toLowerCase()))) { + if (evalRule(ca.rule, ctx)) { return { name: ca.name, domain: ca.domain }; } } diff --git a/lib/schemas/dns.ts b/lib/schemas/dns.ts index 8bdfd56d..09a63cfb 100644 --- a/lib/schemas/dns.ts +++ b/lib/schemas/dns.ts @@ -2,14 +2,10 @@ import { z } from "zod"; export const DnsSourceSchema = z.enum(["cloudflare", "google"]); +export const DnsTypeSchema = z.enum(["A", "AAAA", "MX", "TXT", "NS"]); + export const DnsRecordSchema = z.object({ - type: z.union([ - z.literal("A"), - z.literal("AAAA"), - z.literal("MX"), - z.literal("TXT"), - z.literal("NS"), - ]), + type: DnsTypeSchema, name: z.string(), value: z.string(), ttl: z.number().optional(), @@ -23,5 +19,6 @@ export const DnsResolveResultSchema = z.object({ }); export type DnsSource = z.infer; +export type DnsType = z.infer; export type DnsRecord = z.infer; export type DnsResolveResult = z.infer; diff --git a/lib/schemas/hosting.ts b/lib/schemas/hosting.ts index caa5620f..b9c0d456 100644 --- a/lib/schemas/hosting.ts +++ b/lib/schemas/hosting.ts @@ -1,20 +1,10 @@ import { z } from "zod"; -import { DetectionRuleSchema, ProviderRefSchema } from "./provider"; - -export const HostingProviderSchema = z.object({ - /** The canonical name of the provider (e.g., "Vercel") */ - name: z.string(), - /** The domain used to fetch the provider's icon (e.g., "vercel.com") */ - domain: z.string(), - /** An array of rules that, if matched, identify this provider. */ - rules: z.array(DetectionRuleSchema), -}); +import { ProviderRefSchema } from "./provider"; export const HostingSchema = z.object({ hostingProvider: ProviderRefSchema, emailProvider: ProviderRefSchema, dnsProvider: ProviderRefSchema, - ipAddress: z.string().nullable(), geo: z.object({ city: z.string(), region: z.string(), @@ -25,5 +15,4 @@ export const HostingSchema = z.object({ }), }); -export type HostingProvider = z.infer; export type Hosting = z.infer; diff --git a/lib/schemas/provider.ts b/lib/schemas/provider.ts index fddd83f7..5188f113 100644 --- a/lib/schemas/provider.ts +++ b/lib/schemas/provider.ts @@ -1,26 +1,60 @@ import { z } from "zod"; -/** - * Zod schema for provider detection rules - */ -export const DetectionRuleSchema = z.union([ - z.object({ - type: z.literal("header"), - name: z.string(), - value: z.string().optional(), - present: z.boolean().optional(), - }), - z.object({ - type: z.literal("dns"), - recordType: z.enum(["MX", "NS"]), - value: z.string(), - }), -]); +// Primitive checks +export type HeaderEquals = { + kind: "headerEquals"; + name: string; + value: string; +}; +export type HeaderIncludes = { + kind: "headerIncludes"; + name: string; + substr: string; +}; +export type HeaderPresent = { kind: "headerPresent"; name: string }; +export type MxSuffix = { kind: "mxSuffix"; suffix: string }; +export type NsSuffix = { kind: "nsSuffix"; suffix: string }; +export type IssuerEquals = { kind: "issuerEquals"; value: string }; +export type IssuerIncludes = { kind: "issuerIncludes"; substr: string }; +export type RegistrarEquals = { kind: "registrarEquals"; value: string }; +export type RegistrarIncludes = { kind: "registrarIncludes"; substr: string }; + +// Compose with logic +export type Logic = + | { all: Logic[] } + | { any: Logic[] } + | { not: Logic } + | HeaderEquals + | HeaderIncludes + | HeaderPresent + | MxSuffix + | NsSuffix + | IssuerEquals + | IssuerIncludes + | RegistrarEquals + | RegistrarIncludes; + +// What the evaluator receives +export interface DetectionContext { + headers: Record; // normalized lowercased names + mx: string[]; // lowercased FQDNs + ns: string[]; // lowercased FQDNs + issuer?: string; // lowercased certificate issuer string + registrar?: string; // from WHOIS/RDAP, normalized lowercase +} + +export type ProviderCategory = "hosting" | "email" | "dns" | "ca" | "registrar"; + +export type Provider = { + name: string; + domain: string; + rule: Logic; + category: ProviderCategory; +}; export const ProviderRefSchema = z.object({ name: z.string(), domain: z.string().nullable(), }); -export type DetectionRule = z.infer; export type ProviderRef = z.infer; diff --git a/lib/schemas/registration.ts b/lib/schemas/registration.ts index d637e857..0f090d72 100644 --- a/lib/schemas/registration.ts +++ b/lib/schemas/registration.ts @@ -1,16 +1,6 @@ import { z } from "zod"; import { ProviderRefSchema } from "./provider"; -/** Registrar providers do not use rules; they are matched by partial name */ -export const RegistrarProviderSchema = z.object({ - /** Canonical registrar display name (e.g., "GoDaddy") */ - name: z.string(), - /** Domain for favicon (e.g., "godaddy.com") */ - domain: z.string(), - /** Additional case-insensitive substrings to match (e.g., ["godaddy inc"]). */ - aliases: z.array(z.string()).optional(), -}); - // typed from rdapper // https://chatgpt.com/s/t_68daacac17b88191b9dda5c878327209 export const RegistrationSchema = z.object({ @@ -118,5 +108,4 @@ export const RegistrationSchema = z.object({ registrarProvider: ProviderRefSchema, }); -export type RegistrarProvider = z.infer; export type Registration = z.infer; diff --git a/lib/schemas/tls.ts b/lib/schemas/tls.ts index 8401f59f..79f0f538 100644 --- a/lib/schemas/tls.ts +++ b/lib/schemas/tls.ts @@ -1,16 +1,6 @@ import { z } from "zod"; import { ProviderRefSchema } from "./provider"; -/** Certificate Authority providers matched via aliases in issuer strings */ -export const CertificateAuthorityProviderSchema = z.object({ - /** Canonical CA display name (e.g., "Let's Encrypt") */ - name: z.string(), - /** Domain for favicon (e.g., "letsencrypt.org") */ - domain: z.string(), - /** Case-insensitive substrings or tokens present in issuer, e.g. ["isrg", "r3"] */ - aliases: z.array(z.string()).optional(), -}); - export const CertificateSchema = z.object({ issuer: z.string(), subject: z.string(), @@ -22,7 +12,4 @@ export const CertificateSchema = z.object({ export const CertificatesSchema = z.array(CertificateSchema); -export type CertificateAuthorityProvider = z.infer< - typeof CertificateAuthorityProviderSchema ->; export type Certificate = z.infer; diff --git a/server/services/dns.test.ts b/server/services/dns.test.ts index e6fdf608..f4f4ea83 100644 --- a/server/services/dns.test.ts +++ b/server/services/dns.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveAll } from "./dns"; -vi.mock("@/server/services/cloudflare", () => ({ +vi.mock("@/lib/cloudflare", () => ({ isCloudflareIpAsync: vi.fn(async () => false), })); diff --git a/server/services/dns.ts b/server/services/dns.ts index 44b62133..a58171a5 100644 --- a/server/services/dns.ts +++ b/server/services/dns.ts @@ -1,18 +1,49 @@ import { captureServer } from "@/lib/analytics/server"; +import { isCloudflareIpAsync } from "@/lib/cloudflare"; +import { USER_AGENT } from "@/lib/constants"; import { getOrSetZod, ns } from "@/lib/redis"; import { type DnsRecord, DnsRecordSchema, type DnsResolveResult, + type DnsSource, + type DnsType, + DnsTypeSchema, } from "@/lib/schemas"; -import { isCloudflareIpAsync } from "@/server/services/cloudflare"; -import { - DOH_PROVIDERS, - type DohProvider, -} from "@/server/services/doh-providers"; -type DnsType = DnsRecord["type"]; -const TYPES: DnsType[] = ["A", "AAAA", "MX", "TXT", "NS"]; +export type DohProvider = { + key: DnsSource; + buildUrl: (domain: string, type: DnsType) => URL; + headers?: Record; +}; + +const DEFAULT_HEADERS: Record = { + accept: "application/dns-json", + "user-agent": USER_AGENT, +}; + +export const DOH_PROVIDERS: DohProvider[] = [ + { + key: "cloudflare", + buildUrl: (domain, type) => { + const u = new URL("https://cloudflare-dns.com/dns-query"); + u.searchParams.set("name", domain); + u.searchParams.set("type", type); + return u; + }, + headers: DEFAULT_HEADERS, + }, + { + key: "google", + buildUrl: (domain, type) => { + const u = new URL("https://dns.google/resolve"); + u.searchParams.set("name", domain); + u.searchParams.set("type", type); + return u; + }, + headers: DEFAULT_HEADERS, + }, +]; export async function resolveAll(domain: string): Promise { const lower = domain.toLowerCase(); @@ -26,7 +57,7 @@ export async function resolveAll(domain: string): Promise { const attemptStart = Date.now(); try { const results = await Promise.all( - TYPES.map(async (type) => { + DnsTypeSchema.options.map(async (type) => { const key = ns("dns", `${lower}:${type}:${provider.key}`); return await getOrSetZod( key, @@ -39,7 +70,7 @@ export async function resolveAll(domain: string): Promise { const flat = results.flat(); durationByProvider[provider.key] = Date.now() - attemptStart; - const counts = TYPES.reduce( + const counts = DnsTypeSchema.options.reduce( (acc, t) => { acc[t] = flat.filter((r) => r.type === t).length; return acc; diff --git a/server/services/doh-providers.ts b/server/services/doh-providers.ts deleted file mode 100644 index 5556f3c2..00000000 --- a/server/services/doh-providers.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { USER_AGENT } from "@/lib/constants"; -export type DnsRecordType = "A" | "AAAA" | "MX" | "TXT" | "NS"; - -export type DohProviderKey = "cloudflare" | "google"; - -export type DohProvider = { - key: DohProviderKey; - buildUrl: (domain: string, type: DnsRecordType) => URL; - headers?: Record; -}; - -const DEFAULT_HEADERS: Record = { - accept: "application/dns-json", - "user-agent": USER_AGENT, -}; - -export const DOH_PROVIDERS: DohProvider[] = [ - { - key: "cloudflare", - buildUrl: (domain, type) => { - const u = new URL("https://cloudflare-dns.com/dns-query"); - u.searchParams.set("name", domain); - u.searchParams.set("type", type); - return u; - }, - headers: DEFAULT_HEADERS, - }, - { - key: "google", - buildUrl: (domain, type) => { - const u = new URL("https://dns.google/resolve"); - u.searchParams.set("name", domain); - u.searchParams.set("type", type); - return u; - }, - headers: DEFAULT_HEADERS, - }, -]; diff --git a/server/services/hosting.test.ts b/server/services/hosting.test.ts index 923efa2e..07bf4e16 100644 --- a/server/services/hosting.test.ts +++ b/server/services/hosting.test.ts @@ -89,7 +89,6 @@ describe("detectHosting", () => { expect(result.emailProvider.domain).toBe("google.com"); expect(result.dnsProvider.name).toBe("Cloudflare"); expect(result.dnsProvider.domain).toBe("cloudflare.com"); - expect(result.ipAddress).toBe("1.2.3.4"); expect(result.geo.country).toBe("US"); }); diff --git a/server/services/hosting.ts b/server/services/hosting.ts index 686eaf66..7dd61b24 100644 --- a/server/services/hosting.ts +++ b/server/services/hosting.ts @@ -28,12 +28,6 @@ export async function detectHosting(domain: string): Promise { () => [] as { name: string; value: string }[], ); - // Determine email provider, using "none" when MX is unset - const email = - mx.length === 0 - ? { name: "none", domain: null } - : detectEmailProvider(mx.map((m) => m.value)); - const meta = ip ? await lookupIpMeta(ip) : { @@ -53,6 +47,7 @@ export async function detectHosting(domain: string): Promise { // - If no A record/IP → unset → "none" // - Else if unknown → try IP ownership org/ISP const hosting = detectHostingProvider(headers); + let hostingName = hosting.name; let hostingIconDomain = hosting.domain; if (!ip) { @@ -63,8 +58,14 @@ export async function detectHosting(domain: string): Promise { hostingIconDomain = null; } + // Determine email provider, using "none" when MX is unset + const email = + mx.length === 0 + ? { name: "none", domain: null } + : detectEmailProvider(mx.map((m) => m.value)); let emailName = email.name; let emailIconDomain = email.domain; + // DNS provider from nameservers const dnsResult = detectDnsProvider(ns.map((n) => n.value)); let dnsName = dnsResult.name; @@ -92,7 +93,6 @@ export async function detectHosting(domain: string): Promise { hostingProvider: { name: hostingName, domain: hostingIconDomain }, emailProvider: { name: emailName, domain: emailIconDomain }, dnsProvider: { name: dnsName, domain: dnsIconDomain }, - ipAddress: ip, geo, }; await captureServer("hosting_detected", { @@ -108,5 +108,3 @@ export async function detectHosting(domain: string): Promise { schema, ); } - -// moved to ./ip diff --git a/server/services/registration.ts b/server/services/registration.ts index 81a11b09..5d89154c 100644 --- a/server/services/registration.ts +++ b/server/services/registration.ts @@ -1,7 +1,7 @@ import { lookupDomain } from "rdapper"; import { captureServer } from "@/lib/analytics/server"; import { toRegistrableDomain } from "@/lib/domain-server"; -import { resolveRegistrarDomain } from "@/lib/providers/detection"; +import { detectRegistrar } from "@/lib/providers/detection"; import { getOrSetZod, ns } from "@/lib/redis"; import { type Registration, RegistrationSchema } from "@/lib/schemas"; @@ -33,16 +33,17 @@ export async function getRegistration(domain: string): Promise { } const ttl = record.isRegistered ? 24 * 60 * 60 : 60 * 60; - const registrarName = (record.registrar?.name || "").toString(); - const registrarUrl = (record.registrar?.url || "").toString(); + let registrarName = (record.registrar?.name || "").toString(); let registrarDomain: string | null = null; try { - if (registrarUrl) { - registrarDomain = new URL(registrarUrl).hostname || null; + if (record.registrar?.url) { + registrarDomain = new URL(record.registrar.url).hostname || null; } } catch {} if (!registrarDomain) { - registrarDomain = resolveRegistrarDomain(registrarName); + const det = detectRegistrar(registrarName); + registrarName = det.name; + registrarDomain = det.domain; } const withProvider: Registration = {