@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 +84 -0
- package/action.yml +11 -0
- package/bin/seo-audit.mjs +9 -0
- package/package.json +1 -1
- package/src/areas.mjs +3 -0
- package/src/audit.mjs +22 -1
- package/src/dns.mjs +215 -0
- package/src/http.mjs +22 -5
- package/src/options.mjs +4 -0
- package/src/report.mjs +234 -0
- package/src/score.mjs +33 -0
- package/src/serve.mjs +5 -0
- package/src/site.mjs +300 -7
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.
|
|
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
|
-
|
|
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
|
+
}
|