@impeccable/reviews 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/bin.js +0 -0
- package/dist/fence.js +37 -2
- package/dist/host-questions.js +22 -8
- package/dist/playbook/github-previews.js +101 -0
- package/dist/playbook/netlify-pattern.js +24 -0
- package/dist/playbook/netlify-previews.js +144 -0
- package/dist/playbook/probe-preview.js +67 -0
- package/dist/playbook/protection-secrets.js +15 -0
- package/dist/playbook/public-preview-request.js +96 -0
- package/dist/playbook/publication-wait.js +46 -0
- package/dist/playbook/vercel-protection.js +121 -0
- package/dist/sandbox.js +82 -38
- package/dist/tools.js +102 -22
- package/dist/ui/look.js +3 -3
- package/dist/vercel-bypass.js +80 -15
- package/dist/vercel.js +14 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ The sign-in's setup token lasts an hour. A run that goes longer asks you to sign
|
|
|
52
52
|
|
|
53
53
|
The agent's commands run in an operating-system sandbox, macOS's seatbelt or Linux's bubblewrap, through Anthropic's sandbox runtime (`@anthropic-ai/sandbox-runtime`), which holds even when the repository talks the agent into something:
|
|
54
54
|
|
|
55
|
-
- **Reads**: what your account reads, but your credential folders: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.config/gcloud`, `~/.kube`, `~/.docker`, `~/.netrc`, `~/.git-credentials`, `~/.config/gh`, and your keychains on macOS; the Vercel, Netlify and Cloudflare CLIs' sign-ins (`~/Library/Application Support/com.vercel.cli`, `~/Library/Preferences/netlify` and `~/Library/Preferences/.wrangler` on macOS, `~/.local/share/com.vercel.cli`, `~/.config/netlify` and `~/.config/.wrangler` elsewhere, and `~/.vercel`, `~/.netlify` and `~/.wrangler`); the registries' and build tools' tokens (`~/.yarnrc.yml`, `~/.pypirc`, `~/.cargo/credentials`, `~/.gem/credentials`, `~/.m2/settings.xml`, `~/.gradle/gradle.properties`, `~/.terraform.d`); Codex's and Claude's (`~/.codex`, `~/.claude`); your git's global settings (`~/.gitconfig`, `~/.config/git/config` and every file they include, whether the include holds for this checkout or not) and `~/.config/git/credentials`; your shells' rc files (`~/.bashrc`, `~/.bash_profile`, `~/.profile`, `~/.zshenv`, `~/.zshrc`, `~/.zprofile` and the rest, and fish's `~/.config/fish`) and their histories; and your browsers' profiles (Chrome's, Chromium's, Brave's, Edge's and Firefox's, and `~/Library/Cookies` on macOS). Where your environment moves one of these, the sandbox denies the place it names instead: the folder `GH_CONFIG_DIR`, `VERCEL_AUTH_CONFIG_DIR`, `GNUPGHOME`, `DOCKER_CONFIG`, `CLOUDSDK_CONFIG`, `AZURE_CONFIG_DIR`, `CODEX_HOME` or `CLAUDE_CONFIG_DIR` names, the files `KUBECONFIG`, `GIT_CONFIG_GLOBAL` and `HISTFILE` name, cargo's credentials under `CARGO_HOME`, zsh's rc files under `ZDOTDIR`, and the same entries under `XDG_CONFIG_HOME` and `XDG_DATA_HOME` as under `~/.config` and `~/.local/share`; the variables themselves stay, since `gh`, the host CLIs and the package managers read them. Git in the sandbox reads a copy of your global settings instead of the file: your name and email, what shapes how git reads the checkout (line endings, case, excludes, attributes, LFS's content filter) and `safe.directory`, and none of http's headers, proxy, cookies and certificates, url rewrites, credential helpers, includes, aliases or programs; another content filter's command is left out, since it could carry a password, and when that filter is required, git in the sandbox refuses the file it would have changed rather than stage it unfiltered. Your `~/.npmrc` stays readable: the sandbox's hosts come from the registries it names, and npm needs its token to install from them.
|
|
55
|
+
- **Reads**: what your account reads, but your credential folders: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.config/gcloud`, `~/.kube`, `~/.docker`, `~/.netrc`, `~/.git-credentials`, `~/.config/gh`, and your keychains on macOS; the Vercel, Netlify and Cloudflare CLIs' sign-ins (`~/Library/Application Support/com.vercel.cli`, `~/Library/Preferences/netlify` and `~/Library/Preferences/.wrangler` on macOS, `~/.local/share/com.vercel.cli`, `~/.config/netlify` and `~/.config/.wrangler` elsewhere, and `~/.vercel`, `~/.netlify` and `~/.wrangler`); the registries' and build tools' tokens (`~/.yarnrc.yml`, `~/.pypirc`, `~/.cargo/credentials`, `~/.gem/credentials`, `~/.m2/settings.xml`, `~/.gradle/gradle.properties`, `~/.terraform.d`); Codex's and Claude's (`~/.codex`, `~/.claude`); your git's global settings (`~/.gitconfig`, `~/.config/git/config` and every file they include, whether the include holds for this checkout or not) and `~/.config/git/credentials`; your shells' rc files (`~/.bashrc`, `~/.bash_profile`, `~/.profile`, `~/.zshenv`, `~/.zshrc`, `~/.zprofile` and the rest, and fish's `~/.config/fish`) and their histories; and your browsers' profiles (Chrome's, Chromium's, Brave's, Edge's and Firefox's, and `~/Library/Cookies` on macOS). Where your environment moves one of these, the sandbox denies the place it names instead: the folder `GH_CONFIG_DIR`, `VERCEL_AUTH_CONFIG_DIR`, `GNUPGHOME`, `DOCKER_CONFIG`, `CLOUDSDK_CONFIG`, `AZURE_CONFIG_DIR`, `CODEX_HOME` or `CLAUDE_CONFIG_DIR` names, the files `KUBECONFIG`, `GIT_CONFIG_GLOBAL` and `HISTFILE` name, cargo's credentials under `CARGO_HOME`, zsh's rc files under `ZDOTDIR`, and the same entries under `XDG_CONFIG_HOME` and `XDG_DATA_HOME` as under `~/.config` and `~/.local/share`; the variables themselves stay, since `gh`, the host CLIs and the package managers read them. Git in the sandbox reads a copy of your global settings instead of the file: your name and email, what shapes how git reads the checkout (line endings, case, excludes, attributes, LFS's content filter) and `safe.directory`, and none of http's headers, proxy, cookies and certificates, url rewrites, credential helpers, includes, aliases or programs; another content filter's command is left out, since it could carry a password, and when that filter is required, git in the sandbox refuses the file it would have changed rather than stage it unfiltered. Git's system settings are scrubbed into the same copy and otherwise turned off for it (`GIT_CONFIG_NOSYSTEM`): on a Mac, Apple's git carries a config inside Xcode and the Command Line Tools that names the keychain's credential helper, and git in the sandbox is to have no credential helper at all, so that it asks for no sign-in rather than fail in one; the helper programs that config names (`git-credential-osxkeychain`, in Xcode and in the Command Line Tools) are denied too, so a script that puts the variable back cannot run them, and your keychain itself stays denied, to the helper and to `security` alike. Your `~/.npmrc` stays readable: the sandbox's hosts come from the registries it names, and npm needs its token to install from them.
|
|
56
56
|
- **Writes**: the checkout and its git folder, but never where git takes settings or programs from (its config, hooks, `info`, a `commondir`, the submodules' and the other worktrees' git folders, a `.git` below the checkout's top; on Linux a `commondir` and a nested `.git` are removed at the end of the run instead, as the last paragraph of this section says); the run's scratch folder, where npm's cache and Corepack's package managers go for the run; and the package managers' own caches and stores (pnpm's store, Yarn's and Bun's caches, Prisma's engines), but never `~/.npm`, which your own npm and `npx` read from, nor pnpm's metadata cache, whose `dlx` folder your next `pnpm dlx` runs from (pnpm reads it and goes on without writing it), and of pnpm's home folder only its store, never the global commands it keeps on your PATH. Those stores and caches a sandboxed install could not make itself (pnpm's store on a machine where pnpm never ran, all of them on Linux) are made before the run starts, readable by you alone, and in pnpm's home nothing else is. A sandboxed pnpm is told to use that store, yours, so it never makes one of its own in the checkout, and to copy what it installs from it rather than hard-link it, so a write to an installed file changes only that copy. An install skips the browsers Cypress, Puppeteer and Playwright download, and husky's git hooks.
|
|
57
57
|
- **Network**: only the hosts this setup needs: `--host`; GitHub's hosts an install fetches from (`github.com`, `api.github.com`, `codeload.github.com`, `*.githubusercontent.com`); the public registries your manifests and lockfiles install from (npm's, Yarn's, PyPI); the registries your own `~/.npmrc` names, never one only the repository names; Google's fonts for a Next.js app and Prisma's engines, when the app uses them; and Vercel's or Netlify's API once you said yes to setting that host up; never GitHub over SSH (port 22), which is refused with the reason, since what pushes runs outside the sandbox, as you. The debug log names each host it refused. The list limits where a command's traffic goes, not what it carries (a script can still send what it reads to GitHub or npm over HTTPS, which the sandbox's proxy passes through without reading), so what keeps your sign-ins yours is what the sandbox does not let it read.
|
|
58
58
|
|
|
@@ -62,7 +62,7 @@ The command stops when the sandbox does not start: on macOS inside another tool'
|
|
|
62
62
|
|
|
63
63
|
The fence stays the layer inside the sandbox: it keeps the setup to its own steps and out of `.env` files, and the agent's commands get your environment without the tokens, keys and passwords in it, whether their names say so or their values are shaped like a secret (a GitHub token, a private key, a service account's key, a password in a URL), and what runs as you (`git commit` and `push`, `gh`, the host CLIs) gets it without `GH_REPO` and `GH_HOST`, so `gh` works on this checkout's repository on github.com, whatever repository or host they name. The agent's own file tools (read, write, edit, grep, find and ls) open files in the command's process, as you, so for them the fence judges the path pi opens, not the string the agent typed: spelled the way pi spells it (a curly apostrophe for a straight one, among others) and with its symlinks followed, and a path that starts with `@` or `~` or a URL scheme, which pi would open elsewhere, is refused outright; read, write, edit, grep and ls judge each file once more as they open it (ls each entry too, so a symlink out of the checkout is not listed), find judges the folder it searches before fd walks it and every path fd reports after, and a symlinked folder out of the checkout is neither followed nor listed; the names of `.env` files are listed, as `ls -a` shows them, and only a read of one is refused; a grep glob that names `.env` files is refused, and no line of a `.env` file is in what grep reports, whatever glob it was given. It also reads what goes past it: text shaped like a secret (a cloud, GitHub, Google or SendGrid key, a private key, a token, a Slack webhook's address, a password in an address or on a curl command line) in a file the agent writes, a setting, a commit, a pull request or a message to you is refused, read as written and as the screen would show it once an escape sequence is taken out, a command that names a credential file (`~/.ssh`, `.aws/credentials`, `.netrc`, `.env`) is refused, and what the agent reads gets a line saying it is data when it carries text shaped like instructions; the debug log and `--scan-report` list each match. A commit message, a template and a pull request body come inline (`-m`, `--body`), never from a file: the file the fence read is not surely the file git or gh, outside the sandbox, read later, since a command still running in the sandbox can swap it in between. For the same reason the messages of the commits a push carries are read again as the push runs, all of them (past what the remote has: its branch of that name, the default branch, and one the pushed branch is stacked on, such as an earlier run's setup branch under a new test drive), and so is every line they add to a `.env` file's example, which a command may have written, with the same check as a file the agent writes; a push whose commits cannot be read whole is refused, as is one whose remote cannot be reached, since what it carries cannot be checked then (the push can be tried again once the network is back; the remote is asked with no prompt for a password or a passphrase, which would hold it), and one in the same command as the `git commit` or `git checkout -b` that makes what it carries, which is not there yet when it is read: the push runs as a command of its own, after the commit. A commit carries only files the agent wrote, or named in a `git add`: a file a script staged on its own is refused by name, and `git restore --staged` takes it out; a push whose new commits carry such a file (a script can commit on its own, past the index) is refused the same way. Once `impeccable_sync_workflow` wrote the preview workflow, or removed the retired one, a commit carries that file only as the tool left it: the setup state's bytes, or no file. A status line the agent starts a message with shows in the terminal and on the web page only when its progress tool would take it: in the product voice, short, and holding nothing shaped like a secret.
|
|
64
64
|
|
|
65
|
-
Without asking: `git status|diff|log|show|add|restore --staged|commit|checkout -b|push -u origin <branch>`, `gh pr create`, `gh pr view` for a pull request of this repository, named with `--repo`, by number or by this run's branch name (with `--json` or `--comments`, never `--web`) and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
|
|
65
|
+
Without asking: `git status|diff|log|show|add|restore --staged|commit|checkout -b|push -u origin <branch>`, `git branch` and `git remote` in their listing forms, `git ls-remote origin` (in the sandbox, over https, and never held on a prompt for a sign-in), `gh pr create`, `gh pr view` for a pull request of this repository, named with `--repo`, by number or by this run's branch name (with `--json` or `--comments`, never `--web`) and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
|
|
66
66
|
|
|
67
67
|
`vercel whoami` and `vercel --version`. Once you said yes to the Vercel setup: `vercel link --yes` in the checkout's root alone (never in a folder of it, whose name would pick one of your projects), `vercel project add` and `vercel deploy` for a preview, on the project named after the folder and in the one team the sign-in settled on (the CLI's current scope, your only team, or the one you pick when it asks): no other `--scope`, `--team` or `--project`, never `vercel project ls`, which names your projects, and a global install of the Vercel CLI itself when it is missing, with npm and pinned to the version this release names (`npm install -g vercel@<version>`; another package manager, version or tag is refused). The sign-in (`vercel login`) and the repository's connection (`vercel git connect`, for a project the run created or one connected to the repository already) are the run's own tools, never the agent's commands. Never a production deploy, a project's removal, its environment variables or protection settings (`vercel project protection`, `api` and `curl` print a project's bypass secrets, so the agent's shell never runs them; the lock before a new project's first deployment and the bypass secret are the run's own tools), or a token on the command line.
|
|
68
68
|
|
|
@@ -88,7 +88,7 @@ A test account's password, typed hidden on the account's screen (after a screen
|
|
|
88
88
|
|
|
89
89
|
What you type into any other question the agent asks, it reads. So a question whose title, prompt or hint names a token, a password, a secret, a key or a credential is typed hidden however the agent asked it, and the agent gets a reference, as for a test account's password. Every other question with a field says under it "Never enter a password, a token or a key here."
|
|
90
90
|
|
|
91
|
-
Vercel's bypass secret, which lets reviews open previews behind Vercel's login, is handled the same way. The secret goes only to a preview the linked project serves: before anything is sent, the command reads the project this folder is linked to (`.vercel/project.json`, whose ids it takes only in Vercel's own form; once the run's own `vercel link` wrote that file, only what it wrote counts) through the Vercel CLI as you, and refuses, saying so, unless Vercel shows that project connected to this repository, with Deployment Protection in its settings, and the preview's deployment as the project's own (among its latest, or read on its own by its address), since a deployment GitHub lists can name any address and its page proves nothing about whose it is, or whether the login on it is Vercel's: a deployment can serve a page shaped like Vercel's login, and only your project's settings say whether Vercel's stands there. The same read decides which project the setup locks before its first deployment. When Vercel's login stands in front of a preview of that project, the command asks you once, on one screen that names the preview's address, whether to create a bypass secret for Impeccable, and on your yes creates it as you through the Vercel CLI, labelled "Impeccable reviews" (`vercel api` for Vercel's protection-bypass route; an older CLI's `vercel project protection enable --protection-bypass`), then reads it back from the project's settings. A project that already holds a secret with that label gives it without a question. When
|
|
91
|
+
Vercel's bypass secret, which lets reviews open previews behind Vercel's login, is handled the same way. The secret goes only to a preview the linked project serves: before anything is sent, the command reads the project this folder is linked to (`.vercel/project.json`, whose ids it takes only in Vercel's own form; once the run's own `vercel link` wrote that file, only what it wrote counts) through the Vercel CLI as you, and refuses, saying so, unless Vercel shows that project connected to this repository, with Deployment Protection in its settings, and the preview's deployment as the project's own (among its latest, or read on its own by its address), since a deployment GitHub lists can name any address and its page proves nothing about whose it is, or whether the login on it is Vercel's: a deployment can serve a page shaped like Vercel's login, and only your project's settings say whether Vercel's stands there. The same read decides which project the setup locks before its first deployment. When Vercel's login stands in front of a preview of that project, the command asks you once, on one screen that names the preview's address, whether to create a bypass secret for Impeccable, and on your yes creates it as you through the Vercel CLI, labelled "Impeccable reviews" (`vercel api` for Vercel's protection-bypass route; an older CLI's `vercel project protection enable --protection-bypass`), then reads it back from the project's settings and opens the preview with it, trying again for up to 30 seconds while Vercel's edge still answers with its login, since a secret made moments ago can take a few seconds to be honoured. A project that already holds a secret with that label gives it without a question. When that does not end with a secret that opens the preview, a screen says what stopped it in Vercel's own words (the CLI could not read the project; Vercel refused the make, which takes the Member role or Project Administrator; the secret was made but not read back, or did not open the preview, in which case the screen says to copy it rather than make another), opens that project's own Deployment Protection settings in your browser, under the team the project belongs to, names the preview's address, and takes the secret you type, hidden; a folder that is not linked, or a CLI that is signed out, gets no such screen, since nothing then shows the preview to be yours. It opens the preview with the secret first and saves it, with the setting that sends it in the `x-vercel-protection-bypass` header, only once the page shows your app. The agent never holds it: the secret goes from the command's own process to Vercel and to Impeccable's setting, and the debug log and the transcript remove it. Vercel documents this as Protection Bypass for Automation, which every plan includes; you revoke it in the project's Settings, Deployment Protection.
|
|
92
92
|
|
|
93
93
|
Netlify's site password (Password Protection on Pro and Enterprise plans; "Project visibility: Password" on a credit-based Pro plan) is handled the same way. When a Netlify deploy preview asks for it, the command reads the site's settings through the Netlify CLI, signed in as you, and that read is what shows the site to be yours: a site the CLI cannot read as you (it is signed out, or the site is in none of your teams) gets no password, and the command refuses, saying so, since the lock at a listed address may be anyone's. For a password Netlify gives back, it asks you once, on one screen that names the preview's address, whether to store the site password for Impeccable, and on your yes reads it from the site as you. When Netlify does not give the password back, a screen names the address and takes the password you type, hidden (the run never sets one itself: the Netlify CLI takes a new password only on its command line, where any process could read it). It opens the preview with the password first, as basic credentials in the `Authorization` header (Netlify's password page asks for a password alone, so the credentials carry no username), and saves it, with the setting that sends that header, only once the page shows your app; a password that opens the preview on Netlify's own form but not in the header is reported, not saved, since reviews can send only a header. A site behind Netlify's team login (a private site) has no way in for tools, so the command asks you once, on one screen, whether to make the site public, and on your yes makes it public as you through the Netlify CLI (`netlify api updateSite` with `sso_login` off); anyone with its address can then open it, the live site included, until you make it private again in Project configuration, General, Visitor access. Where Netlify refuses (your team's default keeps every project private, or your role cannot change it), the command opens that page in your browser and asks you to try again once you changed it there; a password you set there instead is stored as above. The agent never holds the password: it goes from the command's own process to Netlify and to Impeccable's setting, and the debug log and the transcript remove it.
|
|
94
94
|
|
package/dist/bin.js
CHANGED
|
File without changes
|
package/dist/fence.js
CHANGED
|
@@ -6,7 +6,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod
|
|
|
6
6
|
import { createFindToolDefinition, createGrepToolDefinition, detectSupportedImageMimeTypeFromFile } from '@earendil-works/pi-coding-agent';
|
|
7
7
|
import { linkedNetlifySite } from './netlify.js';
|
|
8
8
|
import { resolveReadPath, resolveToCwd } from './pi-paths.js';
|
|
9
|
-
import { gitEnvironment } from './sandbox.js';
|
|
9
|
+
import { gitEnvironment, NO_PROMPT } from './sandbox.js';
|
|
10
10
|
import { DATA_NOTE, scan, scanRecorder } from './scan.js';
|
|
11
11
|
import { plainText } from './ui/sensitive.js';
|
|
12
12
|
const allow = (reason = 'on the allowlist') => ({ verdict: 'allow', reason });
|
|
@@ -336,6 +336,35 @@ function printsPatch(sub, args) {
|
|
|
336
336
|
const revisions = options.filter((a) => !a.startsWith('-'));
|
|
337
337
|
return !(sub === 'show' && revisions.length && revisions.every((a) => a.includes(':')));
|
|
338
338
|
}
|
|
339
|
+
/** git branch's listing flags, alone or in a cluster (-avv): -a, -r, -l, -v and -i. */
|
|
340
|
+
const BRANCH_FLAGS = /^-[arlvi]+$/;
|
|
341
|
+
const BRANCH_LONG_FLAGS = new Set(['--all', '--remotes', '--list', '--verbose', '--show-current', '--ignore-case', '--no-color', '--column', '--no-column', '--no-abbrev', '--omit-empty', '--contains', '--no-contains', '--merged', '--no-merged']);
|
|
342
|
+
/** Its options that take a value, with it after `=`: a bare `--contains HEAD` reads HEAD as its value, but a bare `--format %(refname)` would make a branch named %(refname). */
|
|
343
|
+
const BRANCH_VALUES = /^--(?:contains|no-contains|merged|no-merged|points-at|sort|format|color|abbrev)=/;
|
|
344
|
+
/** Whether `git branch` with these words only lists branches: a word that is no option names a pattern, which only --list takes (without it git makes a branch of that name); -d, -m, -c, -u, --set-upstream-to and --edit-description, among others, are not listing. */
|
|
345
|
+
function listsBranches(args) {
|
|
346
|
+
const options = args.filter((a) => a.startsWith('-'));
|
|
347
|
+
const patterns = args.length - options.length;
|
|
348
|
+
if (!options.every((a) => BRANCH_FLAGS.test(a) || BRANCH_LONG_FLAGS.has(a) || BRANCH_VALUES.test(a)))
|
|
349
|
+
return false;
|
|
350
|
+
return patterns === 0 || options.some((a) => a === '--list' || (BRANCH_FLAGS.test(a) && a.includes('l')));
|
|
351
|
+
}
|
|
352
|
+
/** Whether `git remote` with these words only lists: no words, -v, or get-url (with --push or --all) of one remote by name. Every other verb writes the config or fetches, and show without -n asks the remote. */
|
|
353
|
+
function listsRemotes(args) {
|
|
354
|
+
if (!args.length || (args.length === 1 && (args[0] === '-v' || args[0] === '--verbose')))
|
|
355
|
+
return true;
|
|
356
|
+
const [verb, ...rest] = args;
|
|
357
|
+
return verb === 'get-url' && rest.filter((a) => a.startsWith('-')).every((a) => a === '--push' || a === '--all') && rest.filter((a) => !a.startsWith('-')).length === 1;
|
|
358
|
+
}
|
|
359
|
+
/** git ls-remote's listing options, alone or in a cluster (-ht). */
|
|
360
|
+
const LS_REMOTE_FLAGS = /^-[hbtq]+$/;
|
|
361
|
+
const LS_REMOTE_LONG = new Set(['--heads', '--branches', '--tags', '--refs', '--symref', '--quiet', '--exit-code', '--get-url']);
|
|
362
|
+
/** Whether `git ls-remote` with these words asks origin, named, and only lists: the first word that is no option is the repository, so a pattern comes after `origin`, and with none git asks the current branch's own remote, whichever that is; a URL, another remote, --upload-pack (a program) and -o (sent to the server) are not listing. */
|
|
363
|
+
function listsOrigin(args) {
|
|
364
|
+
const options = args.filter((a) => a.startsWith('-'));
|
|
365
|
+
const words = args.filter((a) => !a.startsWith('-'));
|
|
366
|
+
return options.every((a) => LS_REMOTE_FLAGS.test(a) || LS_REMOTE_LONG.has(a) || a.startsWith('--sort=')) && words[0] === 'origin';
|
|
367
|
+
}
|
|
339
368
|
/** The branch a push's refspec updates on the remote: its destination, else its source, without `+` and `refs/heads/` (or `heads/`, which git reads the same). */
|
|
340
369
|
const pushedBranch = (refspec) => refspec.replace(/^\+/, '').split(':').pop().replace(/^(?:refs\/)?heads\//, '');
|
|
341
370
|
const isFile = (path) => {
|
|
@@ -440,6 +469,12 @@ function git(args, where, protectedBranches, held = false) {
|
|
|
440
469
|
}
|
|
441
470
|
if (sub === 'checkout' && rest[0] === '-b' && rest.length >= 2 && rest.length <= 3)
|
|
442
471
|
return allow();
|
|
472
|
+
if (sub === 'branch')
|
|
473
|
+
return listsBranches(rest) ? allow() : deny('git branch lists only: -a, -r, -v, --show-current, --list <pattern>, and --contains, --merged, --points-at, --sort and --format with their value after =; git checkout -b makes a branch');
|
|
474
|
+
if (sub === 'remote')
|
|
475
|
+
return listsRemotes(rest) ? allow() : deny('git remote lists only: on its own, -v, or get-url <name>; nothing that adds, removes, renames, points or prunes a remote, and no show, which asks the remote');
|
|
476
|
+
if (sub === 'ls-remote')
|
|
477
|
+
return listsOrigin(rest) ? allow() : deny('git ls-remote asks origin only, named: git ls-remote --heads origin, with --tags, --refs, --symref, --get-url and patterns after origin; no other remote, no URL, no --upload-pack, and not without origin, which asks the current branch\'s own remote');
|
|
443
478
|
if (sub === 'push' && rest.length === 3 && (rest[0] === '-u' || rest[0] === '--set-upstream') && rest[1] === 'origin') {
|
|
444
479
|
const branch = rest[2];
|
|
445
480
|
if (branch.includes(':') || ['HEAD', '@'].includes(pushedBranch(branch)) || protectedBranches.includes(pushedBranch(branch)))
|
|
@@ -909,7 +944,7 @@ function newMessages(head, bases, cwd) {
|
|
|
909
944
|
*/
|
|
910
945
|
function unprompted(cwd) {
|
|
911
946
|
const ownSsh = process.env.GIT_SSH_COMMAND || process.env.GIT_SSH || gitRead(['config', '--get', 'core.sshCommand'], cwd);
|
|
912
|
-
return {
|
|
947
|
+
return { ...NO_PROMPT, ...(ownSsh ? {} : { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }) };
|
|
913
948
|
}
|
|
914
949
|
/**
|
|
915
950
|
* What a `push -u origin <branch>` adds to, asked of the remote itself: the commits it has on that branch and on the default
|
package/dist/host-questions.js
CHANGED
|
@@ -138,7 +138,7 @@ export const HOST_SCREENS = {
|
|
|
138
138
|
id: 'vercel-bypass',
|
|
139
139
|
kind: 'confirm',
|
|
140
140
|
title: 'Your Vercel previews are protected',
|
|
141
|
-
hint: 'Impeccable reviews each pull request on its preview
|
|
141
|
+
hint: 'Impeccable reviews each pull request on its preview,\nand Vercel keeps yours behind a login.\n\nVercel\'s way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time.',
|
|
142
142
|
prompt: 'Create the secret for Impeccable?',
|
|
143
143
|
confirmLabel: 'Create',
|
|
144
144
|
skipLabel: 'Skip',
|
|
@@ -148,7 +148,7 @@ export const HOST_SCREENS = {
|
|
|
148
148
|
kind: 'text',
|
|
149
149
|
sensitive: true,
|
|
150
150
|
title: 'Your Vercel previews are protected',
|
|
151
|
-
prompt: 'Vercel keeps your previews behind a login, and the
|
|
151
|
+
prompt: 'Vercel keeps your previews behind a login, and the run did not get a bypass secret in place for them. Make one yourself: your project → Settings → Deployment Protection → Protection Bypass for Automation → Create, named "Impeccable reviews". It is stored encrypted and used only to open previews.',
|
|
152
152
|
hint: 'Paste the secret',
|
|
153
153
|
},
|
|
154
154
|
// Netlify's site password in front of the previews: the one consent to storing the password the run reads from the site as the person, and the paste when it cannot read it.
|
|
@@ -156,7 +156,7 @@ export const HOST_SCREENS = {
|
|
|
156
156
|
id: 'netlify-password',
|
|
157
157
|
kind: 'confirm',
|
|
158
158
|
title: 'Your Netlify previews are protected',
|
|
159
|
-
hint: `Impeccable reviews each pull request on its preview, and Netlify keeps yours behind the site password
|
|
159
|
+
hint: `Impeccable reviews each pull request on its preview, and Netlify keeps yours behind the site password.\n\nThe run reads that password from your site as you and stores it encrypted, used only to open previews. Change it any time in ${NETLIFY_PASSWORD_WHERE}.`,
|
|
160
160
|
prompt: 'Store the site password for Impeccable?',
|
|
161
161
|
confirmLabel: 'Store',
|
|
162
162
|
skipLabel: 'Skip',
|
|
@@ -193,7 +193,7 @@ export const HOST_SCREENS = {
|
|
|
193
193
|
id: 'cloudflare-access',
|
|
194
194
|
kind: 'confirm',
|
|
195
195
|
title: 'Your Cloudflare previews are protected',
|
|
196
|
-
hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login
|
|
196
|
+
hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login.\n\nCloudflare\'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.',
|
|
197
197
|
prompt: 'Create the token for Impeccable?',
|
|
198
198
|
confirmLabel: 'Create',
|
|
199
199
|
skipLabel: 'Skip',
|
|
@@ -216,13 +216,27 @@ export const HOST_SCREENS = {
|
|
|
216
216
|
},
|
|
217
217
|
};
|
|
218
218
|
/**
|
|
219
|
-
* A key's screen naming the one address the key goes to
|
|
220
|
-
*
|
|
221
|
-
*
|
|
219
|
+
* A key's paste screen naming the one address the key goes to, which the key opens before anything saves it: a text
|
|
220
|
+
* or account screen says so under its prompt. A confirm screen's consent to a bypass secret, a site password or a
|
|
221
|
+
* service token no longer names the address, so it comes back as it is.
|
|
222
222
|
*/
|
|
223
223
|
export function sentTo(screen, url) {
|
|
224
224
|
const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
|
|
225
|
-
return screen.kind === 'confirm' ?
|
|
225
|
+
return screen.kind === 'confirm' ? screen : { ...screen, prompt: `${screen.prompt} ${line}` };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The paste screen for Vercel's bypass secret, saying what actually stopped the run's own way (the CLI's read, Vercel's
|
|
229
|
+
* refusal in its own words, a secret made but not read back, or one that did not open the preview) rather than one fixed
|
|
230
|
+
* cause: a project that holds a secret labelled for Impeccable already is one to copy from, not to make another on.
|
|
231
|
+
*/
|
|
232
|
+
export function vercelBypassPaste(stopped) {
|
|
233
|
+
const where = 'your project → Settings → Deployment Protection → Protection Bypass for Automation';
|
|
234
|
+
const next = stopped.held === 'labelled'
|
|
235
|
+
? `Copy the secret named "Impeccable reviews" from ${where} and paste it here, or create another one there with that name.`
|
|
236
|
+
: stopped.held === 'unlabelled'
|
|
237
|
+
? `Copy the secret the run just made (the newest one there, with no note) from ${where} and paste it here, or create another one there, named "Impeccable reviews".`
|
|
238
|
+
: `Make one yourself: ${where} → Create, named "Impeccable reviews".`;
|
|
239
|
+
return { ...HOST_SCREENS['vercel-bypass-key'], prompt: `Vercel keeps your previews behind a login. ${stopped.reason} ${next} It is stored encrypted and used only to open previews.` };
|
|
226
240
|
}
|
|
227
241
|
/** The apps question on a repository with several apps, worded here: the tool lists the apps the state has, each picked as it is on, so every run asks it the same way. */
|
|
228
242
|
export const APPS_QUESTION = {
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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';
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|