@impeccable/reviews 0.1.8 → 0.1.10
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 +2 -2
- package/dist/bin.js +0 -0
- package/dist/host-questions.js +25 -1
- package/dist/main.js +8 -5
- package/dist/playbook/cases/monorepo.md +4 -4
- package/dist/playbook/cases/preview-protection.md +1 -1
- package/dist/playbook/cases/vercel-previews.md +2 -0
- package/dist/playbook/cases/vercel-setup.md +1 -1
- package/dist/tools.js +249 -39
- package/dist/vercel-bypass.js +80 -15
- package/dist/vercel.js +14 -3
- 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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
|
+
import { redactShapes } from './scan.js';
|
|
3
5
|
/**
|
|
4
6
|
* Getting past Vercel's Deployment Protection the way Vercel documents it for tools: Protection Bypass for Automation, a
|
|
5
7
|
* secret of the project that a request sends in the x-vercel-protection-bypass header (every plan has it; a team member
|
|
@@ -67,24 +69,71 @@ function pick(secrets, before) {
|
|
|
67
69
|
const newest = (list) => [...list].sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
68
70
|
return newest(secrets.filter((s) => s.note === SECRET_NOTE)) ?? (before && newest(secrets.filter((s) => !before.has(s.value))));
|
|
69
71
|
}
|
|
70
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* What a failed CLI run printed, as one line for a reason the screen and the model read: Vercel's own error message
|
|
74
|
+
* where the output is its JSON, else the first line that says "Error"; nothing shaped like a secret, which a CLI can
|
|
75
|
+
* echo back from its arguments, since the reason goes to the model and the transcript.
|
|
76
|
+
*/
|
|
77
|
+
export function cliSaid(err) {
|
|
78
|
+
const printed = `${String(err.stdout ?? '')}\n${String(err.stderr ?? '')}`;
|
|
79
|
+
const json = printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1);
|
|
80
|
+
let said = '';
|
|
81
|
+
try {
|
|
82
|
+
const error = record(record(JSON.parse(json))?.error);
|
|
83
|
+
if (typeof error?.message === 'string' && error.message)
|
|
84
|
+
said = error.message.trim();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Not JSON: the lines below.
|
|
88
|
+
}
|
|
89
|
+
if (!said) {
|
|
90
|
+
const line = printed.split('\n').map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim()).find((l) => /error/i.test(l));
|
|
91
|
+
said = (line ?? '').replace(/^error[:!]?\s*/i, '');
|
|
92
|
+
}
|
|
93
|
+
return redactShapes(said).slice(0, 200);
|
|
94
|
+
}
|
|
95
|
+
/** Vercel's answer to a role that may not make the secret: 403, or its words for it. */
|
|
96
|
+
const REFUSED = /forbidden|not authorized|not_authorized|permission|\b403\b/i;
|
|
97
|
+
/**
|
|
98
|
+
* Makes the secret with the label through `vercel api`; a CLI without `api` (or one Vercel refuses) falls back to its own
|
|
99
|
+
* command, which the same role check stops. Once made, which kind the project now holds (labelled through `api`, or the
|
|
100
|
+
* CLI's own without a label); else why not, in Vercel's words where it gave any, the role named only where Vercel refused.
|
|
101
|
+
*/
|
|
71
102
|
async function make(run, link, name) {
|
|
103
|
+
const said = [];
|
|
72
104
|
if (link) {
|
|
73
105
|
try {
|
|
74
106
|
await run(makeApi(link));
|
|
75
|
-
return
|
|
107
|
+
return { made: 'labelled' };
|
|
76
108
|
}
|
|
77
|
-
catch {
|
|
109
|
+
catch (err) {
|
|
78
110
|
// The CLI's own command below fails the same way when the role is missing, and works when `api` is the one missing.
|
|
111
|
+
said.push(cliSaid(err));
|
|
79
112
|
}
|
|
80
113
|
}
|
|
81
114
|
try {
|
|
82
115
|
await run(makeArgs(name));
|
|
83
|
-
return
|
|
116
|
+
return { made: 'unlabelled' };
|
|
84
117
|
}
|
|
85
|
-
catch {
|
|
86
|
-
|
|
118
|
+
catch (err) {
|
|
119
|
+
said.push(cliSaid(err));
|
|
87
120
|
}
|
|
121
|
+
const words = said.filter(Boolean).at(-1);
|
|
122
|
+
const detail = words ? ` (Vercel said: ${words})` : '';
|
|
123
|
+
return {
|
|
124
|
+
reason: REFUSED.test(said.join('\n'))
|
|
125
|
+
? `Vercel refused to make a bypass secret on the project for the Vercel CLI here${detail}: that takes the Member role, or Project Administrator.`
|
|
126
|
+
: `The Vercel CLI here could not make a bypass secret on the project${detail}.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** The proof again, while Vercel's login still stands in front of the address, until the edge takes a secret made moments ago or the wait runs out; any other answer stands at once. */
|
|
130
|
+
async function admitted(secret, deps) {
|
|
131
|
+
const wait = deps.wait ?? { totalMs: 30_000, everyMs: 3_000 };
|
|
132
|
+
let proof = await deps.prove(secret);
|
|
133
|
+
for (const end = Date.now() + wait.totalMs; !proof.proven && proof.gate === 'vercel-protection' && Date.now() < end && !deps.signal?.aborted; proof = await deps.prove(secret)) {
|
|
134
|
+
await sleep(wait.everyMs, undefined, { signal: deps.signal });
|
|
135
|
+
}
|
|
136
|
+
return proof;
|
|
88
137
|
}
|
|
89
138
|
/**
|
|
90
139
|
* Gets a bypass secret in place for the address Vercel's login stands in front of, and saves only one that opens it. The
|
|
@@ -96,46 +145,62 @@ export async function passVercelLogin(deps) {
|
|
|
96
145
|
const link = await deps.link();
|
|
97
146
|
const project = await readProject(deps.run, link, deps.project);
|
|
98
147
|
const from = project?.name ? { project: project.name } : {};
|
|
148
|
+
const wait = deps.wait ?? { totalMs: 30_000, everyMs: 3_000 };
|
|
99
149
|
const finish = async (secret, how) => {
|
|
100
150
|
deps.remember(secret);
|
|
101
|
-
|
|
151
|
+
// A secret made moments ago may take the edge a few seconds: its proof is tried again while the login stands.
|
|
152
|
+
const proof = how === 'created' ? await admitted(secret, { ...deps, wait }) : await deps.prove(secret);
|
|
102
153
|
if (!proof.proven)
|
|
103
154
|
return { reason: proof.reason ?? 'The address did not open with the secret.' };
|
|
104
155
|
const failed = await deps.save(secret);
|
|
105
156
|
return failed ? { status: 'save-failed', reason: failed, ...from } : { status: 'saved', secret: how, ...from, value: secret };
|
|
106
157
|
};
|
|
107
|
-
/** Why the CLI's way ended without a secret that opens the preview, for the paste screen
|
|
158
|
+
/** Why the CLI's way ended without a secret that opens the preview, for the paste screen and its skip. */
|
|
108
159
|
let stopped;
|
|
160
|
+
/** What the project holds for the person to copy rather than make another: a secret labelled for Impeccable (its own, or one this run made), or one the CLI's own command made without a label. */
|
|
161
|
+
let held = 'none';
|
|
109
162
|
if (!project)
|
|
110
163
|
stopped = 'The Vercel CLI could not read the project: it is signed out, this folder is not linked to the project, or the role does not allow it.';
|
|
111
164
|
else {
|
|
112
165
|
const own = pick(project.secrets);
|
|
113
166
|
if (own) {
|
|
167
|
+
held = 'labelled';
|
|
114
168
|
const done = await finish(own.value, 'existing');
|
|
115
169
|
if ('status' in done)
|
|
116
170
|
return done;
|
|
117
|
-
stopped = done.reason
|
|
171
|
+
stopped = `The project's secret labelled "${SECRET_NOTE}" did not open the preview: ${done.reason}`;
|
|
118
172
|
}
|
|
119
173
|
else {
|
|
120
174
|
if (!(await deps.consent()))
|
|
121
175
|
return { status: 'skipped', reason: 'The person skipped creating a bypass secret.', ...from };
|
|
122
176
|
const before = new Set(project.secrets.map((s) => s.value));
|
|
123
|
-
|
|
124
|
-
|
|
177
|
+
const made = await make(deps.run, link, deps.project);
|
|
178
|
+
if ('reason' in made)
|
|
179
|
+
stopped = made.reason;
|
|
125
180
|
else {
|
|
126
|
-
|
|
181
|
+
// The project holds the secret from here on, whatever the reads below say: the screen says to copy it, never to make another.
|
|
182
|
+
held = made.made;
|
|
183
|
+
// Read back by its label, again for a few seconds while the settings do not list it yet.
|
|
184
|
+
let ours;
|
|
185
|
+
for (const end = Date.now() + wait.totalMs;;) {
|
|
186
|
+
ours = pick((await readProject(deps.run, link, deps.project))?.secrets ?? [], before);
|
|
187
|
+
if (ours || Date.now() >= end || deps.signal?.aborted)
|
|
188
|
+
break;
|
|
189
|
+
await sleep(wait.everyMs, undefined, { signal: deps.signal });
|
|
190
|
+
}
|
|
127
191
|
if (!ours)
|
|
128
|
-
stopped =
|
|
192
|
+
stopped = `The Vercel CLI made a bypass secret${held === 'labelled' ? ` labelled "${SECRET_NOTE}"` : ' (without a label)'}, but could not read it back from the project's settings within ${Math.round(wait.totalMs / 1000)} s.`;
|
|
129
193
|
else {
|
|
194
|
+
held = ours.note === SECRET_NOTE ? 'labelled' : 'unlabelled';
|
|
130
195
|
const done = await finish(ours.value, 'created');
|
|
131
196
|
if ('status' in done)
|
|
132
197
|
return done;
|
|
133
|
-
stopped = done.reason
|
|
198
|
+
stopped = `The Vercel CLI made a bypass secret${held === 'labelled' ? ` labelled "${SECRET_NOTE}"` : ' (without a label)'}, but the preview did not open with it within ${Math.round(wait.totalMs / 1000)} s: ${done.reason}`;
|
|
134
199
|
}
|
|
135
200
|
}
|
|
136
201
|
}
|
|
137
202
|
}
|
|
138
|
-
const typed = await deps.paste();
|
|
203
|
+
const typed = await deps.paste({ reason: stopped, held });
|
|
139
204
|
if (typed === undefined)
|
|
140
205
|
return { status: 'skipped', reason: stopped, ...from };
|
|
141
206
|
if (!isBypassSecret(typed))
|
package/dist/vercel.js
CHANGED
|
@@ -17,20 +17,31 @@ export async function vercelStatus(vercel) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
* `vercel teams ls --json`: the teams the person is a member of, each by its slug (what `--team` takes)
|
|
21
|
-
* which one is the CLI's current scope. The JSON is on stdout; the table the CLI prints otherwise goes to
|
|
20
|
+
* `vercel teams ls --json`: the teams the person is a member of, each by its slug (what `--team` takes), its name and
|
|
21
|
+
* its id, and which one is the CLI's current scope. The JSON is on stdout; the table the CLI prints otherwise goes to
|
|
22
|
+
* stderr.
|
|
22
23
|
*/
|
|
23
24
|
export async function vercelTeams(vercel) {
|
|
24
25
|
try {
|
|
25
26
|
const listed = JSON.parse(await vercel(['teams', 'ls', '--json'])).teams;
|
|
26
27
|
return (Array.isArray(listed) ? listed : []).flatMap((team) => typeof team.slug === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(team.slug)
|
|
27
|
-
? [{ value: team.slug, label: typeof team.name === 'string' && team.name ? team.name.slice(0, 100) : team.slug, current: team.current === true }]
|
|
28
|
+
? [{ value: team.slug, label: typeof team.name === 'string' && team.name ? team.name.slice(0, 100) : team.slug, current: team.current === true, ...(typeof team.id === 'string' && /^team_[A-Za-z0-9]+$/.test(team.id) ? { id: team.id } : {}) }]
|
|
28
29
|
: []);
|
|
29
30
|
}
|
|
30
31
|
catch {
|
|
31
32
|
return [];
|
|
32
33
|
}
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The scope a project's dashboard pages sit under, `https://vercel.com/<scope>/<project>/…`, for the project `link`
|
|
37
|
+
* names: its team's slug, as the CLI lists the person's teams, or for a project of the personal scope the username the
|
|
38
|
+
* CLI is signed in as. Undefined when the CLI cannot say, where the caller falls back to Vercel's picker.
|
|
39
|
+
*/
|
|
40
|
+
export async function vercelDashboardScope(vercel, link, teams) {
|
|
41
|
+
if (link.orgId.startsWith('team_'))
|
|
42
|
+
return (teams ?? (await vercelTeams(vercel))).find((team) => team.id === link.orgId)?.value;
|
|
43
|
+
return (await vercelStatus(vercel)).user;
|
|
44
|
+
}
|
|
34
45
|
/** Where Vercel's GitHub app is installed or given more repositories. */
|
|
35
46
|
export const VERCEL_APP_PAGE = 'https://github.com/apps/vercel/installations/new';
|
|
36
47
|
/**
|
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
|
-
}
|