create-website-build-kit 0.1.10 → 0.1.12
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/package.json +1 -1
- package/template/CLAUDE.md +1 -0
- package/template/scripts/recon.mjs +145 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-website-build-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Scaffold a production marketing site \u2014 Astro on Cloudflare Workers, with the gates, the migration playbook and the accessibility work already wired.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
package/template/CLAUDE.md
CHANGED
|
@@ -13,6 +13,7 @@ npm run a11y # accessibility check, one URL per
|
|
|
13
13
|
npm run tells # what is undecided, and the design tells
|
|
14
14
|
npm run check:copy # author notes that reached the rendered page
|
|
15
15
|
npm run recon -- https://old-site.com # inventory the old site BEFORE designing routes
|
|
16
|
+
# --allow-internal # ...if the old site is on a VPN or a private address
|
|
16
17
|
npm run dns -- old-site.com # capture the zone. MX loss kills client email
|
|
17
18
|
npm run seo -- https://old-site.com # optional: SEO baseline to diff after cutover
|
|
18
19
|
npm run verify -- https://new.example.com # the deployed site, not the build. exits non-zero
|
|
@@ -37,27 +37,167 @@ const YELLOW = '[33m';
|
|
|
37
37
|
const DIM = '[2m';
|
|
38
38
|
const BOLD = '[1m';
|
|
39
39
|
|
|
40
|
+
/*
|
|
41
|
+
* ── WHAT THIS BLOCKS, AND WHAT IT DOES NOT ─────────────────────────────────
|
|
42
|
+
* Loopback, the RFC1918 private ranges, link-local (which is where the cloud
|
|
43
|
+
* metadata services live, at 169.254.169.254) and localhost. The threat is a
|
|
44
|
+
* redirect: the OLD site is not ours, and a 302 it issues must not be able to
|
|
45
|
+
* steer this crawler at infrastructure on the operator's network.
|
|
46
|
+
*
|
|
47
|
+
* ⚠ THIS IS A STRING BLOCKLIST AND IT ONLY SEES LITERAL ADDRESSES. A HOSTNAME
|
|
48
|
+
* THAT *RESOLVES* TO LOOPBACK WALKS STRAIGHT THROUGH — `localtest.me` is a
|
|
49
|
+
* public name that resolves to ::1 today, and any attacker can point their
|
|
50
|
+
* own name wherever they like. Closing that needs resolution before connect
|
|
51
|
+
* plus a pinned socket, which fetch does not expose.
|
|
52
|
+
*
|
|
53
|
+
* So treat this as defence in depth, not a barrier. It raises the cost of
|
|
54
|
+
* the obvious attack; it does not make the crawler safe to point at a host
|
|
55
|
+
* you do not trust.
|
|
56
|
+
*
|
|
57
|
+
* Node's URL parser canonicalises before we ever see the host, which is why
|
|
58
|
+
* the short forms need no special handling: 127.1, 2130706433 and 0177.0.0.1
|
|
59
|
+
* all arrive as 127.0.0.1, and [0:0:0:0:0:0:0:1] arrives as ::1.
|
|
60
|
+
*
|
|
61
|
+
* ⚠ IT CANONICALISES IPv4-MAPPED IPv6 THE WRONG WAY FOR US. `::ffff:127.0.0.1`
|
|
62
|
+
* comes back as `[::ffff:7f00:1]` — the same address in hex — so a blocklist
|
|
63
|
+
* written in dotted quad never matches it. It has to be folded back by hand,
|
|
64
|
+
* which is what unmapV4 does. Stripping the literal `::ffff:` prefix is NOT
|
|
65
|
+
* enough and looks like it works.
|
|
66
|
+
*/
|
|
67
|
+
const BLOCKED_HOST_RE =
|
|
68
|
+
/^(127(?:\.\d{1,3}){3}|10(?:\.\d{1,3}){3}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|192\.168(?:\.\d{1,3}){2}|169\.254(?:\.\d{1,3}){2}|0\.0\.0\.0|localhost|::1|metadata\.google\.internal)$/i;
|
|
69
|
+
|
|
70
|
+
/** `::ffff:7f00:1` → `127.0.0.1`. Returns the host unchanged if it is not mapped. */
|
|
71
|
+
function unmapV4(host) {
|
|
72
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
73
|
+
if (hex) {
|
|
74
|
+
const [hi, lo] = [parseInt(hex[1], 16), parseInt(hex[2], 16)];
|
|
75
|
+
return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.');
|
|
76
|
+
}
|
|
77
|
+
return host.replace(/^::ffff:/i, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Why this URL is refused, or null if it is fine. Never throws.
|
|
82
|
+
*
|
|
83
|
+
* The `kind` matters: only a `host` refusal is something --allow-internal can
|
|
84
|
+
* excuse. A bad protocol is a typo, and telling someone to pass a flag that
|
|
85
|
+
* cannot help them is worse than saying nothing.
|
|
86
|
+
*/
|
|
87
|
+
function blockedReason(url, { allowInternal = false } = {}) {
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = new URL(url);
|
|
91
|
+
} catch {
|
|
92
|
+
return { kind: 'url', reason: `unparseable URL: ${url}` };
|
|
93
|
+
}
|
|
94
|
+
const { protocol, hostname } = parsed;
|
|
95
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
96
|
+
return { kind: 'protocol', reason: `blocked protocol: ${protocol}` };
|
|
97
|
+
}
|
|
98
|
+
if (allowInternal) return null;
|
|
99
|
+
const host = unmapV4(hostname.replace(/^\[|\]$/g, ''));
|
|
100
|
+
if (BLOCKED_HOST_RE.test(host)) return { kind: 'host', reason: `blocked internal host: ${hostname}` };
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
40
104
|
const argv = process.argv.slice(2);
|
|
41
105
|
const target = argv.find((a) => !a.startsWith('--'));
|
|
42
106
|
const useWayback = !argv.includes('--no-wayback');
|
|
107
|
+
const allowInternal = argv.includes('--allow-internal');
|
|
43
108
|
|
|
44
109
|
if (!target) {
|
|
45
|
-
console.error('usage: npm run recon -- https://old-site.com [--no-wayback]');
|
|
110
|
+
console.error('usage: npm run recon -- https://old-site.com [--no-wayback] [--allow-internal]');
|
|
46
111
|
process.exit(1);
|
|
47
112
|
}
|
|
48
113
|
|
|
49
|
-
|
|
114
|
+
/*
|
|
115
|
+
* ⚠ ONLY PREPEND A SCHEME WHEN THERE IS NONE. `target.startsWith('http')` was
|
|
116
|
+
* the old test, and it turned `file:///etc/passwd` into
|
|
117
|
+
* `https://file:///etc/passwd` — which parses, with hostname `file`, so the
|
|
118
|
+
* protocol check below could never fire on a target the user typed.
|
|
119
|
+
*/
|
|
120
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(target);
|
|
121
|
+
const ORIGIN = (hasScheme ? target : `https://${target}`).replace(/\/$/, '');
|
|
50
122
|
const HOST = new URL(ORIGIN).hostname;
|
|
51
123
|
const OUT = 'recon';
|
|
52
124
|
|
|
125
|
+
/*
|
|
126
|
+
* The target is checked too, not just the redirects. An old site behind a VPN
|
|
127
|
+
* on a private address is a real thing to recon, so this is a flag rather than
|
|
128
|
+
* a refusal — but it has to be asked for, because the default has to be the
|
|
129
|
+
* safe one and `--allow-internal` also relaxes the redirect check below.
|
|
130
|
+
*/
|
|
131
|
+
const originRefusal = blockedReason(ORIGIN, { allowInternal });
|
|
132
|
+
if (originRefusal) {
|
|
133
|
+
const hint =
|
|
134
|
+
originRefusal.kind === 'host'
|
|
135
|
+
? ` recon crawls the old LIVE site, so an internal address is usually a typo.\n` +
|
|
136
|
+
` If it is not — the old site is on a VPN, or behind a private address —\n` +
|
|
137
|
+
` pass ${BOLD}--allow-internal${RESET} and it will crawl it.\n`
|
|
138
|
+
: ` recon speaks http and https. Give it the URL you would type into a browser.\n`;
|
|
139
|
+
console.error(`\n${RED}✗ ${originRefusal.reason}${RESET}\n\n${hint}`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
53
143
|
const section = (t) => console.log(`\n${BOLD}── ${t} ${'─'.repeat(Math.max(0, 56 - t.length))}${RESET}`);
|
|
54
144
|
const notes = [];
|
|
55
145
|
|
|
146
|
+
/*
|
|
147
|
+
* ⚠ A REFUSAL IS NOT A NETWORK ERROR, AND MUST NOT LOOK LIKE ONE.
|
|
148
|
+
*
|
|
149
|
+
* req() returns null when a fetch fails, and every caller reads that as "the
|
|
150
|
+
* old site did not answer". If a blocked host returned null the same way, a
|
|
151
|
+
* refused crawl would be reported as an unreachable site: recon would print a
|
|
152
|
+
* thin inventory, exit 0, and nobody would learn that pages were skipped on
|
|
153
|
+
* purpose. That is the exact shape of failure this kit exists to prevent, so
|
|
154
|
+
* a refusal says so on stdout AND lands in the notes at the end of the run.
|
|
155
|
+
*
|
|
156
|
+
* Deduplicated by reason: a site that redirects every path to the same
|
|
157
|
+
* internal host would otherwise print one line per URL.
|
|
158
|
+
*/
|
|
159
|
+
const refusals = new Set();
|
|
160
|
+
|
|
161
|
+
function refuse(reason, url) {
|
|
162
|
+
if (!refusals.has(reason)) {
|
|
163
|
+
refusals.add(reason);
|
|
164
|
+
console.log(` ${YELLOW}refused${RESET} ${reason}`);
|
|
165
|
+
notes.push(
|
|
166
|
+
`Refused to fetch ${url} — ${reason}. This was NOT a network error: the crawl skipped it ` +
|
|
167
|
+
`deliberately, so the inventory is incomplete. Re-run with --allow-internal if that host is yours.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
56
173
|
async function req(url, options = {}) {
|
|
174
|
+
const refusal = blockedReason(url, { allowInternal });
|
|
175
|
+
if (refusal) return refuse(refusal.reason, url);
|
|
176
|
+
|
|
57
177
|
const controller = new AbortController();
|
|
58
178
|
const timer = setTimeout(() => controller.abort(), 20000);
|
|
179
|
+
const follow = options.redirect !== 'manual';
|
|
180
|
+
/*
|
|
181
|
+
* ⚠ 20, BECAUSE THAT IS WHAT fetch ITSELF ALLOWS. Following redirects by
|
|
182
|
+
* hand is what lets every hop be checked, but the hop LIMIT is not a
|
|
183
|
+
* security property — and picking a lower one silently changes results.
|
|
184
|
+
*
|
|
185
|
+
* At a cap of 5, a page behind a longer chain came back as the 302 rather
|
|
186
|
+
* than the 200 it used to: measured, old → 200, new → 302. In a migration
|
|
187
|
+
* inventory that turns a live page into a redirect, which is the kind of
|
|
188
|
+
* wrong that reads as fine.
|
|
189
|
+
*/
|
|
190
|
+
const MAX_HOPS = 20;
|
|
59
191
|
try {
|
|
60
|
-
|
|
192
|
+
let res = await fetch(url, { ...options, redirect: 'manual', signal: controller.signal });
|
|
193
|
+
for (let hops = 0; follow && res.status >= 300 && res.status < 400 && res.headers.get('location') && hops < MAX_HOPS; hops++) {
|
|
194
|
+
const next = new URL(res.headers.get('location'), url).toString();
|
|
195
|
+
const hopRefusal = blockedReason(next, { allowInternal });
|
|
196
|
+
if (hopRefusal) return refuse(`${hopRefusal.reason} — reached by a redirect from ${url}`, next);
|
|
197
|
+
url = next;
|
|
198
|
+
res = await fetch(url, { ...options, redirect: 'manual', signal: controller.signal });
|
|
199
|
+
}
|
|
200
|
+
return res;
|
|
61
201
|
} catch {
|
|
62
202
|
return null;
|
|
63
203
|
} finally {
|
|
@@ -407,7 +547,8 @@ const VENDORS = [
|
|
|
407
547
|
'jobber', 'servicetitan', 'momence', 'wellnessliving', 'glofox', 'pike13', 'cookieyes', 'cookiebot', 'complianz', 'algolia', 'mindbody',
|
|
408
548
|
'squarespace', 'wix', 'shopify', 'woocommerce', 'memberpress',
|
|
409
549
|
];
|
|
410
|
-
const
|
|
550
|
+
const corpusLower = corpus.toLowerCase();
|
|
551
|
+
const vendors = VENDORS.filter((v) => corpusLower.includes(v.toLowerCase()));
|
|
411
552
|
|
|
412
553
|
const origins = [...new Set([...corpus.matchAll(/(?:src|href)=["']https?:\/\/([^"'/]+)/g)].map((m) => m[1]))]
|
|
413
554
|
.filter((h) => !h.endsWith(HOST.replace(/^www\./, '')))
|