@nurkamol/seo-audit 1.38.2 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -484,6 +484,85 @@ Rules with a `*` or a `:placeholder` match a shape rather than a URL, so asking
484
484
  for them literally proves nothing. They are counted and reported, never guessed
485
485
  at. A rule that works in one hop reports nothing at all.
486
486
 
487
+ ### The rest of the domain
488
+
489
+ Every check above audits the site it was pointed at. `--hosts` asks a different
490
+ question: what *else* is on this domain, and is any of it damaging the site that
491
+ was.
492
+
493
+ ```bash
494
+ node bin/seo-audit.mjs https://example.com --hosts
495
+ ```
496
+
497
+ Discovery is [certificate transparency](https://certificate.transparency.dev) —
498
+ the public log of every certificate ever issued, which is the only free, keyless
499
+ source of hostnames there is. Two logs are asked, certspotter first and crt.sh
500
+ second, because one is not dependable enough: asked five times in forty seconds,
501
+ crt.sh answered twice. Verification is DNS and this tool's own fetcher, and that
502
+ split is the whole design. The log is a *terrible* list of live hosts:
503
+ it holds 3,425 distinct names for one large domain and roughly three thousand of
504
+ them stopped existing years ago. So **nothing from the log is ever reported**. It
505
+ produces candidates; a lookup and a request decide which of them are facts.
506
+
507
+ Three things come out of it, and all three are invisible to a crawl of the site
508
+ itself:
509
+
510
+ - a **staging copy** nobody remembered to close — indexable, competing with
511
+ production for its own results, publishing whatever is being tested;
512
+ - a **subdomain whose CNAME points at a service that is gone**, which anybody
513
+ can claim and then serve from a host on your domain;
514
+ - a **second host serving the same site**, splitting every signal it earns.
515
+
516
+ Plus an inventory, printed whether or not anything was wrong with it, because
517
+ "what else is on this domain" is worth an answer on a healthy domain too:
518
+
519
+ ```
520
+ ── Hosts on example.com ──────────────────────────────────────
521
+ 214 hostnames in certificate transparency, 9 of them resolving, 174 not looked up
522
+
523
+ ✗ staging.example.com 198.51.100.9 200 Example — Staging
524
+ ✗ blog.example.com CNAME → old-tenant.wpengine.net (gone)
525
+ · shop.example.com 198.51.100.20 200 Example Shop
526
+ · www.example.com 198.51.100.1 301 → the canonical host
527
+ · preview.example.com 198.51.100.31 200, noindex
528
+
529
+ Nameservers ns1.cloudflare.com, ns2.cloudflare.com
530
+ Mail aspmx.l.google.com
531
+ Policies v=spf1 include:_spf.google.com ~all
532
+ ```
533
+
534
+ It is off by default and stays that way. These logs are free, unauthenticated
535
+ and rate-limited by IP, and gov.uk's 4,674 hostnames took 89 seconds to sweep —
536
+ a crawl should not quietly spend that. When no log answers, the report says
537
+ `hosts-not-checked` rather than showing a domain with nothing on it, for the same
538
+ reason `tls-not-checked` exists: a missing finding reads exactly like a passing
539
+ one. Across ten real domains that note fired twice, so it is not a rare branch.
540
+
541
+ The staging check is also silent on any host that does not answer for **its own
542
+ root**. Two real domains taught that one, in different disguises:
543
+ `dev.gtm.github.com` answers 307 to `/login`, and that login page answers 200
544
+ with HTML and no `noindex`; `dev.jquery.com` 301s to `bugs.jquery.com`, a
545
+ different sibling entirely. In both cases every other clause passed and a host
546
+ serving nothing was reported as a leaked copy of the site. One condition covers
547
+ both, and it needs no vocabulary of login paths — which would only ever be the
548
+ paths somebody thought of. The trade is a staging site redirecting `/` to `/en/`
549
+ that goes unreported, which is the right way round to be wrong.
550
+
551
+ It is **on by default in the macOS window and the Raycast extension, and off by
552
+ default on the command line**. That is deliberate rather than an inconsistency:
553
+ those two are watched by a person, where a few seconds buys a finding a crawl
554
+ cannot otherwise see, and `npx` is a build step, where the same seconds are spent
555
+ unasked and a third party gets called that nobody chose to call.
556
+
557
+ The inventory travels with every format the engine writes — terminal, Markdown,
558
+ HTML, JSON, the baseline, the macOS window and its PDF export. In CSV it arrives
559
+ as rows at level `host`, the same way passing and not-checked rows already do,
560
+ because a CSV holding two shapes is a CSV nothing can read in one go.
561
+
562
+ The window reaches it from **Settings → Crawl**, and the Raycast extension from
563
+ its preferences. The hosted deployment leaves it off unless `ALLOW_HOSTS` is set,
564
+ because there a stranger would be spending one shared address's allowance.
565
+
487
566
  ### Sites without a sitemap
488
567
 
489
568
  If no sitemap can be found, the crawl follows links from the homepage instead
@@ -536,6 +615,7 @@ reason.
536
615
  | `--reports [date]` | — | List the runs kept on this machine and stop. With a date, only those finished on or after it |
537
616
  | `--since <date>` | — | Crawl only URLs the sitemap says changed on or after this date. Refuses when `lastmod` cannot answer it |
538
617
  | `--exclude <glob>` | — | Leave URLs out of the crawl. Repeatable; `*` stops at a slash, `**` does not |
618
+ | `--hosts` | — | Also audit the rest of the domain: what other hosts exist, which resolve, and what they serve. Off by default — one slow third-party lookup plus one per candidate host |
539
619
  | `--dry-run` | — | Say what would be crawled and stop. A handful of requests instead of hundreds |
540
620
  | `--write-sitemap <file>` | — | Write the sitemap this site should have had. Refuses on a crawl that did not see the whole site |
541
621
  | `--write-llms <file>` | — | Write the `llms.txt` this site should have had, in the [llmstxt.org](https://llmstxt.org) format, from the site's own titles and descriptions. Nothing is generated or rewritten. Refuses on a partial crawl, for the same reason |
@@ -861,6 +941,10 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
861
941
  | The link and image sweeps say so when they stop at their cap rather than implying they checked everything | note |
862
942
  | Every `og:image` actually loads, and isn't too heavy to scrape | error / warning |
863
943
  | A declared `twitter:image` loads, when it differs from `og:image` — its *absence* is fine, X falls back to Open Graph | error |
944
+ | **A subdomain pointing at a service that is gone**, with `--hosts` — a CNAME whose target does not resolve. Anybody can register that name at the provider and serve from a host on your domain | error |
945
+ | **A staging copy open to the index**, with `--hosts` — a host whose leftmost label names an environment (`staging`, `dev`, `uat`, `qa`, `preview`, `sandbox`…), answering 200 with HTML. Silent on one that says `noindex`, disallows crawling in its own robots.txt, canonicalises to production, or redirects there. `beta.` and `demo.` are deliberately not environments: companies ship both | warning |
946
+ | **A second host serving the same site again**, with `--hosts` — bodies compared, not titles, and silent when its canonical points back at the canonical host | warning |
947
+ | The host sweep says when the certificate log did not answer, and when it stopped at its cap — never silence in either case | note |
864
948
 
865
949
  ---
866
950
 
package/action.yml CHANGED
@@ -47,6 +47,15 @@ inputs:
47
47
  people's servers rate-limit and bot-block; only a 404, a 410 or no answer
48
48
  at all is ever reported. Set to any non-empty value.
49
49
  required: false
50
+ hosts:
51
+ description: >
52
+ Also audit the rest of the domain. Asks certificate transparency what
53
+ other hosts exist, resolves them, and fetches the ones that are live —
54
+ a staging copy open to the index, a subdomain whose CNAME points at a
55
+ service that is gone, a second host serving the same site. Off by default
56
+ because it is one slow third-party lookup plus one per candidate host.
57
+ Set to any non-empty value.
58
+ required: false
50
59
  verbose:
51
60
  description: >
52
61
  Log every request as it happens. Useful when a run is slow or a site
@@ -158,6 +167,7 @@ runs:
158
167
  INPUT_PSI_SAMPLE: ${{ inputs.psi-sample }}
159
168
  INPUT_VERBOSE: ${{ inputs.verbose }}
160
169
  INPUT_CHECK_EXTERNAL: ${{ inputs.check-external }}
170
+ INPUT_HOSTS: ${{ inputs.hosts }}
161
171
  INPUT_SETTLE: ${{ inputs.settle }}
162
172
  INPUT_BROWSER: ${{ inputs.browser }}
163
173
  INPUT_OS: ${{ inputs.os }}
@@ -175,6 +185,7 @@ runs:
175
185
  [ -n "$INPUT_SEARCH_CONSOLE" ] && args+=(--search-console "$INPUT_SEARCH_CONSOLE")
176
186
  [ -n "$INPUT_VERBOSE" ] && args+=(--verbose)
177
187
  [ -n "$INPUT_CHECK_EXTERNAL" ] && args+=(--check-external)
188
+ [ -n "$INPUT_HOSTS" ] && args+=(--hosts)
178
189
  [ -n "$INPUT_SETTLE" ] && args+=(--settle "$INPUT_SETTLE")
179
190
  [ -n "$INPUT_BROWSER" ] && args+=(--browser "$INPUT_BROWSER")
180
191
  [ -n "$INPUT_OS" ] && args+=(--os "$INPUT_OS")
package/bin/seo-audit.mjs CHANGED
@@ -81,6 +81,14 @@ const HELP = `
81
81
  --check-external also check links pointing off the site. Off by default:
82
82
  other people's servers rate-limit and bot-block, so only
83
83
  a 404, a 410 or no answer at all is ever reported
84
+ --hosts also audit the rest of the domain. Asks certificate
85
+ transparency what other hosts exist, resolves them and
86
+ fetches the ones that are live: a staging copy open to
87
+ the index, a subdomain whose CNAME points at a service
88
+ that is gone, a second host serving the same site.
89
+ Off by default here and on by default in the window and
90
+ the Raycast extension — those are watched, this is a
91
+ build step, and it calls a third party either way
84
92
  --no-open with --serve, do not open a browser. It opens one when
85
93
  a person ran the command and never when something else
86
94
  did, so this is only for the person who wants neither
@@ -180,6 +188,7 @@ function parseArgs(argv) {
180
188
  else if (arg === '--sitemap') opts.sitemap = value();
181
189
  else if (arg === '--redirects') opts.redirects = value();
182
190
  else if (arg === '--check-external') opts.checkExternal = true;
191
+ else if (arg === '--hosts') opts.hosts = true;
183
192
  else if (arg === '--user-agent') opts.userAgent = value();
184
193
  else if (arg === '--serve') {
185
194
  // The port is optional: --serve on its own, or --serve 8080.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurkamol/seo-audit",
3
- "version": "1.38.2",
3
+ "version": "1.40.0",
4
4
  "description": "Crawl a site's sitemap and check every page for SEO, metadata and structured-data problems that single-page graders miss. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/areas.mjs CHANGED
@@ -127,6 +127,9 @@ const CATEGORY_OF = {
127
127
  // The note the hosted Worker leaves where the certificate checks would
128
128
  // have been. It belongs beside them, not in Other.
129
129
  'tls-expiring': 'Site & security',
130
+ 'subdomain-takeover': 'Site & security', 'staging-indexable': 'Site & security',
131
+ 'duplicate-host': 'Site & security', 'hosts-not-checked': 'Site & security',
132
+ 'host-sweep-capped': 'Site & security',
130
133
  'tls-expired': 'Site & security', 'url-uppercase': 'Site & security',
131
134
  'url-underscore': 'Site & security', 'url-space': 'Site & security',
132
135
  'header-strict-transport-security': 'Site & security',
package/src/audit.mjs CHANGED
@@ -4,7 +4,7 @@ import { bodyKind, parseHtml, parseSitemap } from './parse.mjs';
4
4
  import { parseRobots, robotsVerdict } from './robots.mjs';
5
5
  import { redirectChecks } from './redirects.mjs';
6
6
  import { pageChecks, crossPageChecks, sitemapChecks } from './checks.mjs';
7
- import { certificateExpiry, siteChecks } from './site.mjs';
7
+ import { certificateExpiry, siteChecks, hostChecks } from './site.mjs';
8
8
  import { linkGraph } from './graph.mjs';
9
9
  import { compareAgents } from './compare.mjs';
10
10
  import { scoreRun } from './score.mjs';
@@ -482,6 +482,18 @@ export async function audit(target, opts = {}) {
482
482
  ...(await siteChecks(origin, fetcher, pages, { ...opts, sitemapUrls: urls, bySitemap })),
483
483
  );
484
484
 
485
+ // What else is on this domain — a leaked staging copy, a subdomain whose
486
+ // CNAME points at a service that is gone, a second host serving the same
487
+ // site. Only when asked: it is a slow third-party lookup plus one per
488
+ // candidate host, and a crawl should not quietly spend that.
489
+ let hosts = null;
490
+ if (opts.hosts) {
491
+ onProgress?.({ phase: 'hosts', detail: 'asking certificate transparency what else is on this domain' });
492
+ const swept = await hostChecks(origin, fetcher, { ...opts, onProgress });
493
+ findings.push(...swept.findings);
494
+ hosts = swept.hosts;
495
+ }
496
+
485
497
  // A migration's redirect map, checked against the live site. Only when one
486
498
  // is handed over: there is nothing to infer here, and guessing at old URLs
487
499
  // would invent findings.
@@ -669,6 +681,11 @@ export async function audit(target, opts = {}) {
669
681
  redirects: Boolean(opts.redirects),
670
682
  external: Boolean(opts.checkExternal),
671
683
  compareAs: Boolean(opts.compareAs),
684
+ // Asked for and answered are different things. A run with --hosts whose
685
+ // certificate transparency lookup failed has not checked these, and
686
+ // `hosts-not-checked` says so in the report; scoring them as passed would
687
+ // hand out credit for a lookup that never happened.
688
+ hosts: Boolean(opts.hosts) && hosts !== null,
672
689
  };
673
690
 
674
691
  // The llms.txt this site should have had, from the same crawl and by the
@@ -704,6 +721,10 @@ export async function audit(target, opts = {}) {
704
721
  ms: Date.now() - started,
705
722
  date: new Date().toISOString().slice(0, 10),
706
723
  sitemap: source,
724
+ // The inventory behind the host findings. Absent unless --hosts asked
725
+ // for it, so a report that does not mention other hosts is one that was
726
+ // never asked to look rather than one that looked and found none.
727
+ ...(hosts ? { hosts } : {}),
707
728
  },
708
729
  };
709
730
  }
package/src/dns.mjs ADDED
@@ -0,0 +1,215 @@
1
+ // DNS, asked over HTTPS, because that is the only resolver every runtime this
2
+ // engine runs in actually has.
3
+ //
4
+ // `node:dns` is the obvious choice and it is the wrong one. The Workers runtime
5
+ // has no such module, so anything built on it would quietly vanish from every
6
+ // hosted report — and a missing finding reads exactly like a passing one, which
7
+ // is the failure this project exists to refuse. `tls-expiring` already pays
8
+ // that price and has to apologise for it in the report with `tls-not-checked`.
9
+ // DNS does not have to: DoH is a `fetch` and a JSON body, so the CLI, the
10
+ // Worker and both desktop shells ask the same resolver the same question.
11
+ //
12
+ // Cloudflare's resolver rather than Google's, and not as a preference:
13
+ // dns.google refused a plain fetch outright while cloudflare-dns.com answered
14
+ // the same query, and a lookup that works everywhere beats one that works in a
15
+ // datacentre. `resolver` is a parameter so a network that blocks it can point
16
+ // somewhere else, and so the tests never touch the internet.
17
+
18
+ /** Cloudflare's DoH endpoint, in its JSON dialect. */
19
+ export const RESOLVER = 'https://cloudflare-dns.com/dns-query';
20
+
21
+ /** The certificate transparency searches this asks, in the order it asks them.
22
+ *
23
+ * Ordered by measured reliability, not by preference. crt.sh is the one
24
+ * everybody names and it is not dependable enough to be the only one: asked
25
+ * five times in forty seconds it answered twice — one 502, two successes at
26
+ * 6.9s and 20.9s, and two timeouts at thirty. certspotter answered the same
27
+ * question in 1.8 seconds. Both are free and neither needs a key, so there is
28
+ * no reason to depend on one.
29
+ *
30
+ * First answer wins rather than merging the two. The names feed a sweep that
31
+ * is capped at forty anyway, so a second source would double the latency to
32
+ * refine a list that gets truncated — and the whole point of the ordering is
33
+ * that the fast one usually answers.
34
+ */
35
+ export const CERTIFICATE_SOURCES = [
36
+ {
37
+ name: 'certspotter',
38
+ url: (domain) =>
39
+ 'https://api.certspotter.com/v1/issuances' +
40
+ `?domain=${encodeURIComponent(domain)}&include_subdomains=true&expand=dns_names`,
41
+ // One issuance, many names, already an array.
42
+ names: (rows) => (Array.isArray(rows) ? rows.flatMap((row) => row?.dns_names ?? []) : null),
43
+ },
44
+ {
45
+ name: 'crt.sh',
46
+ url: (domain) => `https://crt.sh/?q=${encodeURIComponent(`%.${domain}`)}&output=json`,
47
+ // One certificate covers many names, and crt.sh packs them into one
48
+ // newline-separated string rather than an array.
49
+ names: (rows) =>
50
+ Array.isArray(rows) ? rows.flatMap((row) => String(row?.name_value ?? '').split('\n')) : null,
51
+ },
52
+ ];
53
+
54
+ /** One source, asked. `null` means it did not answer. */
55
+ async function askLog(source, domain, { timeout, fetchImpl }) {
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), timeout);
58
+ try {
59
+ const res = await fetchImpl(source.url(domain), {
60
+ headers: { accept: 'application/json' },
61
+ signal: controller.signal,
62
+ });
63
+ if (!res.ok) return null;
64
+ return source.names(await res.json());
65
+ } catch {
66
+ return null;
67
+ } finally {
68
+ clearTimeout(timer);
69
+ }
70
+ }
71
+
72
+ /** The record types this asks for, by their wire numbers, because that is what
73
+ * a DoH answer is labelled with. */
74
+ const TYPE = { A: 1, NS: 2, CNAME: 5, MX: 15, TXT: 16, AAAA: 28 };
75
+
76
+ /** NXDOMAIN. The one DNS response code this file treats as load-bearing: it is
77
+ * the difference between "that name is not there" and "I could not tell you",
78
+ * and a dangling-CNAME finding that confused the two would be a false positive
79
+ * aimed at somebody's live site. */
80
+ export const NXDOMAIN = 3;
81
+
82
+ /**
83
+ * One DNS question, answered or honestly not.
84
+ *
85
+ * `ok: false` means the lookup did not happen — the resolver was unreachable,
86
+ * slow, or answered something that was not JSON. It is deliberately distinct
87
+ * from an empty `records`, which means the resolver answered and there is
88
+ * nothing there. Callers must not read the second as the first.
89
+ *
90
+ * @returns {Promise<{ok: boolean, status: number|null, records: string[], cname: string[]}>}
91
+ */
92
+ export async function resolve(name, type, opts = {}) {
93
+ const { resolver = RESOLVER, timeout = 5000, fetchImpl = fetch } = opts;
94
+ const empty = { ok: false, status: null, records: [], cname: [] };
95
+ if (!TYPE[type]) return empty;
96
+
97
+ const controller = new AbortController();
98
+ const timer = setTimeout(() => controller.abort(), timeout);
99
+ try {
100
+ const res = await fetchImpl(`${resolver}?name=${encodeURIComponent(name)}&type=${type}`, {
101
+ headers: { accept: 'application/dns-json' },
102
+ signal: controller.signal,
103
+ });
104
+ if (!res.ok) return empty;
105
+ const body = await res.json();
106
+ const answers = Array.isArray(body?.Answer) ? body.Answer : [];
107
+ // A trailing dot is how DNS writes a fully qualified name and is noise
108
+ // everywhere else in this codebase, so it comes off here rather than at
109
+ // each of the six places that would otherwise have to remember.
110
+ const data = (rrType) =>
111
+ answers.filter((a) => a.type === rrType).map((a) => String(a.data ?? '').replace(/\.$/, ''));
112
+ return {
113
+ ok: true,
114
+ status: typeof body?.Status === 'number' ? body.Status : null,
115
+ records: data(TYPE[type]),
116
+ // Asking for A on a name that is an alias answers with the CNAME *and*
117
+ // the addresses it resolves through to. Both are worth having and one
118
+ // question is cheaper than two, so the alias is always carried back.
119
+ cname: data(TYPE.CNAME),
120
+ };
121
+ } catch {
122
+ return empty;
123
+ } finally {
124
+ clearTimeout(timer);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Every hostname certificate transparency has ever seen for a domain, and which
130
+ * log said so.
131
+ *
132
+ * `null` means no source answered, which is not the same as a domain with no
133
+ * certificates and must never be reported as one. That is not a rare branch:
134
+ * crt.sh alone failed three times in five in testing, which is the reason there
135
+ * is more than one source here.
136
+ *
137
+ * The source is carried back because the report says which log answered. Two
138
+ * runs of one domain can legitimately list different hosts if different logs
139
+ * answered them, and a reader comparing those two runs is owed the reason.
140
+ *
141
+ * @returns {Promise<{source: string, names: string[]}|null>}
142
+ */
143
+ export async function certificateNames(domain, opts = {}) {
144
+ const { sources = CERTIFICATE_SOURCES, timeout = 25000, fetchImpl = fetch } = opts;
145
+
146
+ for (const source of sources) {
147
+ const raw = await askLog(source, domain, { timeout, fetchImpl });
148
+ if (!raw) continue;
149
+
150
+ const names = new Set();
151
+ for (const entry of raw) {
152
+ const name = String(entry ?? '').trim().toLowerCase().replace(/\.$/, '');
153
+ // A wildcard is not a host. `*.example.com` says a certificate covers any
154
+ // name at that level, not that any particular one exists, and resolving
155
+ // it asks a question with no answer.
156
+ if (!name || name.startsWith('*')) continue;
157
+ if (name === domain || name.endsWith(`.${domain}`)) names.add(name);
158
+ }
159
+ return { source: source.name, names: [...names] };
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /** Hostnames that are a fleet rather than a service, collapsed.
165
+ *
166
+ * Certificate transparency is a log of every certificate ever issued, which
167
+ * makes it the best free source of hostnames and a terrible list of live ones.
168
+ * cloudflare.com's log holds 3,425 distinct names and roughly three thousand
169
+ * of them are `ssl2081.cloudflare.com` and its numbered siblings, none of
170
+ * which have existed for years.
171
+ *
172
+ * So names are grouped by *shape* — every run of digits becomes `#` — and at
173
+ * most `perShape` survive each one. That empties the fleet without a
174
+ * hand-written blocklist of somebody's naming convention, and it keeps
175
+ * `staging.` and `staging2.` because those are two shapes and both are worth
176
+ * a look, while `ssl2081.` and three thousand others become two. */
177
+ export function collapseFleets(names, { perShape = 2 } = {}) {
178
+ const seen = new Map();
179
+ const out = [];
180
+ for (const name of names) {
181
+ const shape = name.replace(/\d+/g, '#');
182
+ const count = seen.get(shape) ?? 0;
183
+ if (count >= perShape) continue;
184
+ seen.set(shape, count + 1);
185
+ out.push(name);
186
+ }
187
+ return out;
188
+ }
189
+
190
+ /** The leftmost labels that mean "this was never meant to be public".
191
+ *
192
+ * Kept deliberately tight. `beta.` and `demo.` are missing on purpose: plenty
193
+ * of companies run both as products, and a check that reports somebody's
194
+ * shipped beta as a leak is the kind of false positive that gets a whole
195
+ * report closed. Everything here names an environment, not a product. */
196
+ export const STAGING_LABEL =
197
+ /^(staging|stage|staging-\w+|dev|develop|development|test|testing|tst|uat|qa|preview|sandbox|wip|scratch)\d*$/;
198
+
199
+ /** Whether a hostname's leftmost label names a non-production environment. */
200
+ export const looksLikeStaging = (host) => STAGING_LABEL.test(host.split('.')[0]);
201
+
202
+ /** Candidates worth spending a lookup on, best first.
203
+ *
204
+ * There is a cap, so the order is the difference between finding a leaked
205
+ * staging host and finding two hundred names beginning with `a`. Environment
206
+ * names first because they are what the checks are for; then the short names,
207
+ * because a service a company runs on purpose is usually called something
208
+ * short, and the long ones are usually machines. */
209
+ export function rankHosts(names, apex) {
210
+ const depth = (n) => n.split('.').length;
211
+ const score = (n) => (looksLikeStaging(n) ? 0 : 1);
212
+ return [...names]
213
+ .filter((n) => n !== apex && n !== `www.${apex}`)
214
+ .sort((a, b) => score(a) - score(b) || depth(a) - depth(b) || a.length - b.length || a.localeCompare(b));
215
+ }
package/src/http.mjs CHANGED
@@ -81,7 +81,7 @@ export class Fetcher {
81
81
  * @returns {Promise<{url: string, status: number, ok: boolean, headers: Headers,
82
82
  * body: string, location: string|null, ms: number, error?: string}>}
83
83
  */
84
- async get(url, { method = 'GET', retries = 2 } = {}) {
84
+ async get(url, { method = 'GET', retries = 2, keepBody = true } = {}) {
85
85
  const key = `${method} ${url}`;
86
86
  if (this.cache.has(key)) return this.cache.get(key);
87
87
 
@@ -171,7 +171,24 @@ export class Fetcher {
171
171
  return last;
172
172
  });
173
173
 
174
- this.cache.set(key, promise);
174
+ // The caller is handed the whole response; the cache may keep less.
175
+ //
176
+ // The cache is here so a URL is never fetched twice across checks, and for
177
+ // most of what a run fetches that means remembering a status and a
178
+ // content-type — the link sweep, the image sweep and the social-image
179
+ // sweeps all judge on those alone. Keeping every body as well made the
180
+ // cache the largest live object in the process: 403 requests on one 25-page
181
+ // site held 65MB, of which 18MB was still reachable once the run let go.
182
+ // That is under Node's default heap and over Raycast's, whose commands get
183
+ // 100MB and were dying on sites this tool audits comfortably from a
184
+ // terminal.
185
+ //
186
+ // So a sweep asks for `keepBody: false` and the body is dropped on the way
187
+ // into the cache, never on the way out — whoever fetched it reads it in
188
+ // full, and only a *second* reader of the same URL sees an empty body. The
189
+ // page crawl, robots.txt, the home page and the host checks all keep
190
+ // theirs, because something does read those twice.
191
+ this.cache.set(key, keepBody ? promise : promise.then((res) => ({ ...res, body: '' })));
175
192
  return promise;
176
193
  }
177
194
 
@@ -200,16 +217,16 @@ export class Fetcher {
200
217
  }
201
218
 
202
219
  /** Follow a chain by hand so the number of hops can be reported. */
203
- async chain(url, max = 5) {
220
+ async chain(url, max = 5, { keepBody = true } = {}) {
204
221
  const hops = [];
205
222
  let current = url;
206
223
  for (let i = 0; i < max; i++) {
207
- const res = await this.get(current);
224
+ const res = await this.get(current, { keepBody });
208
225
  hops.push({ url: current, status: res.status });
209
226
  if (res.status < 300 || res.status >= 400 || !res.location) return { hops, final: res };
210
227
  current = new URL(res.location, current).toString();
211
228
  }
212
- return { hops, final: await this.get(current) };
229
+ return { hops, final: await this.get(current, { keepBody }) };
213
230
  }
214
231
  }
215
232
 
package/src/options.mjs CHANGED
@@ -65,6 +65,10 @@ export const OPTIONS = [
65
65
  { flag: '--psi-strategy', query: 'psi-strategy', app: true, via: 'Settings → Performance',
66
66
  field: { type: 'select', label: 'Measured as', needs: 'ALLOW_PSI',
67
67
  choices: [['', 'A phone — what Google indexes with'], ['desktop', 'A desktop']] } },
68
+ { flag: '--hosts', query: 'hosts', app: true, via: 'Settings → Crawl',
69
+ field: { type: 'checkbox', label: 'Audit the rest of the domain too', value: '1',
70
+ needs: 'ALLOW_HOSTS', checked: true,
71
+ help: 'Asks certificate transparency what other hosts exist, then resolves and fetches them. Adds a few seconds, and more on a domain with hundreds of hosts.' } },
68
72
  { flag: '--since', query: null, app: 'not yet — it needs a date picker and a sense of when the last run was, which the window has in the library and does not offer yet' },
69
73
  { flag: '--exclude', query: null, app: 'not yet — a list of patterns needs somewhere to live in Settings, and one text field would be worse than nothing' },
70
74
 
package/src/report.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { byCause, causeScope, sectionOf } from './causes.mjs';
5
5
  import { CATEGORIES, categoryOf } from './areas.mjs';
6
+ import { plural } from './text.mjs';
6
7
 
7
8
  const COLOR = process.env.NO_COLOR === undefined && process.stdout.isTTY;
8
9
  const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
@@ -122,6 +123,9 @@ export function terminal(findings, meta, { score } = {}) {
122
123
  if (!findings.length) {
123
124
  lines.push(` ${c('32', '✓')} nothing to report`);
124
125
  lines.push('');
126
+ // The inventory is not a finding and does not belong to the list above, so
127
+ // a clean site that was asked about its domain still gets the answer.
128
+ lines.push(...hostBlock(findings, meta, rule));
125
129
  if (score?.passed?.length) lines.push(...passingBlock(score, rule));
126
130
  return lines.join('\n');
127
131
  }
@@ -189,6 +193,7 @@ export function terminal(findings, meta, { score } = {}) {
189
193
  lines.push(...listing(notes));
190
194
  }
191
195
 
196
+ lines.push(...hostBlock(findings, meta, rule));
192
197
  lines.push(...passingBlock(score, rule));
193
198
 
194
199
  lines.push(
@@ -198,6 +203,88 @@ export function terminal(findings, meta, { score } = {}) {
198
203
  return lines.join('\n');
199
204
  }
200
205
 
206
+ /** The hosts a finding is actually about, so the inventory can mark them.
207
+ *
208
+ * Read back out of the findings rather than recomputed, because a second
209
+ * arithmetic over the same facts is a second chance to disagree with the
210
+ * report printed six lines above it. */
211
+ export function flaggedHosts(findings) {
212
+ const out = new Map();
213
+ for (const finding of findings) {
214
+ if (!/^(subdomain-takeover|staging-indexable|duplicate-host)$/.test(finding.id)) continue;
215
+ try {
216
+ const host = new URL(finding.url).hostname;
217
+ // An error outranks a warning on the same host, so a row that is both a
218
+ // takeover and a duplicate is coloured by the worse of the two.
219
+ if (finding.level === 'error' || !out.has(host)) out.set(host, finding.level);
220
+ } catch { /* a finding without a parseable URL marks nothing */ }
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /** What a host row says about itself, in one column.
226
+ *
227
+ * A dangling alias is named rather than reduced to a status, because the name
228
+ * it points at is the thing somebody has to go and delete. */
229
+ export function hostSummary(row) {
230
+ if (row.dangling) return `CNAME → ${row.cname} (gone)`;
231
+ if (!row.addresses.length) return row.cname ? `CNAME → ${row.cname}` : 'does not resolve';
232
+ if (!row.checked) return 'not fetched';
233
+ if (row.redirectsHome) return `${row.status} → the canonical host`;
234
+ // Where a request for this host's root actually ended up, when it was not
235
+ // this host's root — another sibling, or a login page. It is the reason the
236
+ // staging check stayed quiet, so it is said rather than left to be guessed.
237
+ if (row.landed && row.landed !== row.host) return `${row.status} → ${row.landed}`;
238
+ if (row.landedPath && row.landedPath !== '/') return `${row.status} → ${row.landedPath}`;
239
+ if (row.noindex) return `${row.status}, noindex`;
240
+ if (row.status) return `${row.status}${row.title ? ` ${row.title}` : ''}`;
241
+ return 'no answer';
242
+ }
243
+
244
+ /** The rest of the domain, as a table.
245
+ *
246
+ * Printed whether or not anything was wrong with it. "What else is on this
247
+ * domain" is worth an answer on a healthy domain too, and it is the answer
248
+ * that lets somebody check the findings above rather than take them. */
249
+ function hostBlock(findings, meta, rule) {
250
+ const inventory = meta?.hosts;
251
+ if (!inventory?.rows?.length) return [];
252
+ const flagged = flaggedHosts(findings);
253
+ const out = [];
254
+
255
+ out.push(rule(`Hosts on ${inventory.apex}`));
256
+ out.push(
257
+ dim(
258
+ ` ${plural(inventory.found, 'hostname')} in certificate transparency` +
259
+ (inventory.source ? ` (${inventory.source})` : '') +
260
+ `, ${inventory.resolved} of them resolving` +
261
+ (inventory.capped ? `, ${inventory.capped} not looked up` : ''),
262
+ ),
263
+ );
264
+ out.push('');
265
+
266
+ const width = Math.min(38, Math.max(...inventory.rows.map((r) => r.host.length)));
267
+ for (const row of inventory.rows) {
268
+ const mark = flagged.has(row.host) ? red('✗') : dim('·');
269
+ const address = row.addresses[0] ?? '';
270
+ out.push(
271
+ ` ${mark} ${row.host.padEnd(width)} ${dim(address.padEnd(15))} ${hostSummary(row)}`,
272
+ );
273
+ }
274
+ out.push('');
275
+
276
+ const facts = [
277
+ ['Nameservers', inventory.nameservers],
278
+ ['Mail', inventory.mail],
279
+ ['Policies', inventory.policies],
280
+ ];
281
+ for (const [label, values] of facts) {
282
+ if (values?.length) out.push(dim(` ${label.padEnd(12)} ${values.slice(0, 4).join(', ')}`));
283
+ }
284
+ out.push('');
285
+ return out;
286
+ }
287
+
201
288
  /** What passed, and what never came up.
202
289
  *
203
290
  * A report that only ever lists faults reads as a list of everything the tool
@@ -320,6 +407,7 @@ export function markdown(findings, meta, { score } = {}) {
320
407
  out.push(...listing(notes));
321
408
  }
322
409
 
410
+ out.push(...hostMarkdown(findings, meta));
323
411
  out.push(...passingMarkdown(score));
324
412
 
325
413
  out.push('---');
@@ -333,6 +421,48 @@ export function markdown(findings, meta, { score } = {}) {
333
421
  return out.join('\n');
334
422
  }
335
423
 
424
+ /** The rest of the domain, in a file somebody sends to a client.
425
+ *
426
+ * A table rather than prose, because the useful thing to do with it is read
427
+ * down the first column and recognise a name nobody meant to leave running. */
428
+ function hostMarkdown(findings, meta) {
429
+ const inventory = meta?.hosts;
430
+ if (!inventory?.rows?.length) return [];
431
+ const flagged = flaggedHosts(findings);
432
+ const out = [];
433
+
434
+ out.push(`## Hosts on ${inventory.apex}`);
435
+ out.push('');
436
+ out.push(
437
+ `${plural(inventory.found, 'hostname')} in certificate transparency` +
438
+ (inventory.source ? ` (${inventory.source})` : '') +
439
+ `, ${inventory.resolved} of them resolving` +
440
+ (inventory.capped ? `, ${inventory.capped} not looked up` : '') +
441
+ '. Discovered from the log and then verified — every row below was resolved, and every row ' +
442
+ 'with a status was fetched.',
443
+ );
444
+ out.push('');
445
+ out.push('| Host | Address | What it serves |');
446
+ out.push('|---|---|---|');
447
+ for (const row of inventory.rows) {
448
+ const name = flagged.has(row.host) ? `**${row.host}**` : row.host;
449
+ out.push(`| ${name} | ${row.addresses[0] ?? '—'} | ${hostSummary(row)} |`);
450
+ }
451
+ out.push('');
452
+
453
+ const facts = [
454
+ ['Nameservers', inventory.nameservers],
455
+ ['Mail', inventory.mail],
456
+ ['Policies', inventory.policies],
457
+ ];
458
+ const shown = facts.filter(([, values]) => values?.length);
459
+ if (shown.length) {
460
+ for (const [label, values] of shown) out.push(`**${label}** — ${values.slice(0, 4).join(', ')}`);
461
+ out.push('');
462
+ }
463
+ return out;
464
+ }
465
+
336
466
  /** The score, in a file somebody sends to a client. */
337
467
  function scoreMarkdown(score) {
338
468
  if (score?.score === null || score?.score === undefined) {
@@ -731,6 +861,20 @@ export function csv(findings, meta, { score } = {}) {
731
861
  rows.push(['not-checked', check.id, check.pass, '', '', 'yes', '', '', '', '', check.why, '']);
732
862
  }
733
863
 
864
+ // The rest of the domain, when the run was asked to look. A new level rather
865
+ // than a second table with its own header: a CSV holding two shapes is a CSV
866
+ // that no spreadsheet and no script can read in one go, and this file already
867
+ // answers "what else did the run see" the same way for passed and skipped
868
+ // checks. Filtering on error/warn/info is still untouched.
869
+ for (const row of meta?.hosts?.rows ?? []) {
870
+ rows.push([
871
+ 'host', 'host', row.host, `https://${row.host}/`, '',
872
+ row.noindex ? 'no' : 'yes', '', '', '', '',
873
+ [row.addresses.join(' '), hostSummary(row)].filter(Boolean).join(' · '),
874
+ '',
875
+ ]);
876
+ }
877
+
734
878
  return `\uFEFF${[columns, ...rows].map((row) => row.map(cell).join(',')).join('\r\n')}\r\n`;
735
879
  }
736
880
 
@@ -836,6 +980,53 @@ export function reportParts(findings, meta, { backHref, backLabel = 'New audit',
836
980
  }`;
837
981
  };
838
982
 
983
+ // --- The rest of the domain ---------------------------------------------
984
+ // A list rather than the findings table, because these rows are not findings:
985
+ // most of them are a healthy domain going about its business, and rendering
986
+ // them in the table that means "something is wrong" would make a mail server
987
+ // look like a problem. The two or three that *are* findings borrow the pill
988
+ // and the colour from the section above, so the eye connects them.
989
+ const hostTable = () => {
990
+ const inventory = meta?.hosts;
991
+ if (!inventory?.rows?.length) return '';
992
+ const flagged = flaggedHosts(findings);
993
+ const facts = [
994
+ ['Nameservers', inventory.nameservers],
995
+ ['Mail', inventory.mail],
996
+ ['SPF and DMARC', inventory.policies],
997
+ ].filter(([, values]) => values?.length);
998
+
999
+ const row = (host) => {
1000
+ const level = flagged.get(host.host);
1001
+ const address = host.addresses[0] ?? '';
1002
+ return `<li class="${level ?? 'quiet'}">
1003
+ <span class="pill ${level ?? 'none'}">${level ? LABEL[level] : '·'}</span>
1004
+ <b>${esc(host.host)}</b>
1005
+ <span class="addr">${esc(address)}</span>
1006
+ <span class="detail">${esc(hostSummary(host))}</span>
1007
+ </li>`;
1008
+ };
1009
+
1010
+ return `
1011
+ <h2 id="hosts"><span>Hosts on ${esc(inventory.apex)}</span><span class="rule"></span><span class="tick">${inventory.rows.length}</span></h2>
1012
+ <p class="lede">${plural(inventory.found, 'hostname')} in certificate transparency${
1013
+ inventory.source ? ` (${esc(inventory.source)})` : ''
1014
+ }, ${inventory.resolved} of them resolving${
1015
+ inventory.capped ? `, ${inventory.capped} not looked up` : ''
1016
+ }. The log is only where the names came from — every row below was resolved, and every row with a status was fetched.</p>
1017
+ <ul class="hosts">${inventory.rows.map(row).join('')}</ul>
1018
+ ${
1019
+ facts.length
1020
+ ? `<ul class="dnsfacts">${facts
1021
+ .map(
1022
+ ([label, values]) =>
1023
+ `<li><b>${esc(label)}</b><span>${values.slice(0, 4).map((v) => `<code>${esc(v)}</code>`).join(' ')}</span></li>`,
1024
+ )
1025
+ .join('')}</ul>`
1026
+ : ''
1027
+ }`;
1028
+ };
1029
+
839
1030
  const section = ({ name, entries: list }) => {
840
1031
  if (!list.length) return '';
841
1032
  const slug = name.toLowerCase().replace(/[^a-z]+/g, '-');
@@ -1046,6 +1237,47 @@ export function reportParts(findings, meta, { backHref, backLabel = 'New audit',
1046
1237
  .skipped b { font-weight: 500; font-size: .88rem; }
1047
1238
  .skipped code { font: 400 11.5px/1.7 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; color: var(--faint); margin-right: .45rem; }
1048
1239
 
1240
+ /* --- Hosts ---------------------------------------------------------- */
1241
+ /* One row per host, on a grid so the names, the addresses and what each one
1242
+ serves line up into three columns somebody can read down. It collapses to
1243
+ a stack on a phone rather than scrolling sideways: a hostname is the thing
1244
+ being looked for and it must never be the column that goes off the edge. */
1245
+ .hosts { list-style: none; margin: 0 0 1.6rem; padding: 0; display: grid; gap: .4rem; }
1246
+ .hosts li {
1247
+ display: grid; align-items: baseline; gap: .3rem .75rem;
1248
+ grid-template-columns: 5.25rem minmax(0, 15rem) minmax(0, 9.5rem) minmax(0, 1fr);
1249
+ padding: .6rem .85rem; border: 1px solid var(--line); border-radius: 8px;
1250
+ background: var(--panel);
1251
+ }
1252
+ .hosts li.error { border-color: var(--error); background: var(--error-bg); }
1253
+ .hosts li.warn { border-color: var(--warn); background: var(--warn-bg); }
1254
+ .hosts b { font-weight: 550; font-size: .9rem; word-break: break-all; }
1255
+ .hosts .addr, .hosts .detail {
1256
+ font: 400 12px/1.6 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
1257
+ color: var(--muted);
1258
+ }
1259
+ .hosts .addr { color: var(--faint); font-variant-numeric: tabular-nums; }
1260
+ .hosts .pill.none {
1261
+ background: transparent; border-color: transparent;
1262
+ color: var(--faint); padding-left: 0; padding-right: 0;
1263
+ }
1264
+ .dnsfacts { list-style: none; margin: 0 0 2.5rem; padding: 0; display: grid; gap: .45rem; }
1265
+ .dnsfacts li { display: grid; grid-template-columns: minmax(7.5rem, auto) 1fr; gap: .3rem .75rem; align-items: baseline; }
1266
+ .dnsfacts b {
1267
+ font-size: .715rem; font-weight: 600; text-transform: uppercase;
1268
+ letter-spacing: .075em; color: var(--muted);
1269
+ }
1270
+ .dnsfacts code {
1271
+ font: 400 11.5px/1.7 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
1272
+ color: var(--faint); word-break: break-all; margin-right: .5rem;
1273
+ }
1274
+ @media (max-width: 640px) {
1275
+ .hosts li { grid-template-columns: 5.25rem minmax(0, 1fr); }
1276
+ .hosts .addr { grid-column: 2; }
1277
+ .hosts .detail { grid-column: 1 / -1; }
1278
+ .dnsfacts li { grid-template-columns: 1fr; }
1279
+ }
1280
+
1049
1281
  /* --- Tally ---------------------------------------------------------- */
1050
1282
  .tally { display: grid; grid-template-columns: repeat(3, 1fr); gap: .7rem; margin: 0 0 3rem; }
1051
1283
  .tally div {
@@ -1248,6 +1480,8 @@ export function reportParts(findings, meta, { backHref, backLabel = 'New audit',
1248
1480
  : `<div class="clean"><b>Nothing to report</b><span>Every check passed on all ${meta.pages ?? 0} pages.</span></div>`
1249
1481
  }
1250
1482
 
1483
+ ${hostTable()}
1484
+
1251
1485
  ${passing()}
1252
1486
 
1253
1487
  <footer>
package/src/score.mjs CHANGED
@@ -193,6 +193,12 @@ const CHECKLIST = {
193
193
  'tls-expired': { worst: 'error', scope: 'site', needs: 'tls', pass: 'The TLS certificate is valid' },
194
194
  'tls-expiring': { worst: 'warn', scope: 'site', needs: 'tls', pass: 'The TLS certificate is not about to expire' },
195
195
  'header-strict-transport-security': { worst: 'warn', scope: 'site', needs: 'https', pass: 'HSTS is set' },
196
+ // The domain rather than the site. All three need --hosts, and all three are
197
+ // one yes or no for the whole domain rather than a share of the crawl: a
198
+ // leaked staging copy is not more wrong because the site has more pages.
199
+ 'subdomain-takeover': { worst: 'error', scope: 'site', needs: 'hosts', pass: 'No subdomain points at a service that is gone' },
200
+ 'staging-indexable': { worst: 'warn', scope: 'site', needs: 'hosts', pass: 'No staging copy is open to the index' },
201
+ 'duplicate-host': { worst: 'warn', scope: 'site', needs: 'hosts', pass: 'No other host serves the same site again' },
196
202
  'url-uppercase': { worst: 'warn', scope: 'page', pass: 'No URL has uppercase in its path' },
197
203
  'url-space': { worst: 'warn', scope: 'page', pass: 'No URL has a space in it' },
198
204
  uncompressed: { worst: 'warn', scope: 'page', pass: 'HTML arrives compressed' },
@@ -343,6 +349,8 @@ export function scoreRun(findings, { pages = 0, applicable = {} } = {}) {
343
349
  area: categoryOf(r.id),
344
350
  pass: r.check.pass,
345
351
  why: WHY_SKIPPED[r.check.needs] ?? 'Not applicable to this run.',
352
+ // Absent when nothing can be pressed, which is most of them.
353
+ ...(ENABLED_BY[r.check.needs] ? { enabledBy: ENABLED_BY[r.check.needs] } : {}),
346
354
  }));
347
355
 
348
356
  return {
@@ -385,6 +393,31 @@ const WHY_SKIPPED = {
385
393
  ogImage: 'No page declares an og:image.',
386
394
  twitterImage: 'No page declares a twitter:image of its own.',
387
395
  compareAs: 'The pages were not fetched a second time — run with --compare-as.',
396
+ hosts: 'The rest of the domain was not enumerated — run with --hosts.',
397
+ };
398
+
399
+ /** The option that would have let a skipped check run, where one exists.
400
+ *
401
+ * A skip has two very different causes and only one of them is anybody's to
402
+ * fix. "No page declares hreflang" is a fact about the site — there is nothing
403
+ * to press. "Outbound links were not checked" is a run that was not asked to,
404
+ * and asking is one flag away.
405
+ *
406
+ * Saying which is which here rather than in each front end, because the engine
407
+ * is what decided the check was skipped and a client re-deriving that from the
408
+ * prose in WHY_SKIPPED would be parsing an English sentence for a flag name.
409
+ * A front end that can offer the re-run offers it; one that cannot ignores
410
+ * this, and nothing about the report changes.
411
+ *
412
+ * Only the reasons a *run* controls appear. `redirects` and `compareAs` are
413
+ * deliberately absent even though both are flags: one needs a file that only
414
+ * the person who did the migration has, and the other needs a second identity
415
+ * to fetch as. Offering a button that cannot be pressed without an argument
416
+ * is worse than offering nothing. */
417
+ export const ENABLED_BY = {
418
+ external: '--check-external',
419
+ hosts: '--hosts',
420
+ psi: '--psi',
388
421
  };
389
422
 
390
423
  /** The same sum, once per area, so "where is this site weak" is answerable
package/src/serve.mjs CHANGED
@@ -44,6 +44,11 @@ export async function serve({ port = 4321, host = '127.0.0.1', maxPages, allowed
44
44
  // person whose account it is; a deployed Worker leaves this unset, where
45
45
  // `?search-console=` would hand a stranger somebody else's traffic data.
46
46
  ALLOW_SEARCH_CONSOLE: '1',
47
+ // Same reasoning again: enumerating the domain calls crt.sh, which is free,
48
+ // unauthenticated and rate-limited by IP. On the loopback address that
49
+ // budget is the caller's own to spend. A deployed Worker leaves this unset,
50
+ // where every visitor would be spending one shared address's allowance.
51
+ ALLOW_HOSTS: '1',
47
52
  // This is Node, so `node:tls` works and the certificate checks are real.
48
53
  // Cloudflare leaves this unset and gets the `tls-not-checked` note instead.
49
54
  // The macOS window talks to this server, and was being handed a hosted
package/src/site.mjs CHANGED
@@ -6,6 +6,10 @@ import { parseRobots, robotsVerdict } from './robots.mjs';
6
6
  import { aiAccess, describeAccess } from './agents-ai.mjs';
7
7
  import { parseHtml } from './parse.mjs';
8
8
  import { schemaNodes, seriesOf, paginatedCanonical } from './checks.mjs';
9
+ import { similarity } from './dupes.mjs';
10
+ import {
11
+ resolve as resolveDns, certificateNames, collapseFleets, rankHosts, looksLikeStaging, NXDOMAIN,
12
+ } from './dns.mjs';
9
13
  import { plural } from './text.mjs';
10
14
 
11
15
  // Two weeks is enough to renew by hand if the automation has quietly stopped,
@@ -375,7 +379,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
375
379
  const targets = all.slice(0, limit);
376
380
  opts.onProgress?.({ phase: 'links', detail: `${targets.length} distinct targets to check` });
377
381
  const results = await mapLimit(targets, 6, async (target) => {
378
- const res = await fetcher.get(target);
382
+ const res = await fetcher.get(target, { keepBody: false });
379
383
  opts.onProgress?.({ phase: 'links', status: res.status, ms: res.ms, url: target });
380
384
  const type = res.headers.get('content-type') ?? '';
381
385
  // A third question the same response answers — and the only place it can be
@@ -495,9 +499,9 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
495
499
  const imageTargets = [...imageSources.values()].slice(0, imageLimit).map((entry) => entry.src);
496
500
  opts.onProgress?.({ phase: 'images', detail: `${imageTargets.length} distinct images to check` });
497
501
  const imageResults = await mapLimit(imageTargets, 6, async (src) => {
498
- let res = await fetcher.get(src, { method: 'HEAD' });
502
+ let res = await fetcher.get(src, { method: 'HEAD', keepBody: false });
499
503
  // Some hosts answer HEAD with 405 or 501 and serve the file perfectly well.
500
- if (res.status === 405 || res.status === 501) res = await fetcher.get(src);
504
+ if (res.status === 405 || res.status === 501) res = await fetcher.get(src, { keepBody: false });
501
505
  opts.onProgress?.({ phase: 'images', status: res.status, ms: res.ms, url: src });
502
506
  return { src, status: res.status, error: res.error };
503
507
  });
@@ -586,8 +590,8 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
586
590
  }
587
591
  const schemaTargets = [...schemaImages.keys()].slice(0, opts.maxImageChecks ?? 200);
588
592
  const schemaResults = await mapLimit(schemaTargets, 4, async (href) => {
589
- let res = await fetcher.get(href, { method: 'HEAD' });
590
- if (res.status === 405 || res.status === 501) res = await fetcher.get(href);
593
+ let res = await fetcher.get(href, { method: 'HEAD', keepBody: false });
594
+ if (res.status === 405 || res.status === 501) res = await fetcher.get(href, { keepBody: false });
591
595
  return { href, status: res.status, error: res.error };
592
596
  });
593
597
  const deadSchemaImages = schemaResults.filter(
@@ -729,7 +733,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
729
733
  // Conservative about what counts as broken, for the same reason as the image
730
734
  // sweep: 403 is hotlink protection working, not a missing file.
731
735
  const ogResults = await mapLimit([...ogImages.keys()], 4, async (src) => {
732
- const { final } = await fetcher.chain(src);
736
+ const { final } = await fetcher.chain(src, 5, { keepBody: false });
733
737
  return { src, final };
734
738
  });
735
739
  for (const { src, final } of ogResults) {
@@ -764,7 +768,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
764
768
  twitterImages.set(src, page.url);
765
769
  }
766
770
  const twitterResults = await mapLimit([...twitterImages.keys()], 4, async (src) => {
767
- const { final } = await fetcher.chain(src);
771
+ const { final } = await fetcher.chain(src, 5, { keepBody: false });
768
772
  return { src, final };
769
773
  });
770
774
  for (const { src, final } of twitterResults) {
@@ -777,3 +781,292 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
777
781
 
778
782
  return out;
779
783
  }
784
+
785
+ // --- Every other host on the domain -----------------------------------------
786
+ //
787
+ // The rest of this file audits the site it was pointed at. This asks a
788
+ // different question: what *else* is on this domain, and is any of it damaging
789
+ // the site that was.
790
+ //
791
+ // Three things come out of it, and all three are invisible to a crawl of the
792
+ // site itself, which is the reason it exists at all:
793
+ //
794
+ // * a staging copy nobody remembered to close, indexable, competing with
795
+ // production for its own results and publishing whatever was being tested;
796
+ // * a subdomain whose CNAME points at a service that is gone, which anybody
797
+ // can claim and then serve from the client's own domain;
798
+ // * a second host serving the same site again, splitting its signals.
799
+ //
800
+ // The discovery is certificate transparency and the verification is this tool's
801
+ // own fetcher, and that split is the whole design. CT is a log of every
802
+ // certificate ever issued: it is the only free, keyless, complete source of
803
+ // hostnames, and it is a terrible list of *live* ones — three quarters of what
804
+ // it returns for a large domain stopped existing years ago. So nothing from the
805
+ // log is ever reported. It produces candidates; DNS and an HTTP request decide
806
+ // which of them are facts. A finding here has been resolved and fetched.
807
+ //
808
+ // Off by default. It is one slow third-party lookup plus a lookup per candidate,
809
+ // and a crawl should not quietly spend that.
810
+
811
+ /** Names that are the site itself rather than a sibling. */
812
+ const canonicalNames = (base) => new Set([base.hostname, base.hostname.replace(/^www\./, ''),
813
+ `www.${base.hostname.replace(/^www\./, '')}`]);
814
+
815
+ /** Whether a response forbids indexing, by either of the two routes Google
816
+ * reads it from. The same test the soft-404 check makes, for the same reason:
817
+ * a noindexed copy is a mistake somebody already mitigated. */
818
+ function noindexed(res) {
819
+ const meta = res.body?.match(/<meta[^>]+name=["']robots["'][^>]*>/i)?.[0] ?? '';
820
+ return /noindex/i.test(meta) || /noindex/i.test(res.headers?.get?.('x-robots-tag') ?? '');
821
+ }
822
+
823
+ /**
824
+ * Sibling hosts, and what is wrong with them.
825
+ *
826
+ * Returns findings *and* the inventory behind them, because a reader who is
827
+ * told a domain has a leaked staging host is owed the list the tool actually
828
+ * looked at — including the part it did not reach.
829
+ *
830
+ * @returns {Promise<{findings: object[], hosts: object|null}>}
831
+ */
832
+ export async function hostChecks(origin, fetcher, opts = {}) {
833
+ const out = [];
834
+ const base = new URL(origin);
835
+ const apex = base.hostname.replace(/^www\./, '');
836
+
837
+ // An IP address and a bare hostname have no domain to enumerate, and asking
838
+ // the log about one is a question with no sensible answer. The same guard the
839
+ // www variants use, for the same reason.
840
+ const isAddress = /^\[?[\d.:]+\]?$/.test(base.hostname);
841
+ if (isAddress || !apex.includes('.')) return { findings: out, hosts: null };
842
+
843
+ // Injectable so the tests never touch the network, and so a network that
844
+ // blocks either service can be pointed somewhere that answers.
845
+ const lookup = opts.resolveDns ?? resolveDns;
846
+ const fromLog = opts.certificateNames ?? certificateNames;
847
+ const dnsOpts = opts.dnsOptions ?? {};
848
+
849
+ const log = await fromLog(apex, dnsOpts);
850
+ if (log === null) {
851
+ // A log that did not answer is not a domain with no other hosts. Saying so
852
+ // is the same rule `tls-not-checked` and `sitemap-not-checked` follow: a
853
+ // check that could not run says it could not run, because a missing finding
854
+ // reads exactly like a passing one.
855
+ out.push(f('info', 'hosts-not-checked', 'Other hosts on this domain were not enumerated',
856
+ 'No certificate transparency log answered, so this run cannot say whether the domain serves ' +
857
+ 'anything besides the site that was audited. Nothing about the site itself is affected. Try ' +
858
+ 'again — these logs are free and unauthenticated, and they rate-limit and time out ' +
859
+ 'accordingly.', origin));
860
+ return { findings: out, hosts: null };
861
+ }
862
+
863
+ const mine = canonicalNames(base);
864
+ const ranked = rankHosts(collapseFleets(log.names), apex);
865
+ const limit = opts.maxHostChecks ?? 40;
866
+ const targets = ranked.slice(0, limit);
867
+ opts.onProgress?.({ phase: 'hosts', detail: `${targets.length} of ${ranked.length} hostnames to resolve` });
868
+
869
+ if (ranked.length > targets.length) {
870
+ out.push(f('info', 'host-sweep-capped', `${ranked.length - targets.length} hostnames were not looked up`,
871
+ `The log named ${ranked.length} distinct hosts for this domain and the sweep stops at ${limit}, ` +
872
+ 'environment names first. Raise it with maxHostChecks in the config — the rest of this section ' +
873
+ 'describes only what was actually resolved.', origin));
874
+ }
875
+
876
+ // --- Resolve ------------------------------------------------------------
877
+ const resolved = await mapLimit(targets, 8, async (host) => {
878
+ const a = await lookup(host, 'A', dnsOpts);
879
+ const alias = a.cname.at(-1) ?? null;
880
+
881
+ // A dangling CNAME: the alias is there and what it points at is not.
882
+ //
883
+ // Most resolvers answer NXDOMAIN for the whole query when the chain ends in
884
+ // a name that does not exist, so usually one lookup settles it. Some answer
885
+ // NOERROR with no address instead, so that case is chased explicitly rather
886
+ // than guessed at — this is the finding that accuses somebody's live domain
887
+ // of being claimable, and it is not allowed to be approximate.
888
+ let dangling = false;
889
+ if (alias) {
890
+ if (a.ok && a.status === NXDOMAIN) dangling = true;
891
+ else if (a.ok && !a.records.length) {
892
+ const chased = await lookup(alias, 'A', dnsOpts);
893
+ dangling = chased.ok && chased.status === NXDOMAIN;
894
+ }
895
+ }
896
+ opts.onProgress?.({ phase: 'hosts', url: host, detail: a.records.length ? a.records[0] : 'no address' });
897
+ return { host, addresses: a.records, cname: alias, dangling, live: a.ok && a.records.length > 0 };
898
+ });
899
+
900
+ for (const row of resolved) {
901
+ if (!row.dangling) continue;
902
+ out.push(f('error', 'subdomain-takeover', `${row.host} points at a service that no longer exists`,
903
+ `It is a CNAME to ${row.cname}, and that name does not resolve — the provider it was hosted on has ` +
904
+ 'released it. Anybody can register the same name at that provider and serve whatever they like ' +
905
+ `from ${row.host}, which is a host on this domain and inherits its reputation. Delete the DNS ` +
906
+ 'record, or point it back at something you own.', `https://${row.host}/`));
907
+ }
908
+
909
+ // --- Fetch what resolves ------------------------------------------------
910
+ // Only live hosts, and only their home page. The point is to establish what a
911
+ // search engine would find at each one, which the first page answers.
912
+ const live = resolved.filter((r) => r.live && !mine.has(r.host));
913
+ const fetchLimit = opts.maxHostFetches ?? 25;
914
+ const probed = await mapLimit(live.slice(0, fetchLimit), 4, async (row) => {
915
+ const { final } = await fetcher.chain(`https://${row.host}/`);
916
+ const type = final.headers?.get?.('content-type') ?? '';
917
+ const isHtml = /text\/html/i.test(type);
918
+ let landed = null;
919
+ let landedPath = null;
920
+ try {
921
+ const at = new URL(final.url);
922
+ landed = at.hostname;
923
+ landedPath = at.pathname;
924
+ } catch { /* an unparseable final URL is not a host to compare */ }
925
+ const doc = final.ok && isHtml && final.body ? parseHtml(final.body, final.url) : null;
926
+ return {
927
+ ...row,
928
+ status: final.status,
929
+ error: final.error ?? null,
930
+ isHtml,
931
+ // A sibling that redirects to the canonical host is the correct
932
+ // arrangement, not a finding — it is how a company parks an old name.
933
+ redirectsHome: Boolean(landed && mine.has(landed)),
934
+ // Where asking for this host's home page actually ended up.
935
+ landed,
936
+ landedPath,
937
+ // The one condition the staging and duplicate checks actually need: this
938
+ // host answered for its own root. Anything else — a redirect off to
939
+ // another host, or a bounce to a login page — means the host is not
940
+ // serving a copy of anything, whatever the final response says.
941
+ //
942
+ // Both real false positives this check has had were this, in different
943
+ // disguises. `dev.gtm.github.com` bounces to `/login`, which answers 200
944
+ // with HTML and no noindex. `dev.jquery.com` 301s to `bugs.jquery.com`,
945
+ // a different sibling, so the old redirectsHome test — which only knew
946
+ // about the canonical host — let it through and the landing path was `/`.
947
+ // One condition covers both and needs no vocabulary of login paths.
948
+ servesOwnRoot: landed === row.host && landedPath === '/',
949
+ noindex: final.ok ? noindexed(final) : false,
950
+ title: doc?.title ?? null,
951
+ canonical: doc?.canonical?.[0] ?? null,
952
+ fingerprint: doc?.fingerprint ?? null,
953
+ };
954
+ });
955
+
956
+ // --- A staging copy that is open to the index ---------------------------
957
+ //
958
+ // Narrow on purpose, and every clause is doing work. The name has to be an
959
+ // environment rather than a product — `beta.` and `demo.` are deliberately
960
+ // not in that list, because companies ship both. It has to actually serve a
961
+ // page. It must not already be handled: a `noindex`, a canonical pointing at
962
+ // production, or a robots.txt that disallows crawling are all somebody having
963
+ // thought about this, and reporting them would be the cry-wolf that gets a
964
+ // whole report ignored.
965
+ const staged = new Set();
966
+ for (const row of probed) {
967
+ if (!looksLikeStaging(row.host) || row.status !== 200 || !row.isHtml) continue;
968
+ if (row.noindex || !row.servesOwnRoot) continue;
969
+ // Its own robots.txt, not the site's. A staging host that blocks crawlers
970
+ // is a staging host somebody closed.
971
+ const robots = await fetcher.get(`https://${row.host}/robots.txt`);
972
+ if (robots.ok && !robotsVerdict(parseRobots(robots.body), '/').allowed) continue;
973
+ // A canonical pointing at production is the other legitimate arrangement.
974
+ let canonicalHost = null;
975
+ try {
976
+ canonicalHost = row.canonical ? new URL(row.canonical, `https://${row.host}/`).hostname : null;
977
+ } catch { /* a malformed canonical is not a defence */ }
978
+ if (canonicalHost && mine.has(canonicalHost)) continue;
979
+
980
+ staged.add(row.host);
981
+ out.push(f('warn', 'staging-indexable', `${row.host} is a live, indexable copy of the site`,
982
+ `It answers 200 with HTML${row.title ? ` and is titled “${row.title}”` : ''}, carries no noindex, ` +
983
+ 'and nothing in its robots.txt or its canonical keeps it out of the index. A staging host that ' +
984
+ 'Google can reach competes with production for its own results and publishes whatever is being ' +
985
+ 'tested on it. Add a noindex, disallow it in robots.txt, or put it behind authentication.',
986
+ `https://${row.host}/`));
987
+ }
988
+
989
+ // --- The same site again, on another host -------------------------------
990
+ //
991
+ // Compared body to body rather than by title, for the same reason
992
+ // `duplicate-content` is: two hosts serving one site is a real split of the
993
+ // signals, and two hosts that happen to share a title is a coincidence.
994
+ // Silent when the sibling has already been reported as staging — one fault
995
+ // gets one finding.
996
+ const home = await fetcher.get(`${origin}/`);
997
+ const homeDoc = home.ok && /text\/html/i.test(home.headers.get('content-type') ?? '')
998
+ ? parseHtml(home.body, `${origin}/`)
999
+ : null;
1000
+ if (homeDoc?.fingerprint) {
1001
+ for (const row of probed) {
1002
+ if (staged.has(row.host) || row.noindex || !row.servesOwnRoot) continue;
1003
+ if (row.status !== 200 || !row.fingerprint) continue;
1004
+ let canonicalHost = null;
1005
+ try {
1006
+ canonicalHost = row.canonical ? new URL(row.canonical, `https://${row.host}/`).hostname : null;
1007
+ } catch { /* as above */ }
1008
+ if (canonicalHost && mine.has(canonicalHost)) continue;
1009
+ if (similarity(homeDoc.fingerprint, row.fingerprint) < 0.9) continue;
1010
+
1011
+ out.push(f('warn', 'duplicate-host', `${row.host} serves the same site again`,
1012
+ `Its home page is the same page as ${origin}/, it is indexable, and its canonical does not point ` +
1013
+ 'back. Two hosts serving one site split every signal the site earns between them, and Google ' +
1014
+ 'picks which one to show. Redirect it to the canonical host, or make it say so with a canonical.',
1015
+ `https://${row.host}/`));
1016
+ }
1017
+ }
1018
+
1019
+ // --- The inventory ------------------------------------------------------
1020
+ // Shipped whether or not anything was wrong, because "what else is on this
1021
+ // domain" is a question worth an answer on a healthy domain too.
1022
+ const [ns, mx, txt] = await Promise.all([
1023
+ lookup(apex, 'NS', dnsOpts), lookup(apex, 'MX', dnsOpts), lookup(apex, 'TXT', dnsOpts),
1024
+ ]);
1025
+ const byHost = new Map(probed.map((p) => [p.host, p]));
1026
+
1027
+ return {
1028
+ findings: out,
1029
+ hosts: {
1030
+ apex,
1031
+ // Which log answered. Two runs of one domain can list different hosts
1032
+ // because different sources answered them, and a reader comparing those
1033
+ // two runs is owed the reason rather than left to suspect the domain
1034
+ // changed.
1035
+ source: log.source,
1036
+ found: ranked.length,
1037
+ resolved: resolved.filter((r) => r.live).length,
1038
+ capped: Math.max(0, ranked.length - targets.length),
1039
+ nameservers: ns.records,
1040
+ // An MX record's data is a preference and a host — "10 mx.example.net" —
1041
+ // and the number is a mail-routing detail nobody reads an SEO report for.
1042
+ mail: mx.records.map((r) => r.replace(/^\d+\s+/, '')),
1043
+ // Only the TXT records that say something about who the domain talks to.
1044
+ // The rest are verification strings for a dozen SaaS products and are
1045
+ // nobody's business in an SEO report.
1046
+ // TXT records arrive quoted, and a long one arrives as several quoted
1047
+ // chunks that are one string joined — which is the wire format, not
1048
+ // something to print at somebody.
1049
+ policies: txt.records
1050
+ .map((r) => r.replace(/"\s*"/g, '').replace(/^"|"$/g, ''))
1051
+ .filter((r) => /^v=(spf1|DMARC1)/i.test(r)),
1052
+ rows: resolved.map((row) => {
1053
+ const seen = byHost.get(row.host);
1054
+ return {
1055
+ host: row.host,
1056
+ addresses: row.addresses,
1057
+ cname: row.cname,
1058
+ dangling: row.dangling,
1059
+ status: seen?.status ?? null,
1060
+ title: seen?.title ?? null,
1061
+ landedPath: seen?.landedPath ?? null,
1062
+ landed: seen?.landed ?? null,
1063
+ redirectsHome: seen?.redirectsHome ?? false,
1064
+ noindex: seen?.noindex ?? false,
1065
+ // Said explicitly rather than inferred from a null status, which
1066
+ // would read as "did not answer" for a host nobody asked.
1067
+ checked: Boolean(seen),
1068
+ };
1069
+ }),
1070
+ },
1071
+ };
1072
+ }