create-website-build-kit 0.1.10 → 0.1.11
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 +134 -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.11",
|
|
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,156 @@ 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';
|
|
59
180
|
try {
|
|
60
|
-
|
|
181
|
+
let res = await fetch(url, { ...options, redirect: 'manual', signal: controller.signal });
|
|
182
|
+
for (let hops = 0; follow && res.status >= 300 && res.status < 400 && res.headers.get('location') && hops < 5; hops++) {
|
|
183
|
+
const next = new URL(res.headers.get('location'), url).toString();
|
|
184
|
+
const hopRefusal = blockedReason(next, { allowInternal });
|
|
185
|
+
if (hopRefusal) return refuse(`${hopRefusal.reason} — reached by a redirect from ${url}`, next);
|
|
186
|
+
url = next;
|
|
187
|
+
res = await fetch(url, { ...options, redirect: 'manual', signal: controller.signal });
|
|
188
|
+
}
|
|
189
|
+
return res;
|
|
61
190
|
} catch {
|
|
62
191
|
return null;
|
|
63
192
|
} finally {
|
|
@@ -407,7 +536,8 @@ const VENDORS = [
|
|
|
407
536
|
'jobber', 'servicetitan', 'momence', 'wellnessliving', 'glofox', 'pike13', 'cookieyes', 'cookiebot', 'complianz', 'algolia', 'mindbody',
|
|
408
537
|
'squarespace', 'wix', 'shopify', 'woocommerce', 'memberpress',
|
|
409
538
|
];
|
|
410
|
-
const
|
|
539
|
+
const corpusLower = corpus.toLowerCase();
|
|
540
|
+
const vendors = VENDORS.filter((v) => corpusLower.includes(v.toLowerCase()));
|
|
411
541
|
|
|
412
542
|
const origins = [...new Set([...corpus.matchAll(/(?:src|href)=["']https?:\/\/([^"'/]+)/g)].map((m) => m[1]))]
|
|
413
543
|
.filter((h) => !h.endsWith(HOST.replace(/^www\./, '')))
|