@impeccable/reviews 0.1.1 → 0.1.3
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 +25 -7
- package/dist/agent.js +2 -2
- package/dist/bin.js +38 -0
- package/dist/cloudflare-access.js +157 -49
- package/dist/cloudflare.js +93 -51
- package/dist/fence.js +107 -39
- package/dist/host-questions.js +4 -3
- package/dist/hosted.js +29 -8
- package/dist/log.js +6 -1
- package/dist/login.js +8 -6
- package/dist/main.js +76 -23
- package/dist/netlify-password.js +2 -1
- package/dist/netlify.js +4 -1
- package/dist/playbook/cases/cloudflare-previews.md +4 -4
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/monorepo.md +4 -3
- package/dist/playbook/cases/netlify-previews.md +2 -2
- package/dist/playbook/cases/netlify-setup.md +4 -4
- package/dist/playbook/cases/preview-protection.md +3 -3
- package/dist/playbook/cases/pull-requests.md +2 -2
- package/dist/playbook/cases/server-previews.md +17 -0
- package/dist/playbook/cases/sign-in.md +0 -1
- package/dist/playbook/cases/static-site.md +1 -1
- package/dist/playbook/cases/vercel-previews.md +1 -1
- package/dist/playbook/cases/vercel-setup.md +3 -3
- package/dist/playbook.js +3 -2
- package/dist/preflight.js +52 -12
- package/dist/report.js +68 -0
- package/dist/sandbox.js +280 -0
- package/dist/tools.js +58 -35
- package/dist/ui/look.js +13 -5
- package/dist/ui/run-state.js +9 -9
- package/dist/ui/screens.js +15 -16
- package/package.json +2 -1
- package/dist/playbook/cases/ci-tunnel.md +0 -21
package/dist/cloudflare.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { commandRunner } from './netlify.js';
|
|
4
|
+
import { ownFolder } from './sandbox.js';
|
|
4
5
|
/**
|
|
5
6
|
* Cloudflare Pages: the preview deployments this repository's pull requests
|
|
6
|
-
* get, from GitHub's record and from
|
|
7
|
-
* signed in (read only), and whether
|
|
7
|
+
* get, from GitHub's record and from cf, Cloudflare's CLI, on this machine
|
|
8
|
+
* when it is signed in (read only), and whether cf is signed in. Cloudflare's
|
|
8
9
|
* GitHub app records no deployment and no commit status: its record is a
|
|
9
10
|
* check run on each commit it built and a comment on the pull request, both
|
|
10
11
|
* naming the deployment's address (https://<id>.<project>.pages.dev) and the
|
|
@@ -27,7 +28,7 @@ const list = (value) => (Array.isArray(value) ? value.map(record).filter((item)
|
|
|
27
28
|
const sha = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/.test(value);
|
|
28
29
|
/** A branch name GitHub can be asked about: the characters git allows, no `..`, and neither a slash nor a dot at the end. */
|
|
29
30
|
const branchName = (value) => /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/.test(value) && !value.includes('..') && !value.endsWith('/') && !value.endsWith('.');
|
|
30
|
-
/** A branch, as GitHub names it in a check run's summary or a
|
|
31
|
+
/** A branch, as GitHub names it in a check run's summary or cf in a deployment: git's characters, and not too long to be one. */
|
|
31
32
|
const branchOf = (value) => (typeof value === 'string' && branchName(value) ? value : undefined);
|
|
32
33
|
/** The project a Pages address belongs to; null for any other address. */
|
|
33
34
|
export function pagesProject(url) {
|
|
@@ -153,57 +154,98 @@ export function plainJson(text) {
|
|
|
153
154
|
}
|
|
154
155
|
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
155
156
|
}
|
|
156
|
-
/**
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
157
|
+
/** cf, Cloudflare's CLI, at the version this command is built against, run through npx, which installs it on first use. */
|
|
158
|
+
export const CF_PACKAGE = 'cf@1.0.0-beta.8';
|
|
159
|
+
/**
|
|
160
|
+
* cf here: its JSON uncoloured, and an account's command given its account, which cf otherwise asks the person to pick
|
|
161
|
+
* when they have several. `browser` lets its sign-in open one. npx runs it from a folder of the command's own, never the
|
|
162
|
+
* checkout, whose node_modules and .npmrc would choose which cf runs as the person, outside the sandbox: every cf
|
|
163
|
+
* command names its project or its account, so none needs the checkout.
|
|
164
|
+
*/
|
|
165
|
+
export function cfRunner(opts = {}, runner = commandRunner) {
|
|
166
|
+
return (args, { account, signal } = {}) => runner('npx', ownFolder(), { timeoutMs: 120_000, ...opts, signal, env: { NO_COLOR: '1', ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}) } })(['--yes', CF_PACKAGE, ...args]);
|
|
167
|
+
}
|
|
168
|
+
/** The status of the API call a cf command failed on, which its error box names on a line of its own ("│ 403 Forbidden", "│ 404 Not Found · HTTP /accounts/…"); null when it names none. */
|
|
169
|
+
export function cfFailure(err) {
|
|
170
|
+
const said = String(err.stderr ?? '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
171
|
+
const status = /^│?\s*([1-5]\d\d)(?: [A-Z][A-Za-z ]*)?(?: · HTTP \S*)?\s*$/m.exec(said)?.[1];
|
|
172
|
+
return status ? Number(status) : null;
|
|
173
|
+
}
|
|
174
|
+
/** `cf auth whoami`, which prints JSON signed in or out: whether it is signed in with a token that works, its scopes and its accounts. */
|
|
175
|
+
export async function cfStatus(cf, signal) {
|
|
163
176
|
let status;
|
|
164
177
|
try {
|
|
165
|
-
|
|
178
|
+
const printed = await cf(['auth', 'whoami'], { signal });
|
|
179
|
+
status = record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
|
|
166
180
|
}
|
|
167
181
|
catch (err) {
|
|
168
|
-
|
|
169
|
-
return 'missing';
|
|
170
|
-
try {
|
|
171
|
-
status = whoamiOf(err.stdout);
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
return 'unknown';
|
|
175
|
-
}
|
|
182
|
+
return { cli: err.missing ? 'missing' : 'unknown', accounts: [] };
|
|
176
183
|
}
|
|
177
|
-
|
|
184
|
+
// A stored sign-in that neither the user's record nor the accounts answer is one to sign in again.
|
|
185
|
+
if (status?.authenticated === false || (status?.authenticated === true && status.tokenValid === false))
|
|
186
|
+
return { cli: 'signed-out', accounts: [] };
|
|
187
|
+
if (status?.authenticated !== true)
|
|
188
|
+
return { cli: 'unknown', accounts: [] };
|
|
189
|
+
const scopes = Array.isArray(status.scopes) ? status.scopes.filter((s) => typeof s === 'string') : undefined;
|
|
190
|
+
const accounts = list(status.accounts).map((a) => a.id).filter((id) => typeof id === 'string' && /^[0-9a-f]{32}$/.test(id));
|
|
191
|
+
return { cli: 'signed-in', ...(scopes ? { scopes } : {}), accounts };
|
|
178
192
|
}
|
|
193
|
+
/** What a Pages deployment's latest stage says of its build: success once it deployed, and building until a stage failed or it was cancelled or skipped. */
|
|
194
|
+
const stageState = (stage) => stage?.status === 'failure' ? 'failure' : stage?.status === 'canceled' || stage?.status === 'skipped' ? 'canceled' : stage?.status === 'success' && stage.name === 'deploy' ? 'success' : 'building';
|
|
195
|
+
/** A page of cf's list of deployments, and how many it reads of each account at most: the newest pull requests' builds come first. */
|
|
196
|
+
const PER_PAGE = 25;
|
|
197
|
+
const PAGES = 4;
|
|
179
198
|
/**
|
|
180
|
-
* What
|
|
181
|
-
*
|
|
182
|
-
* the
|
|
199
|
+
* What cf lists of the project's preview deployments when it is signed in with Pages access: `pages deployments list`
|
|
200
|
+
* prints them as Cloudflare's API has them, each with its address, the branch's alias, the branch and commit it built,
|
|
201
|
+
* its latest stage and the repository it was built from. Each account the sign-in reaches is read, since another may hold
|
|
202
|
+
* a project of the same name, a page at a time, until the list ends or every head has its build; a deployment Cloudflare
|
|
203
|
+
* built from another repository is not this one's.
|
|
183
204
|
*/
|
|
184
|
-
async function
|
|
185
|
-
const cli = await
|
|
186
|
-
|
|
205
|
+
async function fromCf(project, cf, repository, signal) {
|
|
206
|
+
const { cli, scopes, accounts } = await cfStatus(cf, signal);
|
|
207
|
+
const pages = !scopes || scopes.includes('pages:read') || scopes.includes('pages:write');
|
|
208
|
+
if (cli !== 'signed-in' || !project || !pages)
|
|
187
209
|
return { cli, rows: [], failed: cli === 'unknown' };
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
210
|
+
const same = (value, name) => typeof value === 'string' && value.toLowerCase() === name.toLowerCase();
|
|
211
|
+
const ours = (d) => {
|
|
212
|
+
const source = record(record(d.source)?.config);
|
|
213
|
+
return !source || (same(source.owner, repository.owner) && same(source.repo_name, repository.repo));
|
|
214
|
+
};
|
|
215
|
+
const rows = [];
|
|
216
|
+
const built = () => repository.heads.every((sha) => rows.some((row) => row.sha && sha.startsWith(row.sha)));
|
|
217
|
+
let failed = false;
|
|
218
|
+
for (const account of accounts.length ? accounts : [undefined]) {
|
|
219
|
+
if (rows.length && built())
|
|
220
|
+
break;
|
|
221
|
+
for (let page = 1; page <= PAGES; page++) {
|
|
222
|
+
let listed;
|
|
223
|
+
try {
|
|
224
|
+
const printed = await cf(['pages', 'deployments', 'list', '--project-name', project, '--env', 'preview', '--page', String(page), '--per-page', String(PER_PAGE)], { account, signal });
|
|
225
|
+
listed = list(JSON.parse(printed.slice(printed.indexOf('['), printed.lastIndexOf(']') + 1)));
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
// An account with no such project answers 404; one that could not be read leaves the others' builds standing.
|
|
229
|
+
failed ||= cfFailure(err) !== 404;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
for (const d of listed.filter(ours)) {
|
|
233
|
+
const url = typeof d.url === 'string' && PAGES_ADDRESS.test(d.url) ? d.url : undefined;
|
|
234
|
+
if (!url || d.environment !== 'preview')
|
|
235
|
+
continue;
|
|
236
|
+
const trigger = record(record(d.deployment_trigger)?.metadata);
|
|
237
|
+
const alias = (Array.isArray(d.aliases) ? d.aliases : []).find((a) => typeof a === 'string' && PAGES_ADDRESS.test(a));
|
|
238
|
+
const commit = typeof trigger?.commit_hash === 'string' && /^[0-9a-f]{7,40}$/.test(trigger.commit_hash) ? trigger.commit_hash : undefined;
|
|
239
|
+
rows.push({ ...(branchOf(trigger?.branch) ? { branch: trigger.branch } : {}), ...(commit ? { sha: commit } : {}), url, ...(alias ? { alias } : {}), state: stageState(record(d.latest_stage)), from: 'cf', at: -rows.length });
|
|
240
|
+
}
|
|
241
|
+
if (listed.length < PER_PAGE || built())
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
203
244
|
}
|
|
245
|
+
return { cli, rows, failed };
|
|
204
246
|
}
|
|
205
247
|
/**
|
|
206
|
-
* Whether this repository's pull requests get Cloudflare Pages previews: the newest, as GitHub and
|
|
248
|
+
* Whether this repository's pull requests get Cloudflare Pages previews: the newest, as GitHub and cf have them;
|
|
207
249
|
* with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists.
|
|
208
250
|
*/
|
|
209
251
|
export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
@@ -214,11 +256,11 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
214
256
|
return unread();
|
|
215
257
|
const github = await fromGitHub(owner, repo, deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal }), deps.pull, deps.branch);
|
|
216
258
|
const project = github.previews.map((p) => pagesProject(p.url)).find(Boolean) ?? (await configuredProject(cwd));
|
|
217
|
-
//
|
|
218
|
-
const
|
|
259
|
+
// cf installs itself through npx on a machine without it, which takes a while the first time.
|
|
260
|
+
const cf = await fromCf(project, deps.cf ?? cfRunner(), { owner, repo, heads: github.heads.map((h) => h.sha) }, deps.signal);
|
|
219
261
|
// A row is the head's when its commit is the head's; the pull request's heads say which. A wait for a pushed branch takes its head's rows alone.
|
|
220
262
|
const head = (row) => github.heads.find((h) => row.sha && h.sha.startsWith(row.sha) && (deps.branch === undefined || h.branch === deps.branch));
|
|
221
|
-
const rows =
|
|
263
|
+
const rows = cf.rows.flatMap((row) => {
|
|
222
264
|
const of = head(row);
|
|
223
265
|
if (deps.branch !== undefined && !of)
|
|
224
266
|
return [];
|
|
@@ -226,8 +268,8 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
226
268
|
return [];
|
|
227
269
|
return [of ? { ...row, ...(of.number ? { pullRequest: of.number } : {}), branch: of.branch ?? row.branch, sha: of.sha, at: of.at } : row];
|
|
228
270
|
});
|
|
229
|
-
// Of the same build, the check run outranks
|
|
230
|
-
const rank = (p) => (p.from === 'check run' ? 0 : p.from === '
|
|
271
|
+
// Of the same build, the check run outranks cf's row, and both the app's comment; each address once.
|
|
272
|
+
const rank = (p) => (p.from === 'check run' ? 0 : p.from === 'cf' ? 1 : 2);
|
|
231
273
|
const previews = [];
|
|
232
274
|
for (const p of [...github.previews, ...rows].sort((a, b) => rank(a) - rank(b))) {
|
|
233
275
|
const twin = previews.find((q) => q.url === p.url && (q.pullRequest ?? q.branch) === (p.pullRequest ?? p.branch));
|
|
@@ -236,14 +278,14 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
236
278
|
else if (!twin.alias && p.alias)
|
|
237
279
|
twin.alias = p.alias;
|
|
238
280
|
}
|
|
239
|
-
// The most recently updated pull request's first; a branch with no pull request among them last, as
|
|
281
|
+
// The most recently updated pull request's first; a branch with no pull request among them last, as cf lists it.
|
|
240
282
|
previews.sort((a, b) => (b.pullRequest ? 1 : 0) - (a.pullRequest ? 1 : 0) || b.at - a.at);
|
|
241
283
|
return {
|
|
242
284
|
...(project ? { project } : {}),
|
|
243
285
|
previews: previews.slice(0, deps.branch !== undefined ? previews.length : NEWEST).map(({ at, ...preview }) => preview),
|
|
244
|
-
cli:
|
|
245
|
-
...(github.failed ||
|
|
246
|
-
? { error: "Some of Cloudflare's previews could not be read: what is missing is unknown, not absent.", unread: [...(github.failed ? ['GitHub'] : []), ...(
|
|
286
|
+
cli: cf.cli,
|
|
287
|
+
...(github.failed || cf.failed
|
|
288
|
+
? { error: "Some of Cloudflare's previews could not be read: what is missing is unknown, not absent.", unread: [...(github.failed ? ['GitHub'] : []), ...(cf.failed ? ['cf'] : [])] }
|
|
247
289
|
: {}),
|
|
248
290
|
};
|
|
249
291
|
}
|
package/dist/fence.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { gitEnvironment } from './sandbox.js';
|
|
4
5
|
import { DATA_NOTE, scan, scanRecorder } from './scan.js';
|
|
5
6
|
const allow = (reason = 'on the allowlist') => ({ verdict: 'allow', reason });
|
|
6
7
|
const deny = (reason) => ({ verdict: 'deny', reason });
|
|
@@ -329,7 +330,7 @@ function scriptsIn(dir) {
|
|
|
329
330
|
* package.json and the files the scripts and listed tools load from the checkout by convention (`*.config.js`, `.*rc`): once the agent
|
|
330
331
|
* changes one, what runs is no longer the repository's own code.
|
|
331
332
|
*/
|
|
332
|
-
/**
|
|
333
|
+
/** A rerun on Impeccable's tunnel (first-preview-failure) corrects Vite's preview.allowedHosts and proves it with the local preview check, which runs in its own copy and environment: after that edit only the shell stops. */
|
|
333
334
|
const VITE_CONFIG = /(?:^|\/)vite\.config\.[cm]?[jt]s$/i;
|
|
334
335
|
const LOADED = /(?:^|\/)(?:package\.json|[\w.-]+\.config\.[cm]?[jt]s|\.[\w-]+rc(?:\.\w+)*|tsconfig[^/]*\.json|knexfile\.[^/]+|bunfig\.toml|prisma\/seed\.[^/]+|node_modules\/.+)$/i;
|
|
335
336
|
/** Why scripts and tools stop running: the agent changed a package.json (true when it does not say which), or a file named here that they load. */
|
|
@@ -354,6 +355,8 @@ function tool(args, where, scriptsChanged = false) {
|
|
|
354
355
|
// A version or a tag may follow the name; an alias such as next@npm:other is another package, so it stays on.
|
|
355
356
|
const name = (args[i] ?? '').replace(/@[\w.^~<>=*+-]+$/, '');
|
|
356
357
|
const rest = args.slice(i + 1);
|
|
358
|
+
if (name === 'cf')
|
|
359
|
+
return cloudflareCli(rest);
|
|
357
360
|
if (!TOOLS.has(name))
|
|
358
361
|
return deny(`${name || 'that'} is not on the tool list`);
|
|
359
362
|
if (name === 'playwright' && (rest[0] === 'install-deps' || rest.includes('--with-deps')))
|
|
@@ -364,6 +367,15 @@ function tool(args, where, scriptsChanged = false) {
|
|
|
364
367
|
return deny(changedReason(scriptsChanged));
|
|
365
368
|
return inside(rest, where) ? allow() : deny('a path outside the checkout');
|
|
366
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* cf, Cloudflare's CLI: the tools run it in the command's own process, as the person (its sign-in, the preview
|
|
372
|
+
* deployments, and the service token, whose secret it prints), so none of it runs here, and a delete never does.
|
|
373
|
+
*/
|
|
374
|
+
function cloudflareCli(args) {
|
|
375
|
+
if (args.some((a) => a === 'delete' || a === 'disconnect'))
|
|
376
|
+
return never('deletes on Cloudflare');
|
|
377
|
+
return deny("cf runs in the tools, as the person: impeccable_cloudflare_previews reads Cloudflare's previews, and impeccable_cloudflare_access signs in and makes the service token");
|
|
378
|
+
}
|
|
367
379
|
/** A package manager's install, script or version; with `tools`, also a tool from the list through pnpm exec or yarn. */
|
|
368
380
|
function packageManager(pm, args, where, scriptsChanged, tools = true) {
|
|
369
381
|
const [sub = '', ...rest] = args;
|
|
@@ -417,6 +429,25 @@ function reader(cmd, args, where) {
|
|
|
417
429
|
}
|
|
418
430
|
/** A host's CLI package, by host: the one global install that runs, and only once the person said yes to setting that host up. */
|
|
419
431
|
const HOST_CLIS = { netlify: 'netlify-cli', vercel: 'vercel' };
|
|
432
|
+
/** git commit and push, gh and the host CLIs: what uses the person's GitHub, Vercel, Netlify or Cloudflare sign-in, keys or keychain. */
|
|
433
|
+
function asPerson(words) {
|
|
434
|
+
const [cmd = '', ...args] = words;
|
|
435
|
+
if (cmd === 'git')
|
|
436
|
+
return ['commit', 'push'].includes((args[0] === '--no-pager' ? args[1] : args[0]) ?? '');
|
|
437
|
+
return cmd === 'gh' || cmd === 'vercel' || cmd === 'netlify' || cmd === 'cf';
|
|
438
|
+
}
|
|
439
|
+
/** Where the command runs; null when it joins what runs as the person to what runs in the sandbox, which the fence refuses. cd and git's other commands, which need no sign-in, run on either side. */
|
|
440
|
+
export function placeOf(command) {
|
|
441
|
+
const commands = (parseCommand(command) ?? []).map(({ words }) => words.slice(envCount(words)));
|
|
442
|
+
const installs = commands.filter(([pm = '', ...args]) => PACKAGE_MANAGERS.has(pm) && installsHostCli(pm, args, new Set(Object.keys(HOST_CLIS)))).length;
|
|
443
|
+
if (installs)
|
|
444
|
+
return installs === commands.length ? 'install' : null;
|
|
445
|
+
const person = commands.filter(asPerson).length;
|
|
446
|
+
if (!person)
|
|
447
|
+
return 'sandbox';
|
|
448
|
+
const either = commands.filter((words) => !asPerson(words) && (words[0] === 'cd' || words[0] === 'git')).length;
|
|
449
|
+
return person + either === commands.length ? 'person' : null;
|
|
450
|
+
}
|
|
420
451
|
/** Whether this is a global install of a host's CLI and nothing else, the person having said yes to setting that host up. */
|
|
421
452
|
function installsHostCli(pm, args, hosts) {
|
|
422
453
|
const rest = args.filter((a) => !['-g', '--global', '--location=global'].includes(a));
|
|
@@ -427,14 +458,13 @@ function installsHostCli(pm, args, hosts) {
|
|
|
427
458
|
return global && (verbs[pm] ?? []).includes(verb) && !more.length && Object.entries(HOST_CLIS).some(([host, pkg]) => pkg === cli && hosts.has(host));
|
|
428
459
|
}
|
|
429
460
|
/** The Netlify CLI's options that take a value, of those the rules below allow, so the value is not read as a command. */
|
|
430
|
-
const NETLIFY_VALUES = new Set(['--name', '--
|
|
431
|
-
/** What each of those values may be: a project's name or id, a GitHub repository, a remote's name,
|
|
461
|
+
const NETLIFY_VALUES = new Set(['--name', '--git-remote-url', '--git-remote-name', '--id', '--account-slug']);
|
|
462
|
+
/** What each of those values may be: a project's name or id, a GitHub repository, a remote's name, a team's slug. */
|
|
432
463
|
const NETLIFY_VALID = {
|
|
433
464
|
'--name': /^[A-Za-z0-9-]{1,63}$/,
|
|
434
465
|
'--id': /^[0-9a-f-]{8,64}$/i,
|
|
435
466
|
'--git-remote-url': /^(?:https:\/\/github\.com\/)?[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}?(?:\.git)?$/,
|
|
436
467
|
'--git-remote-name': /^[A-Za-z0-9._-]{1,50}$/,
|
|
437
|
-
'--context': /^deploy-preview$/,
|
|
438
468
|
'--account-slug': /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/,
|
|
439
469
|
};
|
|
440
470
|
/** The Vercel project the folder is linked to, as `vercel link` wrote it in `.vercel/project.json`; null with none. */
|
|
@@ -447,25 +477,15 @@ function linkedVercelProject(dir) {
|
|
|
447
477
|
return null;
|
|
448
478
|
}
|
|
449
479
|
}
|
|
450
|
-
/** Whether the folder is linked to a Netlify site: `.netlify/state.json` names it. */
|
|
451
|
-
function linkedToSite(dir) {
|
|
452
|
-
try {
|
|
453
|
-
return typeof JSON.parse(readFileSync(join(dir, '.netlify', 'state.json'), 'utf8')).siteId === 'string';
|
|
454
|
-
}
|
|
455
|
-
catch {
|
|
456
|
-
return false;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
480
|
/**
|
|
460
481
|
* The Netlify CLI: `--version` and `status` at any time; once the person signed in to Netlify for the setup, the
|
|
461
|
-
* project's link and creation, in the person's own current team or the one team they chose (`team`)
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
* command line.
|
|
482
|
+
* project's link and creation, in the person's own current team or the one team they chose (`team`). The sign-in and
|
|
483
|
+
* the repository's connection are the tools' (a `netlify login` outside the sign-in screen, and a `netlify init` that
|
|
484
|
+
* asks its questions in a terminal, never run). Never a deploy, which builds the repository with the Netlify sign-in,
|
|
485
|
+
* production or a project's removal, and nothing of its environment variables, its API (which reaches them, and prints
|
|
486
|
+
* a site's password: impeccable_netlify_password runs it in the command's own process) or a token on the command line.
|
|
467
487
|
*/
|
|
468
|
-
function netlify(args,
|
|
488
|
+
function netlify(args, agreed, team) {
|
|
469
489
|
const words = [];
|
|
470
490
|
const options = new Map();
|
|
471
491
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -509,10 +529,8 @@ function netlify(args, where, agreed, team) {
|
|
|
509
529
|
case 'sites:create':
|
|
510
530
|
return !rest.length && only('--name', '--json', '--account-slug') ? allow() : deny('a netlify sites:create off the allowlist');
|
|
511
531
|
case 'deploy':
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
// Where no site is linked the CLI creates one with a random name, which is not what the person said yes to.
|
|
515
|
-
return linkedToSite(where.cwd) ? allow() : deny('a netlify deploy in a folder linked to no site');
|
|
532
|
+
// The build runs in the sandbox and the Netlify CLI outside it, as the person: never both at once.
|
|
533
|
+
return deny("netlify deploy builds the repository with the person's Netlify sign-in, and the two never run together");
|
|
516
534
|
default:
|
|
517
535
|
return deny(sub.startsWith('env') ? "Netlify's environment variables are the person's" : `netlify ${sub || 'without a command'} is not on the allowlist`);
|
|
518
536
|
}
|
|
@@ -521,16 +539,8 @@ function netlify(args, where, agreed, team) {
|
|
|
521
539
|
const VERCEL_VALUES = new Set(['--cwd', '--target', '--scope', '-S', '--team']);
|
|
522
540
|
/** The options that name a team: the CLI never picks one without a terminal, so the run names the one the person chose. */
|
|
523
541
|
const VERCEL_TEAM = ['--scope', '-S', '--team'];
|
|
524
|
-
/**
|
|
525
|
-
|
|
526
|
-
* project's link and Git connection, and a preview deploy, on the project named after the folder and in the one team
|
|
527
|
-
* the sign-in settled on (`team`): no other `--scope`, `--team` or `--project`. The sign-in is the tool's (a
|
|
528
|
-
* `vercel login` outside the sign-in screen never runs). Never production or a project's removal, and nothing of its
|
|
529
|
-
* environment variables, its protection settings (their bypass secrets print: impeccable_vercel_bypass runs those in the
|
|
530
|
-
* command's own process, so no secret reaches the model, and `api` and `curl` reach them too) or a token on the command
|
|
531
|
-
* line.
|
|
532
|
-
*/
|
|
533
|
-
function vercel(args, where, agreed, team, settlement = null) {
|
|
542
|
+
/** The Vercel CLI's arguments as its words, the subcommand first, and its options, each with the value it takes. */
|
|
543
|
+
function vercelArgs(args) {
|
|
534
544
|
const words = [];
|
|
535
545
|
const options = new Map();
|
|
536
546
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -545,6 +555,19 @@ function vercel(args, where, agreed, team, settlement = null) {
|
|
|
545
555
|
const value = at > 0 ? arg.slice(at + 1) : VERCEL_VALUES.has(name) && args[i + 1] !== undefined && !args[i + 1].startsWith('-') ? args[++i] : true;
|
|
546
556
|
options.set(name, value);
|
|
547
557
|
}
|
|
558
|
+
return { words, options };
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The Vercel CLI: `whoami` and its version at any time; once the person signed in to Vercel for the setup, the
|
|
562
|
+
* project's link and Git connection, and a preview deploy, on the project named after the folder and in the one team
|
|
563
|
+
* the sign-in settled on (`team`): no other `--scope`, `--team` or `--project`. The sign-in is the tool's (a
|
|
564
|
+
* `vercel login` outside the sign-in screen never runs). Never production or a project's removal, and nothing of its
|
|
565
|
+
* environment variables, its protection settings (their bypass secrets print: impeccable_vercel_bypass runs those in the
|
|
566
|
+
* command's own process, so no secret reaches the model, and `api` and `curl` reach them too) or a token on the command
|
|
567
|
+
* line.
|
|
568
|
+
*/
|
|
569
|
+
function vercel(args, where, agreed, team, settlement = null) {
|
|
570
|
+
const { words, options } = vercelArgs(args);
|
|
548
571
|
const [sub = '', ...rest] = words;
|
|
549
572
|
if ([...options.keys()].some((name) => name === '--token' || /^-t/.test(name)))
|
|
550
573
|
return deny('puts a Vercel token on the command line');
|
|
@@ -662,9 +685,11 @@ function simpleCommand(seg, where, opts) {
|
|
|
662
685
|
case 'find':
|
|
663
686
|
return reader(cmd, args, where);
|
|
664
687
|
case 'netlify':
|
|
665
|
-
return netlify(args,
|
|
688
|
+
return netlify(args, opts.hosts.has('netlify'), opts.teams.get('netlify'));
|
|
666
689
|
case 'vercel':
|
|
667
690
|
return vercel(args, where, opts.hosts.has('vercel'), opts.teams.get('vercel'), opts.vercelSettled);
|
|
691
|
+
case 'cf':
|
|
692
|
+
return cloudflareCli(args);
|
|
668
693
|
default:
|
|
669
694
|
if (PACKAGE_MANAGERS.has(cmd) && installsHostCli(cmd, args, opts.hosts))
|
|
670
695
|
return allow('installs the CLI of the host the person said yes to');
|
|
@@ -692,7 +717,7 @@ function readable(path, where) {
|
|
|
692
717
|
function commitMessages(revisions, cwd) {
|
|
693
718
|
try {
|
|
694
719
|
// The closing -- reads each as a commit even where a file of the same name is there.
|
|
695
|
-
return execFileSync('git', ['log', '--format=%B', ...revisions, '--'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 16 * 1024 * 1024 });
|
|
720
|
+
return execFileSync('git', ['log', '--format=%B', ...revisions, '--'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 16 * 1024 * 1024, env: gitEnvironment() });
|
|
696
721
|
}
|
|
697
722
|
catch {
|
|
698
723
|
return null;
|
|
@@ -778,6 +803,9 @@ export function classifyCommand(command, root, options = {}) {
|
|
|
778
803
|
if (seg.words[0] === 'cd' && decision.verdict === 'allow')
|
|
779
804
|
where.cwd = resolve(where.cwd, seg.words[1]);
|
|
780
805
|
}
|
|
806
|
+
if (result.verdict === 'allow' && placeOf(command) === null) {
|
|
807
|
+
return deny("joins git commit or push, gh, vercel, netlify or a host CLI's install, which run outside the sandbox as the person, to what runs in it: run them as separate commands");
|
|
808
|
+
}
|
|
781
809
|
return result;
|
|
782
810
|
}
|
|
783
811
|
/**
|
|
@@ -871,14 +899,16 @@ export function classifyPreviewCommand(command, root, options = {}) {
|
|
|
871
899
|
}
|
|
872
900
|
return allow('inside the disposable preview verifier');
|
|
873
901
|
}
|
|
874
|
-
/** The fence's answer for pi's file tools: writes stay in the checkout and out of .git; reads stay in it or in the run's scratch folder, and off .env files. */
|
|
902
|
+
/** The fence's answer for pi's file tools: writes stay in the checkout and out of every .git, the checkout's and any other a file could make; reads stay in it or in the run's scratch folder, and off .env files. */
|
|
875
903
|
export function classifyFileTool(tool, path, root, scratch) {
|
|
876
904
|
const where = { root: realPath(root), cwd: root, scratch: scratch && realPath(scratch) };
|
|
877
905
|
const p = typeof path === 'string' && path ? path : '.';
|
|
878
906
|
if (tool === 'write' || tool === 'edit') {
|
|
879
907
|
if (outside(p, where))
|
|
880
908
|
return never('writes outside the checkout');
|
|
881
|
-
|
|
909
|
+
// A .git file or folder below the top would be where git takes its settings and hooks from there.
|
|
910
|
+
const inGit = [p, relative(where.root, realPath(resolve(root, p)))].some((q) => q.split(/[\\/]/).includes('.git'));
|
|
911
|
+
return inGit ? never('writes into .git, where hooks run') : allow();
|
|
882
912
|
}
|
|
883
913
|
if (outside(p, where) && !inScratch(p, where))
|
|
884
914
|
return deny('reads outside the checkout');
|
|
@@ -1087,7 +1117,7 @@ export function observePreviewAttempts(state, runs, historyComplete) {
|
|
|
1087
1117
|
}
|
|
1088
1118
|
/** The hosts the person said yes to setting up in this run, by the run's log. */
|
|
1089
1119
|
const agreedHosts = new WeakMap();
|
|
1090
|
-
function hostsAgreed(log) {
|
|
1120
|
+
export function hostsAgreed(log) {
|
|
1091
1121
|
let hosts = agreedHosts.get(log);
|
|
1092
1122
|
if (!hosts)
|
|
1093
1123
|
agreedHosts.set(log, (hosts = new Set()));
|
|
@@ -1103,6 +1133,42 @@ export function agreeToHostSetup(log, questionId) {
|
|
|
1103
1133
|
export function hostSetupAgreed(log, host) {
|
|
1104
1134
|
return hostsAgreed(log).has(host);
|
|
1105
1135
|
}
|
|
1136
|
+
/** The runs whose `vercel link` left its folder linked to a project: that project may build the next push, so its hold stands until it is settled, whatever ends the setup. */
|
|
1137
|
+
const linkedVercel = new WeakSet();
|
|
1138
|
+
/**
|
|
1139
|
+
* The host's setup ended before any project of this run's could build a push: the sign-in skipped or not completed, the
|
|
1140
|
+
* team skipped, or the link failed. Its CLI stops and the hold on pushes goes, as though the host was never offered; a
|
|
1141
|
+
* Vercel project linked in this run keeps its hold until it is settled.
|
|
1142
|
+
*/
|
|
1143
|
+
export function endHostSetup(log, host) {
|
|
1144
|
+
if (host !== 'vercel' || !linkedVercel.has(log))
|
|
1145
|
+
hostsAgreed(log).delete(host);
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* What the `vercel link`s of a command that ran left, each in the folder it ran in (the one `--cwd` names, else the
|
|
1149
|
+
* shell's, after its `cd`), whatever the command's exit: a folder linked to a project keeps the hold; links that left
|
|
1150
|
+
* their folders linked to none failed, and end the Vercel setup.
|
|
1151
|
+
*/
|
|
1152
|
+
function noteVercelLinks(log, root, command) {
|
|
1153
|
+
let cwd = root;
|
|
1154
|
+
const folders = [];
|
|
1155
|
+
for (const { words } of parseCommand(command) ?? []) {
|
|
1156
|
+
if (words[0] === 'cd')
|
|
1157
|
+
cwd = resolve(cwd, words[1] ?? '.');
|
|
1158
|
+
if (words[0] !== 'vercel')
|
|
1159
|
+
continue;
|
|
1160
|
+
const { words: [sub], options } = vercelArgs(words.slice(1));
|
|
1161
|
+
const folder = options.get('--cwd');
|
|
1162
|
+
if (sub === 'link')
|
|
1163
|
+
folders.push(resolve(cwd, typeof folder === 'string' ? folder : '.'));
|
|
1164
|
+
}
|
|
1165
|
+
if (!folders.length)
|
|
1166
|
+
return;
|
|
1167
|
+
if (folders.some((folder) => linkedVercelProject(folder) !== null))
|
|
1168
|
+
linkedVercel.add(log);
|
|
1169
|
+
else
|
|
1170
|
+
endHostSetup(log, 'vercel');
|
|
1171
|
+
}
|
|
1106
1172
|
/** The team the person picked for each host's CLI in this run, by the run's log. */
|
|
1107
1173
|
const chosenTeams = new WeakMap();
|
|
1108
1174
|
function teamsChosen(log) {
|
|
@@ -1254,6 +1320,8 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
|
|
|
1254
1320
|
if (!['read', 'grep', 'bash'].includes(event.toolName))
|
|
1255
1321
|
return undefined;
|
|
1256
1322
|
const input = event.input;
|
|
1323
|
+
if (event.toolName === 'bash')
|
|
1324
|
+
noteVercelLinks(log, root, String(input.command ?? ''));
|
|
1257
1325
|
const sources = event.toolName === 'bash' ? (parseCommand(String(input.command ?? ''))?.flatMap((seg) => seg.words) ?? []) : [input.path, input.glob].filter((s) => typeof s === 'string');
|
|
1258
1326
|
const hits = scan('input', event.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'), sources);
|
|
1259
1327
|
if (!hits.length)
|
package/dist/host-questions.js
CHANGED
|
@@ -81,22 +81,23 @@ export const HOST_SCREENS = {
|
|
|
81
81
|
prompt: `Netlify keeps your previews behind the site password, and the run could not read it: the Netlify CLI here is signed out, the site is in none of your teams, or Netlify does not give the password back. Find it, or set a new one, in ${NETLIFY_PASSWORD_WHERE}. It is stored encrypted and used only to open previews.`,
|
|
82
82
|
hint: 'Paste the password',
|
|
83
83
|
},
|
|
84
|
-
// Cloudflare Access in front of the previews: the one consent to a service token the run creates as the person, the API token that
|
|
84
|
+
// Cloudflare Access in front of the previews: the one consent to a service token the run creates as the person through cf, the retry of cf's sign-in, the API token where that sign-in cannot create it, and the paste where the run does not create one.
|
|
85
85
|
'cloudflare-access': {
|
|
86
86
|
id: 'cloudflare-access',
|
|
87
87
|
kind: 'confirm',
|
|
88
88
|
title: 'Your Cloudflare previews are protected',
|
|
89
|
-
hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login. Cloudflare\'s way in for tools is a service token made for one tool: the run creates one named "Impeccable reviews" and allows it in your Access application\'s policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.',
|
|
89
|
+
hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login. Cloudflare\'s way in for tools is a service token made for one tool: with your Cloudflare sign-in, the run creates one named "Impeccable reviews" and allows it in your Access application\'s policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.',
|
|
90
90
|
prompt: 'Create the token for Impeccable?',
|
|
91
91
|
confirmLabel: 'Create',
|
|
92
92
|
skipLabel: 'Skip',
|
|
93
93
|
},
|
|
94
|
+
'cloudflare-signin-retry': { id: 'cloudflare-signin-retry', kind: 'confirm', title: 'Sign in to Cloudflare', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
|
|
94
95
|
'cloudflare-access-api': {
|
|
95
96
|
id: 'cloudflare-access-api',
|
|
96
97
|
kind: 'text',
|
|
97
98
|
sensitive: true,
|
|
98
99
|
title: 'One Cloudflare API token',
|
|
99
|
-
prompt: '
|
|
100
|
+
prompt: 'The Cloudflare CLI here could not create the token, so creating it takes a Cloudflare API token, used for this run only and stored nowhere. Make one at dash.cloudflare.com/profile/api-tokens → Create Token → Create Custom Token, with two permissions: Access: Apps and Policies → Edit, and Access: Service Tokens → Edit.',
|
|
100
101
|
hint: 'Paste the API token',
|
|
101
102
|
},
|
|
102
103
|
'cloudflare-access-key': {
|
package/dist/hosted.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { ACCESS_ID_HEADER, ACCESS_SECRET_HEADER } from './cloudflare-access.js';
|
|
4
4
|
import { basicCredentials } from './netlify-password.js';
|
|
5
|
+
import { gitEnvironment } from './sandbox.js';
|
|
5
6
|
import { BYPASS_HEADER } from './vercel-bypass.js';
|
|
6
7
|
/**
|
|
7
8
|
* Previews a host already deploys: what GitHub has on record of the
|
|
@@ -124,15 +125,33 @@ function plain(text) {
|
|
|
124
125
|
/** Whether a file the checkout tracks holds the words; .env files are never read. */
|
|
125
126
|
async function inCheckout(root, words) {
|
|
126
127
|
try {
|
|
127
|
-
await execFileAsync('git', ['grep', '--quiet', '--fixed-strings', '--ignore-case', '-e', words, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000 });
|
|
128
|
+
await execFileAsync('git', ['grep', '--quiet', '--fixed-strings', '--ignore-case', '-e', words, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
|
|
128
129
|
return true;
|
|
129
130
|
}
|
|
130
131
|
catch {
|
|
131
132
|
return false;
|
|
132
133
|
}
|
|
133
134
|
}
|
|
135
|
+
/** What a visitor never sees of a page: comments, scripts, styles and templates. */
|
|
136
|
+
const UNSEEN = /<!--[\s\S]*?-->|<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
|
134
137
|
/** The words a visitor sees: the title and the text, without comments, scripts, styles, tags or attributes. */
|
|
135
|
-
const visible = (html) => plain(html.replace(
|
|
138
|
+
const visible = (html) => plain(html.replace(UNSEEN, ' ').replace(/<[^>]*>/g, ' '));
|
|
139
|
+
/**
|
|
140
|
+
* Netlify's own lock, never the status alone, as Netlify documents it: the basic challenge (`WWW-Authenticate: Basic`)
|
|
141
|
+
* its Basic-Auth rules send, which "trigger the built-in basic browser authentication"
|
|
142
|
+
* (https://docs.netlify.com/manage/security/secure-access-to-sites/basic-authentication-with-custom-http-headers/); the
|
|
143
|
+
* "generic password prompt" of its Password Protection, where a visitor enters a shared password and nothing else
|
|
144
|
+
* (https://docs.netlify.com/manage/security/secure-access-to-sites/password-protection/); or the "Netlify-branded page"
|
|
145
|
+
* its team login shows a visitor (https://docs.netlify.com/manage/security/secure-access-to-sites/project-visibility/),
|
|
146
|
+
* whose title names Netlify other than in the site's own address: a mention in an app's own page, such as a footer, is
|
|
147
|
+
* not one.
|
|
148
|
+
*/
|
|
149
|
+
function netlifyLock(challenge, html) {
|
|
150
|
+
const seen = html.replace(UNSEEN, ' ');
|
|
151
|
+
const fields = [...seen.matchAll(/<input\b[^>]*>/gi)].map(([tag]) => /\stype\s*=\s*["']?([\w-]+)/i.exec(tag)?.[1]?.toLowerCase() ?? 'text').filter((type) => !['hidden', 'submit', 'button', 'reset', 'image'].includes(type));
|
|
152
|
+
const title = plain(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(seen)?.[1] ?? '');
|
|
153
|
+
return /^basic\b/i.test(challenge ?? '') || (/<form\b/i.test(seen) && fields.join() === 'password') || /\bnetlify\b/.test(title.replace(/\S*\.netlify\.app\S*/g, ' '));
|
|
154
|
+
}
|
|
136
155
|
/** The first `max` bytes of a page, as text. */
|
|
137
156
|
async function pageText(res, max = 1_000_000) {
|
|
138
157
|
const reader = res.body?.getReader();
|
|
@@ -181,8 +200,10 @@ async function formOpens(target, password, phrase, open, signal) {
|
|
|
181
200
|
* `words` that a file of the checkout at `root` holds, so the page is this app's and not another site's. With `bypass`, a
|
|
182
201
|
* Vercel bypass secret, a Vercel address is opened with it in the header Vercel reads it from, as reviews do; with
|
|
183
202
|
* `password`, a Netlify site password, a Netlify address is opened with it as basic credentials in the Authorization
|
|
184
|
-
* header, the one way reviews can send it
|
|
185
|
-
*
|
|
203
|
+
* header, the one way reviews can send it, and with `form` posted to Netlify's form too where the header did not get past
|
|
204
|
+
* the lock (only for a site whose settings show a password: an app's own password form looks the same); with `access`, a
|
|
205
|
+
* Cloudflare Access service token, a Pages address is opened with it in the two headers Access reads it from. None goes
|
|
206
|
+
* to another host.
|
|
186
207
|
*/
|
|
187
208
|
export async function provePreview(url, words, root, deps = {}) {
|
|
188
209
|
const unproven = (reason, status = null) => ({ proven: false, status, reason });
|
|
@@ -206,7 +227,7 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
206
227
|
...(withPassword ? { authorization: basicCredentials(deps.password) } : {}),
|
|
207
228
|
...(withToken ? { [ACCESS_ID_HEADER]: deps.access.id, [ACCESS_SECRET_HEADER]: deps.access.secret } : {}),
|
|
208
229
|
};
|
|
209
|
-
// Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it
|
|
230
|
+
// Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it.
|
|
210
231
|
const still = opened ? ' even with the bypass secret' : withPassword ? ' even with the password' : withToken ? ' even with the service token' : '';
|
|
211
232
|
let target = new URL(url);
|
|
212
233
|
try {
|
|
@@ -225,14 +246,14 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
225
246
|
}
|
|
226
247
|
if (res.status === 401 || (host !== 'vercel' && res.status === 403)) {
|
|
227
248
|
// Vercel's own page names Vercel; any other 401 there is the app's own sign-in, which a page a visitor opens without signing in gets past.
|
|
228
|
-
//
|
|
249
|
+
// Netlify's lock shows itself; the app's own 401 or 403 does not, so it gets no site password.
|
|
229
250
|
// Cloudflare Access names itself on the 401 or 403 it answers a request it will not let in (a service token it does not admit), where a browser gets its login.
|
|
230
251
|
const page = await pageText(res, 64_000);
|
|
231
|
-
const named = host === 'netlify' || (host === 'vercel' && /vercel/i.test(page)) || (host === 'cloudflare' && (/^cloudflare-access\b/i.test(res.headers.get('www-authenticate') ?? '') || /cloudflareaccess\.com|cloudflare access/i.test(page)));
|
|
252
|
+
const named = (host === 'netlify' && netlifyLock(res.headers.get('www-authenticate'), page)) || (host === 'vercel' && /vercel/i.test(page)) || (host === 'cloudflare' && (/^cloudflare-access\b/i.test(res.headers.get('www-authenticate') ?? '') || /cloudflareaccess\.com|cloudflare access/i.test(page)));
|
|
232
253
|
if (!named)
|
|
233
254
|
return unproven(res.status === 401 ? "The address asked for the app's own sign-in: try a page a visitor opens without signing in." : `The address answered ${res.status}.`, res.status);
|
|
234
255
|
// A password the header did not get past: Netlify's own form says whether it is the site's password at all.
|
|
235
|
-
if (withPassword && (await formOpens(target, deps.password, phrase, open, signal))) {
|
|
256
|
+
if (withPassword && deps.form && (await formOpens(target, deps.password, phrase, open, signal))) {
|
|
236
257
|
return { ...unproven("The password opens the address on Netlify's own form, but not in the Authorization header, the one way reviews can send it.", res.status), gate, formOpened: true };
|
|
237
258
|
}
|
|
238
259
|
return { ...unproven(`The address asked for ${name}'s login or password${still}.`, res.status), gate };
|