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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-website-build-kit",
3
- "version": "0.1.10",
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",
@@ -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 = '';
37
37
  const DIM = '';
38
38
  const BOLD = '';
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
- const ORIGIN = (target.startsWith('http') ? target : `https://${target}`).replace(/\/$/, '');
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
- return await fetch(url, { redirect: 'follow', signal: controller.signal, ...options });
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 vendors = VENDORS.filter((v) => new RegExp(v.replace('.', '\\.'), 'i').test(corpus));
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\./, '')))