@impeccable/reviews 0.1.7 → 0.1.9
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 +1 -0
- package/dist/bin.js +0 -0
- package/dist/cloudflare.js +26 -4
- package/dist/host-previews.js +16 -0
- package/dist/host-questions.js +21 -1
- package/dist/hosted.js +3 -3
- package/dist/main.js +16 -5
- package/dist/netlify.js +16 -3
- package/dist/playbook/cases/monorepo.md +8 -8
- package/dist/playbook/cases/preview-protection.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +3 -1
- package/dist/playbook/cases/vercel-setup.md +2 -1
- package/dist/playbook.js +1 -1
- package/dist/preflight.js +8 -2
- package/dist/tools.js +486 -103
- package/dist/vercel-bypass.js +14 -10
- package/dist/vercel.js +43 -6
- package/package.json +1 -1
- package/dist/playbook/github-previews.js +0 -101
- package/dist/playbook/netlify-pattern.js +0 -24
- package/dist/playbook/netlify-previews.js +0 -144
- package/dist/playbook/probe-preview.js +0 -67
- package/dist/playbook/protection-secrets.js +0 -15
- package/dist/playbook/public-preview-request.js +0 -96
- package/dist/playbook/publication-wait.js +0 -46
- package/dist/playbook/vercel-protection.js +0 -121
package/dist/vercel-bypass.js
CHANGED
|
@@ -14,10 +14,12 @@ export const BYPASS_HEADER = 'x-vercel-protection-bypass';
|
|
|
14
14
|
export const SECRET_NOTE = 'Impeccable reviews';
|
|
15
15
|
/** Vercel makes its secrets of 32 letters and digits, and takes no other. */
|
|
16
16
|
export const isBypassSecret = (value) => typeof value === 'string' && /^[A-Za-z0-9]{32}$/.test(value);
|
|
17
|
-
/** The CLI's read of the linked project's protection: `protectionBypass` maps each secret to its note and creation time. */
|
|
17
|
+
/** The CLI's read of the linked project's protection, or with `name` that project's: `protectionBypass` maps each secret to its note and creation time. */
|
|
18
18
|
export const READ = ['project', 'protection', '--format', 'json', '--non-interactive'];
|
|
19
|
-
|
|
19
|
+
const readArgs = (name) => (name ? ['project', 'protection', name, ...READ.slice(2)] : READ);
|
|
20
|
+
/** The CLI's own way to make a secret, which carries no label, for a CLI without `api`; with `name`, on that project. */
|
|
20
21
|
export const MAKE_CLI = ['project', 'protection', 'enable', '--protection-bypass', '--non-interactive'];
|
|
22
|
+
const makeArgs = (name) => (name ? ['project', 'protection', 'enable', name, ...MAKE_CLI.slice(3)] : MAKE_CLI);
|
|
21
23
|
/** `vercel api` (beta) for PATCH /v1/projects/{id}/protection-bypass with `generate.note`, so the secret carries the label; a team's project needs its team on the request. */
|
|
22
24
|
export const makeApi = (link) => [
|
|
23
25
|
'api',
|
|
@@ -30,21 +32,23 @@ const record = (value) => (value !== null && typeof value === 'object' && !Array
|
|
|
30
32
|
/** The ids as Vercel makes them, and nothing else goes into a request's path: the file is in the checkout, which the repository and the sandboxed agent write. */
|
|
31
33
|
const PROJECT_ID = /^prj_[A-Za-z0-9]+$/;
|
|
32
34
|
const ORG_ID = /^(?:team_)?[A-Za-z0-9]+$/;
|
|
35
|
+
/** A project and its team or account as Vercel names them, or null for ids that are not Vercel's. */
|
|
36
|
+
export const asLink = (projectId, orgId) => typeof projectId === 'string' && PROJECT_ID.test(projectId) && typeof orgId === 'string' && ORG_ID.test(orgId) ? { projectId, orgId } : null;
|
|
33
37
|
/** The link as the file has it; null with no file, or ids that are not Vercel's. What project the ids name is the CLI's to confirm (readOwnProject in vercel.ts). */
|
|
34
38
|
export async function readLink(cwd) {
|
|
35
39
|
try {
|
|
36
40
|
const link = record(JSON.parse(await readFile(join(cwd, '.vercel', 'project.json'), 'utf8')));
|
|
37
|
-
return
|
|
41
|
+
return asLink(link?.projectId, link?.orgId);
|
|
38
42
|
}
|
|
39
43
|
catch {
|
|
40
44
|
return null;
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
/** The linked project's name and automation secrets; null when the CLI could not say: signed out, not linked, or the role does not allow it; or when what it read is not the project `link` names. */
|
|
44
|
-
async function readProject(run, link) {
|
|
48
|
+
async function readProject(run, link, name) {
|
|
45
49
|
let settings;
|
|
46
50
|
try {
|
|
47
|
-
const printed = await run(
|
|
51
|
+
const printed = await run(readArgs(name));
|
|
48
52
|
settings = record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
|
|
49
53
|
}
|
|
50
54
|
catch {
|
|
@@ -64,7 +68,7 @@ function pick(secrets, before) {
|
|
|
64
68
|
return newest(secrets.filter((s) => s.note === SECRET_NOTE)) ?? (before && newest(secrets.filter((s) => !before.has(s.value))));
|
|
65
69
|
}
|
|
66
70
|
/** Makes the secret with the label through `vercel api`; a CLI without `api` (or one Vercel refuses) falls back to its own command, which the same role check stops. */
|
|
67
|
-
async function make(run, link) {
|
|
71
|
+
async function make(run, link, name) {
|
|
68
72
|
if (link) {
|
|
69
73
|
try {
|
|
70
74
|
await run(makeApi(link));
|
|
@@ -75,7 +79,7 @@ async function make(run, link) {
|
|
|
75
79
|
}
|
|
76
80
|
}
|
|
77
81
|
try {
|
|
78
|
-
await run(
|
|
82
|
+
await run(makeArgs(name));
|
|
79
83
|
return true;
|
|
80
84
|
}
|
|
81
85
|
catch {
|
|
@@ -90,7 +94,7 @@ async function make(run, link) {
|
|
|
90
94
|
*/
|
|
91
95
|
export async function passVercelLogin(deps) {
|
|
92
96
|
const link = await deps.link();
|
|
93
|
-
const project = await readProject(deps.run, link);
|
|
97
|
+
const project = await readProject(deps.run, link, deps.project);
|
|
94
98
|
const from = project?.name ? { project: project.name } : {};
|
|
95
99
|
const finish = async (secret, how) => {
|
|
96
100
|
deps.remember(secret);
|
|
@@ -116,10 +120,10 @@ export async function passVercelLogin(deps) {
|
|
|
116
120
|
if (!(await deps.consent()))
|
|
117
121
|
return { status: 'skipped', reason: 'The person skipped creating a bypass secret.', ...from };
|
|
118
122
|
const before = new Set(project.secrets.map((s) => s.value));
|
|
119
|
-
if (!(await make(deps.run, link)))
|
|
123
|
+
if (!(await make(deps.run, link, deps.project)))
|
|
120
124
|
stopped = 'The Vercel CLI could not make a bypass secret on the project: that takes the Member role or Project Administrator.';
|
|
121
125
|
else {
|
|
122
|
-
const ours = pick((await readProject(deps.run, link))?.secrets ?? [], before);
|
|
126
|
+
const ours = pick((await readProject(deps.run, link, deps.project))?.secrets ?? [], before);
|
|
123
127
|
if (!ours)
|
|
124
128
|
stopped = 'The secret the Vercel CLI made could not be read back.';
|
|
125
129
|
else {
|
package/dist/vercel.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
|
-
import { matchesBranch, TEST_DRIVE_BRANCH } from './host-previews.js';
|
|
5
|
-
import { readLink } from './vercel-bypass.js';
|
|
4
|
+
import { folderOf, matchesBranch, TEST_DRIVE_BRANCH } from './host-previews.js';
|
|
5
|
+
import { asLink, readLink } from './vercel-bypass.js';
|
|
6
6
|
/** `vercel whoami`: the CLI prints the username, or says it has no credentials and exits 1. */
|
|
7
7
|
export async function vercelStatus(vercel) {
|
|
8
8
|
try {
|
|
@@ -135,7 +135,8 @@ export async function readLinkedProject(vercel, link) {
|
|
|
135
135
|
const ignore = project.commandForIgnoringBuildStep;
|
|
136
136
|
// The live app: the production deployment's first alias, the project's own domain, as Vercel lists them (its automatic aliases follow). Its own hashed address is one build's, which the next production deployment leaves behind: never the before.
|
|
137
137
|
const live = target && !failed(target) ? (Array.isArray(target.alias) ? target.alias : []).map(hostname).find(Boolean) : undefined;
|
|
138
|
-
|
|
138
|
+
const root = folderOf(project.rootDirectory);
|
|
139
|
+
return { ...(root !== undefined ? { rootDirectory: root } : {}), ...(typeof project.name === 'string' && project.name ? { name: project.name } : {}), production: exists ? 'exists' : 'none', ...(typeof branch === 'string' && branch ? { productionBranch: branch } : {}), ...(live ? { productionUrl: `https://${live}` } : {}), protection: typeof protection === 'string' ? protection : null, previewsProtected, deploymentHosts: new Set(hosts), ...(repository ? { repository } : {}), ignoredBuildStep: typeof ignore === 'string' && ignore.trim().length > 0, githubDeployments: record(project.gitProviderOptions)?.createDeployments !== 'disabled' };
|
|
139
140
|
}
|
|
140
141
|
/**
|
|
141
142
|
* The linked project as this repository's: read now, and connected to the repository on Vercel, which its Git link
|
|
@@ -150,6 +151,41 @@ export async function readOwnProject(vercel, link, repository) {
|
|
|
150
151
|
return { reason: `The Vercel project this folder is linked to${project.name ? ` (${project.name})` : ''} is not connected to this repository on Vercel.` };
|
|
151
152
|
return project;
|
|
152
153
|
}
|
|
154
|
+
/** Vercel's project names, which go into the CLI's arguments. */
|
|
155
|
+
const PROJECT_NAME = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
156
|
+
/**
|
|
157
|
+
* The Vercel project that builds `directory` of `repository`, on a repository with several apps: of the projects Vercel
|
|
158
|
+
* lists as connected to the repository (GET /v10/projects?repoUrl=), in the team `team` names (the root link's) or the
|
|
159
|
+
* CLI's current scope, the one whose Root Directory is that folder. Never the newest, nor the one the root link names:
|
|
160
|
+
* none, or several, is a `reason`, so one app never borrows another's project.
|
|
161
|
+
*/
|
|
162
|
+
export async function vercelProjectFor(vercel, repository, directory, team) {
|
|
163
|
+
const where = directory ? `the folder ${directory}` : "the repository's root";
|
|
164
|
+
let listed;
|
|
165
|
+
try {
|
|
166
|
+
const query = `repoUrl=${encodeURIComponent(`https://github.com/${repository}`)}&limit=100${team?.startsWith('team_') ? `&teamId=${encodeURIComponent(team)}` : ''}`;
|
|
167
|
+
const printed = await vercel(['api', `/v10/projects?${query}`, '--raw', '--non-interactive']);
|
|
168
|
+
listed = record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)))?.projects;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return { reason: "The Vercel CLI could not list this repository's projects (vercel api): it is signed out, or this CLI has no api command." };
|
|
172
|
+
}
|
|
173
|
+
if (!Array.isArray(listed))
|
|
174
|
+
return { reason: "The Vercel CLI could not list this repository's projects (vercel api): it is signed out, or this CLI has no api command." };
|
|
175
|
+
const builds = listed.map(record).filter((p) => {
|
|
176
|
+
const git = record(p?.link);
|
|
177
|
+
return git?.type === 'github' && `${git.org}/${git.repo}`.toLowerCase() === repository.toLowerCase() && folderOf(p.rootDirectory) === folderOf(directory);
|
|
178
|
+
});
|
|
179
|
+
const found = builds.flatMap((p) => {
|
|
180
|
+
const link = asLink(p.id, p.accountId);
|
|
181
|
+
return link && typeof p.name === 'string' && PROJECT_NAME.test(p.name) ? [{ link, name: p.name }] : [];
|
|
182
|
+
});
|
|
183
|
+
if (builds.length > 1)
|
|
184
|
+
return { reason: `Several Vercel projects build ${where} (${found.map((p) => p.name).join(', ') || 'unnamed'}), so which one is this app's cannot be told.` };
|
|
185
|
+
if (!found.length)
|
|
186
|
+
return { reason: `No Vercel project of this repository builds ${where} (its Root Directory), as far as the Vercel CLI here can see.` };
|
|
187
|
+
return found[0];
|
|
188
|
+
}
|
|
153
189
|
/**
|
|
154
190
|
* Whether the project made the deployment at `host`: one of its latest, as the project read lists them; else, since a
|
|
155
191
|
* project lists its newest deployments alone while an older pull request's preview stays served, the deployment read
|
|
@@ -174,15 +210,16 @@ export async function projectMadeDeployment(vercel, link, project, host) {
|
|
|
174
210
|
* (readOwnProject): another repository's settings are never the fact. Nothing here changes Vercel: the fact is reported.
|
|
175
211
|
* Undefined where the checkout names no Vercel setting and is linked to no project.
|
|
176
212
|
*/
|
|
177
|
-
export async function vercelPreviewsFact(cwd, repository, vercel) {
|
|
213
|
+
export async function vercelPreviewsFact(cwd, repository, vercel, app) {
|
|
178
214
|
let setting;
|
|
179
215
|
try {
|
|
180
|
-
|
|
216
|
+
// An app's project reads the vercel.json of the folder it builds.
|
|
217
|
+
setting = record(record(JSON.parse(await readFile(join(cwd, app?.directory ?? '', 'vercel.json'), 'utf8')))?.git)?.deploymentEnabled;
|
|
181
218
|
}
|
|
182
219
|
catch {
|
|
183
220
|
// No vercel.json, or none that reads as JSON: Vercel's default, every branch.
|
|
184
221
|
}
|
|
185
|
-
const link = await readLink(cwd);
|
|
222
|
+
const link = app ? app.link : await readLink(cwd);
|
|
186
223
|
if (setting === undefined && !link)
|
|
187
224
|
return undefined;
|
|
188
225
|
// The CLI runs only for a linked project, and only this repository's own counts.
|
package/package.json
CHANGED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
4
|
-
const LIMIT = 20;
|
|
5
|
-
const COVERAGE = 'Only the 20 newest GitHub deployments, their latest statuses, and the 20 most recently updated pull requests are checked. A missing match does not prove previews are absent. Empty GitHub deployment history does not prove that a live app or host previews do not exist. A deployment URL does not prove the application rendered.';
|
|
6
|
-
const FAILURE = 'GitHub preview evidence could not be read completely.';
|
|
7
|
-
const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
|
|
8
|
-
function record(value) {
|
|
9
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
10
|
-
? value
|
|
11
|
-
: undefined;
|
|
12
|
-
}
|
|
13
|
-
function positiveInteger(value) {
|
|
14
|
-
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
15
|
-
}
|
|
16
|
-
function sha(value) {
|
|
17
|
-
return typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
|
|
18
|
-
}
|
|
19
|
-
function previewUrl(value) {
|
|
20
|
-
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#]+$/i.test(value))
|
|
21
|
-
return null;
|
|
22
|
-
try {
|
|
23
|
-
const url = new URL(value);
|
|
24
|
-
if (url.username || url.password || url.search || url.hash || /^https:\/\/[^/]*@/i.test(value))
|
|
25
|
-
return null;
|
|
26
|
-
return url.href;
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/** Read a bounded projection of GitHub evidence; never return response bodies or command errors. */
|
|
33
|
-
export async function readHostedPreviews(owner, repo, dependencies = {}) {
|
|
34
|
-
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
|
|
35
|
-
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
36
|
-
return { coverage: COVERAGE, deployments: [], error: FAILURE };
|
|
37
|
-
}
|
|
38
|
-
const runGh = dependencies.runGh ?? (async (args) => {
|
|
39
|
-
const { stdout } = await execFileAsync('gh', args, {
|
|
40
|
-
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
|
|
41
|
-
});
|
|
42
|
-
return stdout;
|
|
43
|
-
});
|
|
44
|
-
const base = `/repos/${owner}/${repo}`;
|
|
45
|
-
async function get(route) {
|
|
46
|
-
if (dependencies.signal?.aborted)
|
|
47
|
-
throw new Error(FAILURE);
|
|
48
|
-
const stdout = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
|
|
49
|
-
if (dependencies.signal?.aborted)
|
|
50
|
-
throw new Error(FAILURE);
|
|
51
|
-
const data = JSON.parse(stdout);
|
|
52
|
-
if (!Array.isArray(data))
|
|
53
|
-
throw new Error(FAILURE);
|
|
54
|
-
return data.slice(0, LIMIT);
|
|
55
|
-
}
|
|
56
|
-
try {
|
|
57
|
-
const [deployments, pulls] = await Promise.all([
|
|
58
|
-
get(`${base}/deployments?per_page=${LIMIT}`),
|
|
59
|
-
get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=${LIMIT}`),
|
|
60
|
-
]);
|
|
61
|
-
let incomplete = false;
|
|
62
|
-
const evidence = [];
|
|
63
|
-
for (const entry of deployments) {
|
|
64
|
-
if (dependencies.signal?.aborted) {
|
|
65
|
-
incomplete = true;
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
const deployment = record(entry);
|
|
69
|
-
if (!deployment || !positiveInteger(deployment.id) || !sha(deployment.sha))
|
|
70
|
-
continue;
|
|
71
|
-
let latest;
|
|
72
|
-
try {
|
|
73
|
-
latest = record((await get(`${base}/deployments/${deployment.id}/statuses?per_page=1`))[0]);
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
incomplete = true;
|
|
77
|
-
if (dependencies.signal?.aborted)
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
const status = STATES.find((state) => state === latest?.state) ?? null;
|
|
81
|
-
const environment = typeof deployment.environment === 'string' && deployment.environment.length <= 100
|
|
82
|
-
&& !/[\u0000-\u001f\u007f]/.test(deployment.environment) ? deployment.environment : null;
|
|
83
|
-
const pullRequests = [];
|
|
84
|
-
for (const item of pulls) {
|
|
85
|
-
const pull = record(item);
|
|
86
|
-
if (pull && positiveInteger(pull.number) && (pull.state === 'open' || pull.state === 'closed')
|
|
87
|
-
&& record(pull.head)?.sha === deployment.sha) {
|
|
88
|
-
pullRequests.push({ number: pull.number, state: pull.state });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
evidence.push({ id: deployment.id, sha: deployment.sha, environment, status,
|
|
92
|
-
previewUrl: status ? previewUrl(latest?.environment_url) : null, pullRequests });
|
|
93
|
-
}
|
|
94
|
-
incomplete ||= dependencies.signal?.aborted ?? false;
|
|
95
|
-
return { coverage: COVERAGE, deployments: evidence,
|
|
96
|
-
...(incomplete ? { error: FAILURE } : { emptyDeploymentHistory: deployments.length === 0 }) };
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return { coverage: COVERAGE, deployments: [], error: FAILURE };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** Keep Netlify's repository-wide pattern tied to successful discovery evidence. */
|
|
2
|
-
export function netlifyPatternError(body, observed) {
|
|
3
|
-
const pattern = body.previewUrlPattern;
|
|
4
|
-
if (typeof pattern !== 'string')
|
|
5
|
-
return null;
|
|
6
|
-
let netlify = /netlify\.app|<site>/i.test(pattern);
|
|
7
|
-
try {
|
|
8
|
-
const hostname = new URL(pattern.replaceAll('{pr}', '1')).hostname;
|
|
9
|
-
netlify ||= hostname === 'netlify.app' || hostname.endsWith('.netlify.app');
|
|
10
|
-
}
|
|
11
|
-
catch { /* The canonical validation below rejects malformed Netlify patterns. */ }
|
|
12
|
-
if (!netlify)
|
|
13
|
-
return null;
|
|
14
|
-
const match = /^https:\/\/deploy-preview-\{pr\}--([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.netlify\.app$/.exec(pattern);
|
|
15
|
-
if (!match || match[1].length > 63)
|
|
16
|
-
return 'Use a real Netlify site pattern from successful preview evidence; placeholders and malformed patterns are not saved.';
|
|
17
|
-
if (Object.hasOwn(body, 'appKey'))
|
|
18
|
-
return 'A Netlify preview URL pattern belongs to the repository; omit appKey.';
|
|
19
|
-
if (body.intendedMethod !== 'preview-deploy')
|
|
20
|
-
return 'Save a Netlify pattern with intendedMethod preview-deploy.';
|
|
21
|
-
if (!observed.has(pattern))
|
|
22
|
-
return 'Read this repository\'s Netlify previews first and choose a pattern backed by a successful current-head status.';
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
4
|
-
const FAILURE = 'Netlify preview evidence could not be read completely.';
|
|
5
|
-
const COVERAGE = 'Only the 20 most recently updated pull requests, the newest 100 statuses on each exact head SHA, and the 100 most recently updated repository issue comments are checked. Context-derived URLs do not prove the application rendered. Bot comments are historical candidates, not current-head deployment proof. Missing evidence does not prove previews are absent.';
|
|
6
|
-
const SITE = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
7
|
-
const CONTEXT = new RegExp(`^netlify/(${SITE})/deploy-preview$`);
|
|
8
|
-
const PREVIEW_HOST = new RegExp(`^deploy-preview-([1-9][0-9]*)--(${SITE})\\.netlify\\.app$`);
|
|
9
|
-
const STATES = ['success', 'pending', 'failure', 'error'];
|
|
10
|
-
function record(value) {
|
|
11
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
12
|
-
? value : undefined;
|
|
13
|
-
}
|
|
14
|
-
function cleanUrl(value) {
|
|
15
|
-
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#@]+$/i.test(value))
|
|
16
|
-
return;
|
|
17
|
-
try {
|
|
18
|
-
const url = new URL(value);
|
|
19
|
-
if (!url.username && !url.password && !url.port && !url.search && !url.hash)
|
|
20
|
-
return url;
|
|
21
|
-
}
|
|
22
|
-
catch { /* Discard unsafe evidence without returning it. */ }
|
|
23
|
-
}
|
|
24
|
-
function urls(pull, site) {
|
|
25
|
-
if (`deploy-preview-${pull.number}--${site}`.length > 63)
|
|
26
|
-
return;
|
|
27
|
-
return {
|
|
28
|
-
previewUrl: `https://deploy-preview-${pull.number}--${site}.netlify.app/`,
|
|
29
|
-
previewUrlPattern: `https://deploy-preview-{pr}--${site}.netlify.app`,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
function validTarget(value, pull, site) {
|
|
33
|
-
if (value === null || value === undefined || value === '')
|
|
34
|
-
return true;
|
|
35
|
-
const target = cleanUrl(value);
|
|
36
|
-
if (!target)
|
|
37
|
-
return false;
|
|
38
|
-
if (target.hostname === `deploy-preview-${pull.number}--${site}.netlify.app` && target.pathname === '/')
|
|
39
|
-
return true;
|
|
40
|
-
return target.hostname === 'app.netlify.com'
|
|
41
|
-
&& new RegExp(`^/(?:sites|projects)/${site}/deploys(?:/[a-zA-Z0-9_-]+)?/?$`).test(target.pathname);
|
|
42
|
-
}
|
|
43
|
-
/** Read a bounded, repository-scoped projection; never expose comments, command errors or credentials. */
|
|
44
|
-
export async function readNetlifyPreviews(owner, repo, dependencies = {}) {
|
|
45
|
-
const previews = [];
|
|
46
|
-
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
|
|
47
|
-
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
48
|
-
return { coverage: COVERAGE, previews, error: FAILURE };
|
|
49
|
-
}
|
|
50
|
-
const runGh = dependencies.runGh ?? (async (args) => {
|
|
51
|
-
const { stdout } = await execFileAsync('gh', args, {
|
|
52
|
-
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
|
|
53
|
-
});
|
|
54
|
-
return stdout;
|
|
55
|
-
});
|
|
56
|
-
const base = `/repos/${owner}/${repo}`;
|
|
57
|
-
async function get(route, limit) {
|
|
58
|
-
if (dependencies.signal?.aborted)
|
|
59
|
-
throw new Error();
|
|
60
|
-
const raw = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
|
|
61
|
-
if (dependencies.signal?.aborted)
|
|
62
|
-
throw new Error();
|
|
63
|
-
const data = JSON.parse(raw);
|
|
64
|
-
if (!Array.isArray(data))
|
|
65
|
-
throw new Error();
|
|
66
|
-
return data.slice(0, limit);
|
|
67
|
-
}
|
|
68
|
-
const [pullRead, commentRead] = await Promise.allSettled([
|
|
69
|
-
get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=20`, 20),
|
|
70
|
-
get(`${base}/issues/comments?sort=updated&direction=desc&per_page=100`, 100),
|
|
71
|
-
]);
|
|
72
|
-
if (pullRead.status === 'rejected')
|
|
73
|
-
return { coverage: COVERAGE, previews, error: FAILURE };
|
|
74
|
-
let incomplete = false;
|
|
75
|
-
const warnings = commentRead.status === 'rejected'
|
|
76
|
-
? ['Historical Netlify bot comments could not be read. Current-head status evidence is still checked.'] : [];
|
|
77
|
-
const pulls = new Map();
|
|
78
|
-
for (const item of pullRead.value) {
|
|
79
|
-
const pull = record(item);
|
|
80
|
-
const headSha = record(pull?.head)?.sha;
|
|
81
|
-
if (!pull || typeof pull.number !== 'number' || !Number.isSafeInteger(pull.number) || pull.number <= 0
|
|
82
|
-
|| (pull.state !== 'open' && pull.state !== 'closed') || typeof headSha !== 'string' || !/^[a-f0-9]{40}$/.test(headSha))
|
|
83
|
-
continue;
|
|
84
|
-
const issueUrl = `https://api.github.com${base}/issues/${pull.number}`;
|
|
85
|
-
if (!pulls.has(issueUrl))
|
|
86
|
-
pulls.set(issueUrl, { number: pull.number, state: pull.state, headSha });
|
|
87
|
-
}
|
|
88
|
-
const seen = new Set();
|
|
89
|
-
const statusesBySha = new Map();
|
|
90
|
-
for (const pull of pulls.values()) {
|
|
91
|
-
if (dependencies.signal?.aborted) {
|
|
92
|
-
incomplete = true;
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
try {
|
|
96
|
-
if (!statusesBySha.has(pull.headSha)) {
|
|
97
|
-
statusesBySha.set(pull.headSha, get(`${base}/commits/${pull.headSha}/statuses?per_page=100`, 100));
|
|
98
|
-
}
|
|
99
|
-
for (const item of await statusesBySha.get(pull.headSha)) {
|
|
100
|
-
const status = record(item);
|
|
101
|
-
const site = typeof status?.context === 'string' ? CONTEXT.exec(status.context)?.[1] : undefined;
|
|
102
|
-
if (!site || seen.has(`${pull.number}:${site}`))
|
|
103
|
-
continue;
|
|
104
|
-
// GitHub returns newest first: even an invalid latest entry must hide older success.
|
|
105
|
-
seen.add(`${pull.number}:${site}`);
|
|
106
|
-
const state = STATES.find((state) => state === status?.state);
|
|
107
|
-
const canonical = urls(pull, site);
|
|
108
|
-
if (!state || !canonical || !validTarget(status?.target_url, pull, site))
|
|
109
|
-
continue;
|
|
110
|
-
previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
|
|
111
|
-
status: state, ...canonical, source: 'commit-status', matchesHead: true });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
incomplete = true;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (!dependencies.signal?.aborted && commentRead.status === 'fulfilled') {
|
|
119
|
-
for (const item of commentRead.value) {
|
|
120
|
-
const comment = record(item);
|
|
121
|
-
const author = record(comment?.user);
|
|
122
|
-
const pull = typeof comment?.issue_url === 'string' ? pulls.get(comment.issue_url) : undefined;
|
|
123
|
-
if (!pull || author?.login !== 'netlify[bot]' || author.type !== 'Bot'
|
|
124
|
-
|| typeof comment?.body !== 'string' || comment.body.length > 65_536)
|
|
125
|
-
continue;
|
|
126
|
-
for (const match of comment.body.matchAll(/https:\/\/[^\s<>"'()[\]`]+/gi)) {
|
|
127
|
-
const url = cleanUrl(match[0].replace(/[.,;:]+$/, ''));
|
|
128
|
-
const parts = url ? PREVIEW_HOST.exec(url.hostname) : null;
|
|
129
|
-
if (!url || url.pathname !== '/' || !parts || parts[1] !== String(pull.number))
|
|
130
|
-
continue;
|
|
131
|
-
const site = parts[2];
|
|
132
|
-
const canonical = urls(pull, site);
|
|
133
|
-
if (!canonical || seen.has(`${pull.number}:${site}`))
|
|
134
|
-
continue;
|
|
135
|
-
seen.add(`${pull.number}:${site}`);
|
|
136
|
-
previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
|
|
137
|
-
status: null, ...canonical, source: 'bot-comment', matchesHead: false });
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (dependencies.signal?.aborted)
|
|
142
|
-
incomplete = true;
|
|
143
|
-
return { coverage: COVERAGE, previews, ...(warnings.length ? { warnings } : {}), ...(incomplete ? { error: FAILURE } : {}) };
|
|
144
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
function redirectGate(location, source) {
|
|
2
|
-
if (!location)
|
|
3
|
-
return 'redirected';
|
|
4
|
-
try {
|
|
5
|
-
const target = new URL(location, source);
|
|
6
|
-
if (target.protocol !== 'https:')
|
|
7
|
-
return 'redirected';
|
|
8
|
-
if (target.origin === 'https://vercel.com' && target.pathname === '/sso-api'
|
|
9
|
-
&& target.searchParams.has('url'))
|
|
10
|
-
return 'vercel-protection';
|
|
11
|
-
if ((target.hostname === 'vercel.com' || target.hostname.endsWith('.vercel.com'))
|
|
12
|
-
&& /^\/login(?:\/|$)/.test(target.pathname))
|
|
13
|
-
return 'vercel-protection';
|
|
14
|
-
if (target.hostname.endsWith('.cloudflareaccess.com'))
|
|
15
|
-
return 'cloudflare-access';
|
|
16
|
-
if (target.origin === source.origin && /^\/(?:login|sign-in|signin)(?:\/|$)/.test(target.pathname)) {
|
|
17
|
-
return 'app-sign-in';
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
// A malformed redirect reveals nothing about preview protection.
|
|
22
|
-
}
|
|
23
|
-
return 'redirected';
|
|
24
|
-
}
|
|
25
|
-
/** An unauthenticated HTTP check only: reachable does not prove this is the intended app. */
|
|
26
|
-
export async function probePreview(url, dependencies = {}) {
|
|
27
|
-
let target;
|
|
28
|
-
try {
|
|
29
|
-
target = new URL(url);
|
|
30
|
-
if (!isPlatformPreview(target)
|
|
31
|
-
|| /[?#\\\s]/.test(url) || !/^https:\/\//i.test(url))
|
|
32
|
-
throw new Error();
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return { status: null, gate: 'unavailable', error: 'invalid-preview-url' };
|
|
36
|
-
}
|
|
37
|
-
const controller = new AbortController();
|
|
38
|
-
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
39
|
-
const signal = dependencies.signal
|
|
40
|
-
? AbortSignal.any([controller.signal, dependencies.signal])
|
|
41
|
-
: controller.signal;
|
|
42
|
-
try {
|
|
43
|
-
signal.throwIfAborted();
|
|
44
|
-
const response = dependencies.fetch ? await dependencies.fetch(target, {
|
|
45
|
-
method: 'GET',
|
|
46
|
-
headers: { accept: 'text/html' },
|
|
47
|
-
credentials: 'omit',
|
|
48
|
-
redirect: 'manual',
|
|
49
|
-
signal,
|
|
50
|
-
}) : await publicPreviewRequest(target, signal);
|
|
51
|
-
const status = response.status;
|
|
52
|
-
const gate = status === 401 && response.headers.get('x-vercel-error') === 'UNAUTHORIZED'
|
|
53
|
-
? 'vercel-protection' : status >= 300 && status < 400
|
|
54
|
-
? redirectGate(response.headers.get('location'), target)
|
|
55
|
-
: status >= 200 && status < 300 ? 'reachable' : 'unavailable';
|
|
56
|
-
// Bodies and redirect details may contain credentials; never read or return them.
|
|
57
|
-
await response.body?.cancel().catch(() => undefined);
|
|
58
|
-
return { status, gate };
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
return { status: null, gate: 'unavailable', error: 'request-failed' };
|
|
62
|
-
}
|
|
63
|
-
finally {
|
|
64
|
-
clearTimeout(timeout);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
import { isPlatformPreview, publicPreviewRequest } from './public-preview-request.js';
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/** Register credential-shaped write fields before a message can be recorded.
|
|
2
|
-
* Do not trust the requested path: even a refused or malformed destination must
|
|
3
|
-
* keep its supplied credentials private. Values remain unchanged in memory.
|
|
4
|
-
*/
|
|
5
|
-
export function protectionWriteSecrets(input) {
|
|
6
|
-
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
7
|
-
return [];
|
|
8
|
-
const body = input.body;
|
|
9
|
-
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
10
|
-
return [];
|
|
11
|
-
return ['value', 'clientId', 'clientSecret']
|
|
12
|
-
.map((key) => body[key])
|
|
13
|
-
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
14
|
-
.sort((a, b) => b.length - a.length);
|
|
15
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { lookup } from 'node:dns/promises';
|
|
2
|
-
import { request } from 'node:https';
|
|
3
|
-
import { BlockList, isIP } from 'node:net';
|
|
4
|
-
const blockedV4 = new BlockList();
|
|
5
|
-
for (const [address, prefix] of [
|
|
6
|
-
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
7
|
-
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
|
8
|
-
['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
|
|
9
|
-
['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
10
|
-
])
|
|
11
|
-
blockedV4.addSubnet(address, prefix, 'ipv4');
|
|
12
|
-
const publicV6 = new BlockList();
|
|
13
|
-
publicV6.addSubnet('2000::', 3, 'ipv6');
|
|
14
|
-
const blockedV6 = new BlockList();
|
|
15
|
-
// Conservatively exclude special-purpose, transition and documentation prefixes.
|
|
16
|
-
for (const [address, prefix] of [
|
|
17
|
-
['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20],
|
|
18
|
-
])
|
|
19
|
-
blockedV6.addSubnet(address, prefix, 'ipv6');
|
|
20
|
-
export function isPlatformPreview(target) {
|
|
21
|
-
return target.protocol === 'https:' && !target.username && !target.password && !target.port
|
|
22
|
-
&& !/[?#]/.test(target.href)
|
|
23
|
-
&& /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:vercel|netlify)\.app$/.test(target.hostname);
|
|
24
|
-
}
|
|
25
|
-
function isPublicAddress(value) {
|
|
26
|
-
if (!value || typeof value.address !== 'string' || value.address.includes('%'))
|
|
27
|
-
return false;
|
|
28
|
-
const family = isIP(value.address);
|
|
29
|
-
if (family !== value.family)
|
|
30
|
-
return false;
|
|
31
|
-
if (family === 4)
|
|
32
|
-
return !blockedV4.check(value.address, 'ipv4');
|
|
33
|
-
return family === 6 && publicV6.check(value.address, 'ipv6') && !blockedV6.check(value.address, 'ipv6');
|
|
34
|
-
}
|
|
35
|
-
/** Resolve once, validate every answer, then connect only to the pinned public addresses. */
|
|
36
|
-
export async function publicPreviewRequest(target, signal, dependencies = {}) {
|
|
37
|
-
try {
|
|
38
|
-
if (!isPlatformPreview(target))
|
|
39
|
-
throw new Error();
|
|
40
|
-
signal.throwIfAborted();
|
|
41
|
-
let abortDns = () => { };
|
|
42
|
-
const aborted = new Promise((_resolve, reject) => {
|
|
43
|
-
abortDns = () => reject(new Error());
|
|
44
|
-
signal.addEventListener('abort', abortDns, { once: true });
|
|
45
|
-
});
|
|
46
|
-
const addresses = await Promise.race([
|
|
47
|
-
(dependencies.lookup ?? lookup)(target.hostname, { all: true, verbatim: true }), aborted,
|
|
48
|
-
]).finally(() => signal.removeEventListener('abort', abortDns));
|
|
49
|
-
signal.throwIfAborted();
|
|
50
|
-
if (!Array.isArray(addresses) || !addresses.length || !addresses.every(isPublicAddress))
|
|
51
|
-
throw new Error();
|
|
52
|
-
const pinned = addresses.map(({ address, family }) => ({ address, family }));
|
|
53
|
-
return await new Promise((resolve, reject) => {
|
|
54
|
-
const options = {
|
|
55
|
-
method: 'GET', headers: { accept: 'text/html' }, signal,
|
|
56
|
-
agent: false, autoSelectFamily: true, rejectUnauthorized: true,
|
|
57
|
-
lookup(hostname, options, callback) {
|
|
58
|
-
if (hostname !== target.hostname)
|
|
59
|
-
return callback(new Error('Preview request failed'), '');
|
|
60
|
-
if (options.all)
|
|
61
|
-
callback(null, pinned.map((address) => ({ ...address })));
|
|
62
|
-
else
|
|
63
|
-
callback(null, pinned[0].address, pinned[0].family);
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
const req = (dependencies.request ?? request)(target, options, (response) => {
|
|
67
|
-
response.once('error', () => { });
|
|
68
|
-
try {
|
|
69
|
-
const status = response.statusCode;
|
|
70
|
-
const headers = new Headers();
|
|
71
|
-
for (const name of ['location', 'x-vercel-error']) {
|
|
72
|
-
const value = response.headers[name];
|
|
73
|
-
if (typeof value === 'string')
|
|
74
|
-
headers.set(name, value);
|
|
75
|
-
}
|
|
76
|
-
if (!status)
|
|
77
|
-
reject(new Error());
|
|
78
|
-
else
|
|
79
|
-
resolve({ status, headers, body: null });
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
reject(new Error());
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
response.destroy();
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
req.once('error', () => reject(new Error()));
|
|
89
|
-
req.once('upgrade', (_response, socket) => { socket.destroy(); reject(new Error()); });
|
|
90
|
-
req.end();
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
throw new Error('Preview request failed');
|
|
95
|
-
}
|
|
96
|
-
}
|