@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
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
const INTERVAL_MS = 30_000;
|
|
3
|
+
const DEADLINE_MS = 8 * 60_000;
|
|
4
|
+
/** One publication deadline per setup PR, including time spent checking the preview. */
|
|
5
|
+
export function createPublicationWait(dependencies = {}) {
|
|
6
|
+
const now = dependencies.now ?? (() => performance.now());
|
|
7
|
+
const pause = dependencies.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
|
|
8
|
+
const starts = new Map();
|
|
9
|
+
return {
|
|
10
|
+
begin(pr) {
|
|
11
|
+
if (!starts.has(pr))
|
|
12
|
+
starts.set(pr, now());
|
|
13
|
+
},
|
|
14
|
+
remainingMs(pr) {
|
|
15
|
+
const start = starts.get(pr);
|
|
16
|
+
return start === undefined ? null : Math.max(0, DEADLINE_MS - (now() - start));
|
|
17
|
+
},
|
|
18
|
+
async wait(pr, signal) {
|
|
19
|
+
const start = starts.get(pr);
|
|
20
|
+
if (start === undefined)
|
|
21
|
+
return null;
|
|
22
|
+
const result = (status) => {
|
|
23
|
+
const elapsed = Math.max(0, now() - start);
|
|
24
|
+
return {
|
|
25
|
+
status,
|
|
26
|
+
waited_seconds: Math.floor(elapsed / 1000),
|
|
27
|
+
remaining_seconds: Math.ceil(Math.max(0, DEADLINE_MS - elapsed) / 1000),
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
if (signal?.aborted)
|
|
31
|
+
return result('cancelled');
|
|
32
|
+
const remaining = DEADLINE_MS - (now() - start);
|
|
33
|
+
if (remaining <= 0)
|
|
34
|
+
return result('timed-out');
|
|
35
|
+
try {
|
|
36
|
+
await pause(Math.min(INTERVAL_MS, remaining), signal);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return result('cancelled');
|
|
40
|
+
}
|
|
41
|
+
if (signal?.aborted)
|
|
42
|
+
return result('cancelled');
|
|
43
|
+
return result(now() - start >= DEADLINE_MS ? 'timed-out' : 'retry');
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const METADATA_LIMIT = 16 * 1024;
|
|
6
|
+
const OUTPUT_LIMIT = 2 * 1024 * 1024;
|
|
7
|
+
function record(value) {
|
|
8
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
9
|
+
? value : undefined;
|
|
10
|
+
}
|
|
11
|
+
/** Only the root's small project-link metadata is read; CLI authentication stays with the CLI. */
|
|
12
|
+
async function linkedProject(cwd) {
|
|
13
|
+
try {
|
|
14
|
+
const root = await realpath(cwd);
|
|
15
|
+
const directory = join(root, '.vercel');
|
|
16
|
+
const directoryStat = await lstat(directory);
|
|
17
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || await realpath(directory) !== directory)
|
|
18
|
+
return;
|
|
19
|
+
const path = join(directory, 'project.json');
|
|
20
|
+
if (!(await lstat(path)).isFile())
|
|
21
|
+
return;
|
|
22
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
23
|
+
try {
|
|
24
|
+
const stat = await file.stat();
|
|
25
|
+
const currentDirectory = await lstat(directory);
|
|
26
|
+
const currentFile = await lstat(path);
|
|
27
|
+
if (!stat.isFile() || stat.size > METADATA_LIMIT
|
|
28
|
+
|| currentDirectory.dev !== directoryStat.dev || currentDirectory.ino !== directoryStat.ino
|
|
29
|
+
|| currentFile.isSymbolicLink() || currentFile.dev !== stat.dev || currentFile.ino !== stat.ino
|
|
30
|
+
|| await realpath(path) !== path)
|
|
31
|
+
return;
|
|
32
|
+
const buffer = Buffer.alloc(METADATA_LIMIT + 1);
|
|
33
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
34
|
+
if (bytesRead > METADATA_LIMIT)
|
|
35
|
+
return;
|
|
36
|
+
const metadata = record(JSON.parse(buffer.toString('utf8', 0, bytesRead)));
|
|
37
|
+
if (typeof metadata?.projectId !== 'string' || !/^prj_[A-Za-z0-9]{1,128}$/.test(metadata.projectId)
|
|
38
|
+
|| typeof metadata.orgId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(metadata.orgId))
|
|
39
|
+
return;
|
|
40
|
+
return { root, projectId: metadata.projectId, orgId: metadata.orgId };
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await file.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Reuse one existing default automation bypass in memory, without exposing project responses or keys. */
|
|
51
|
+
export async function useExistingVercelBypass(input, dependencies) {
|
|
52
|
+
const { signal } = dependencies;
|
|
53
|
+
if (signal?.aborted)
|
|
54
|
+
return { status: 'cancelled' };
|
|
55
|
+
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(input.owner)
|
|
56
|
+
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(input.repo) || input.repo === '.' || input.repo === '..') {
|
|
57
|
+
return { status: 'project-mismatch' };
|
|
58
|
+
}
|
|
59
|
+
const linked = await linkedProject(input.cwd);
|
|
60
|
+
if (signal?.aborted)
|
|
61
|
+
return { status: 'cancelled' };
|
|
62
|
+
if (!linked)
|
|
63
|
+
return { status: 'not-linked' };
|
|
64
|
+
const runVercel = dependencies.runVercel ?? ((args) => new Promise((resolve, reject) => {
|
|
65
|
+
const child = execFile('vercel', args, {
|
|
66
|
+
cwd: linked.root, encoding: 'utf8', timeout: 30_000, maxBuffer: OUTPUT_LIMIT, signal,
|
|
67
|
+
}, (error, stdout) => error ? reject(new Error('Vercel project lookup failed.')) : resolve(stdout));
|
|
68
|
+
child.stdin?.end();
|
|
69
|
+
}));
|
|
70
|
+
// teamId selects the linked team. Legacy personal projects omit it and still require accountId to match.
|
|
71
|
+
const route = `/v9/projects/${linked.projectId}${linked.orgId.startsWith('team_') ? `?teamId=${linked.orgId}` : ''}`;
|
|
72
|
+
let selected;
|
|
73
|
+
try {
|
|
74
|
+
const stdout = await runVercel(['api', route, '--method', 'GET', '--raw', '--non-interactive'], signal);
|
|
75
|
+
if (signal?.aborted)
|
|
76
|
+
return { status: 'cancelled' };
|
|
77
|
+
if (Buffer.byteLength(stdout, 'utf8') > OUTPUT_LIMIT)
|
|
78
|
+
return { status: 'unavailable' };
|
|
79
|
+
const project = record(JSON.parse(stdout));
|
|
80
|
+
const link = record(project?.link);
|
|
81
|
+
if (project?.id !== linked.projectId || project.accountId !== linked.orgId
|
|
82
|
+
|| !link || !['github', 'github-limited'].includes(String(link.type))
|
|
83
|
+
|| typeof link.org !== 'string' || link.org.toLowerCase() !== input.owner.toLowerCase()
|
|
84
|
+
|| typeof link.repo !== 'string' || link.repo.toLowerCase() !== input.repo.toLowerCase()) {
|
|
85
|
+
return { status: 'project-mismatch' };
|
|
86
|
+
}
|
|
87
|
+
const bypasses = record(project.protectionBypass);
|
|
88
|
+
if (!bypasses || Object.keys(bypasses).length > 128)
|
|
89
|
+
return { status: 'unavailable' };
|
|
90
|
+
const candidates = [];
|
|
91
|
+
for (const [value, metadata] of Object.entries(bypasses)) {
|
|
92
|
+
const bypass = record(metadata);
|
|
93
|
+
if (bypass?.scope !== 'automation-bypass')
|
|
94
|
+
continue;
|
|
95
|
+
if (!/^[A-Za-z0-9]{32}$/.test(value)
|
|
96
|
+
|| (bypass.isEnvVar !== undefined && typeof bypass.isEnvVar !== 'boolean'))
|
|
97
|
+
return { status: 'unavailable' };
|
|
98
|
+
// An omitted isEnvVar is the legacy default. Explicit false keys must never be chosen implicitly.
|
|
99
|
+
if (bypass.isEnvVar !== false)
|
|
100
|
+
candidates.push(value);
|
|
101
|
+
}
|
|
102
|
+
if (candidates.length > 1)
|
|
103
|
+
return { status: 'ambiguous' };
|
|
104
|
+
if (candidates.length === 0)
|
|
105
|
+
return { status: 'unavailable' };
|
|
106
|
+
selected = candidates[0];
|
|
107
|
+
dependencies.rememberSecret(selected);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { status: signal?.aborted ? 'cancelled' : 'unavailable' };
|
|
111
|
+
}
|
|
112
|
+
if (signal?.aborted)
|
|
113
|
+
return { status: 'cancelled' };
|
|
114
|
+
try {
|
|
115
|
+
const status = await dependencies.save(selected);
|
|
116
|
+
return { status: ['saved', 'save-failed', 'partial-save', 'cancelled'].includes(status) ? status : 'save-failed' };
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { status: signal?.aborted ? 'cancelled' : 'save-failed' };
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/sandbox.js
CHANGED
|
@@ -122,7 +122,8 @@ function moved(env, gitConfigs, kept) {
|
|
|
122
122
|
}
|
|
123
123
|
/**
|
|
124
124
|
* What a command in the sandbox reads and writes: it reads what the person can but the credential folders, where their
|
|
125
|
-
* environment moved them, and the files git's global settings come from
|
|
125
|
+
* environment moved them, and the files git's global settings come from and the credential helper programs its system
|
|
126
|
+
* settings name (`gitConfigs`), and writes
|
|
126
127
|
* in the checkout, its git folders (a worktree's are outside it), the run's scratch folder and the package caches, but
|
|
127
128
|
* never where git reads settings or programs from, nor a .git below the checkout's top. Bubblewrap takes no pattern in
|
|
128
129
|
* a write rule, and puts a file in place of a denied path that is not there: on Linux the names git reads as nothing
|
|
@@ -145,7 +146,7 @@ export function sandboxPolicy(o) {
|
|
|
145
146
|
};
|
|
146
147
|
}
|
|
147
148
|
/**
|
|
148
|
-
* The
|
|
149
|
+
* The git settings, global and system, a command in the sandbox keeps: the person's name and email, what shapes how git reads the
|
|
149
150
|
* checkout (line endings, case, excludes, attributes, and the content filters, which git add runs: of their commands only
|
|
150
151
|
* LFS's, see LFS_COMMAND, and every filter's `required`) and safe.directory. Nothing that carries or names a sign-in (http's
|
|
151
152
|
* headers, proxy, cookies and certificates, url rewrites, credential helpers, sendemail's and imap's passwords, includes),
|
|
@@ -158,27 +159,46 @@ const KEPT_SETTINGS = /^(?:user\.(?:name|email)|init\.defaultbranch|core\.(?:aut
|
|
|
158
159
|
* filtered rather than stage it unfiltered.
|
|
159
160
|
*/
|
|
160
161
|
const LFS_COMMAND = /^git(?:-| +)lfs +(?:clean|smudge|filter-process)(?: +--skip)?(?: +(?:-- +)?%f)?$/;
|
|
161
|
-
/** git's `-z --show-origin` listing, setting by setting: the file it came from, its key, and its value when it has one. */
|
|
162
|
+
/** git's `-z --show-scope --show-origin` listing, setting by setting: its scope, the file it came from, its key, and its value when it has one. */
|
|
162
163
|
function* settings(listing) {
|
|
163
164
|
const parts = listing.split('\0');
|
|
164
|
-
for (let i = 0; i +
|
|
165
|
-
const origin = parts[i], entry = parts[i +
|
|
165
|
+
for (let i = 0; i + 2 < parts.length; i += 3) {
|
|
166
|
+
const scope = parts[i], origin = parts[i + 1], entry = parts[i + 2];
|
|
166
167
|
const at = entry.indexOf('\n');
|
|
167
|
-
yield { file: origin.startsWith('file:') ? origin.slice('file:'.length) : undefined, key: at < 0 ? entry : entry.slice(0, at), value: at < 0 ? undefined : entry.slice(at + 1) };
|
|
168
|
+
yield { scope, file: origin.startsWith('file:') ? origin.slice('file:'.length) : undefined, key: at < 0 ? entry : entry.slice(0, at), value: at < 0 ? undefined : entry.slice(at + 1) };
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
/**
|
|
171
|
-
* The
|
|
172
|
-
*
|
|
173
|
-
*
|
|
172
|
+
* The scopes outside the checkout, whose settings the sandbox's copy takes: the system config and the global one. Apple's
|
|
173
|
+
* git reads the config bundled with it (under Xcode or the Command Line Tools, `usr/share/git-core/gitconfig`, which names
|
|
174
|
+
* the osxkeychain credential helper) under no scope git has a name for, so `--show-scope` says `unknown`; GIT_CONFIG_NOSYSTEM
|
|
175
|
+
* turns that file off with /etc/gitconfig. The checkout's own settings (local, worktree) git in the sandbox reads as they
|
|
176
|
+
* are, and the command's own (`-c`, GIT_CONFIG_PARAMETERS) it gets in its environment.
|
|
177
|
+
*/
|
|
178
|
+
const OUTSIDE_SCOPES = new Set(['system', 'unknown', 'global']);
|
|
179
|
+
/**
|
|
180
|
+
* The person's git settings scrubbed for the sandbox, from git's own `-z --show-scope --show-origin` listing of every scope
|
|
181
|
+
* (the scope, NUL, `file:path`, NUL, the key, a newline and the value, NUL, per setting; the includes that hold for this
|
|
182
|
+
* checkout already read in): the text of a config file that holds the kept settings of the system and global scopes, in
|
|
183
|
+
* git's order, so a global setting still wins over the system one, and the global files they came from, for the deny list.
|
|
184
|
+
* The system files (Apple git's under Xcode or the Command Line Tools, /etc/gitconfig) hold no sign-in of the person's and
|
|
185
|
+
* GIT_CONFIG_NOSYSTEM keeps git in the sandbox from reading them, so they are not denied; the checkout's own settings git
|
|
186
|
+
* reads from the checkout, which the copy does not repeat.
|
|
174
187
|
*/
|
|
175
188
|
export function scrubGitConfig(listing) {
|
|
176
189
|
const quoted = (text) => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t')}"`;
|
|
177
190
|
const files = new Set();
|
|
191
|
+
const helpers = new Set();
|
|
178
192
|
const sections = new Map();
|
|
179
|
-
for (const { file, key, value } of settings(listing)) {
|
|
180
|
-
if (
|
|
193
|
+
for (const { scope, file, key, value } of settings(listing)) {
|
|
194
|
+
if (!OUTSIDE_SCOPES.has(scope))
|
|
195
|
+
continue;
|
|
196
|
+
if (file && scope === 'global')
|
|
181
197
|
files.add(file);
|
|
198
|
+
// A system config's helper by its bare name (`osxkeychain`, `libsecret`): the program git-credential-<name> on git's exec path, which no sandboxed command is to run.
|
|
199
|
+
const named = scope !== 'global' && /^credential(?:\..+)?\.helper$/i.test(key) ? /^([^\s!/]+)(?:\s|$)/.exec(value ?? '')?.[1] : undefined;
|
|
200
|
+
if (named)
|
|
201
|
+
helpers.add(named);
|
|
182
202
|
if (!KEPT_SETTINGS.test(key) || (/^filter\..+\.(?:clean|smudge|process)$/i.test(key) && !LFS_COMMAND.test(value ?? '')))
|
|
183
203
|
continue;
|
|
184
204
|
const section = key.slice(0, key.indexOf('.')), name = key.slice(key.lastIndexOf('.') + 1);
|
|
@@ -188,19 +208,23 @@ export function scrubGitConfig(listing) {
|
|
|
188
208
|
lines.push(value === undefined ? `\t${name}` : `\t${name} = ${quoted(value)}`);
|
|
189
209
|
sections.set(header, lines);
|
|
190
210
|
}
|
|
191
|
-
return { text: [...sections].map(([header, lines]) => `${[header, ...lines].join('\n')}\n`).join(''), files: [...files] };
|
|
211
|
+
return { text: [...sections].map(([header, lines]) => `${[header, ...lines].join('\n')}\n`).join(''), files: [...files], helpers: [...helpers] };
|
|
192
212
|
}
|
|
213
|
+
/** Where Apple's gits keep their programs, git-credential-osxkeychain among them: the one xcode-select names answers `git --exec-path`, the other stays, and a script could run either. */
|
|
214
|
+
const APPLE_GIT_CORE = ['/Applications/Xcode.app/Contents/Developer/usr/libexec/git-core', '/Library/Developer/CommandLineTools/usr/libexec/git-core'];
|
|
193
215
|
/** A setting that names a file git includes: include.path, and includeIf.<condition>.path, whose condition may not hold for this checkout. */
|
|
194
216
|
const INCLUDE = /^include(?:if\..*)?\.path$/i;
|
|
195
217
|
/**
|
|
196
218
|
* Every file the settings in a listing include, whether the include holds for this checkout or not, since a token sits as
|
|
197
219
|
* readily behind an includeIf for another one: `~/` from the home folder, a relative path from the including file's folder,
|
|
198
|
-
* an absolute path as it is, and never another user's `~user/`, which only git resolves.
|
|
220
|
+
* an absolute path as it is, and never another user's `~user/`, which only git resolves. Of one scope's settings only when
|
|
221
|
+
* `scope` is given: a listing of every scope carries the checkout's own includes too, which git in the sandbox reads as
|
|
222
|
+
* they are, so they are not for the deny list.
|
|
199
223
|
*/
|
|
200
|
-
export function gitIncludes(listing, home) {
|
|
224
|
+
export function gitIncludes(listing, home, scope) {
|
|
201
225
|
const named = new Set();
|
|
202
|
-
for (const { file, key, value } of settings(listing)) {
|
|
203
|
-
if (!INCLUDE.test(key) || value === undefined)
|
|
226
|
+
for (const { scope: own, file, key, value } of settings(listing)) {
|
|
227
|
+
if ((scope !== undefined && own !== scope) || !INCLUDE.test(key) || value === undefined)
|
|
204
228
|
continue;
|
|
205
229
|
if (value.startsWith('~/'))
|
|
206
230
|
named.add(join(home, value.slice(2)));
|
|
@@ -212,46 +236,55 @@ export function gitIncludes(listing, home) {
|
|
|
212
236
|
return [...named];
|
|
213
237
|
}
|
|
214
238
|
/**
|
|
215
|
-
* The scrubbed copy of the person's
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
239
|
+
* The scrubbed copy of the person's git settings, system and global, read as the person from the checkout (an includeIf
|
|
240
|
+
* for it holds): the system scope from a listing of every scope with the global config turned off, since git lists the
|
|
241
|
+
* system config Apple's git reads under no scope's name only without a scope flag, and `--system` names /etc/gitconfig
|
|
242
|
+
* alone; the global scope from its own listing, which dies on a global config git may not open where the listing of every
|
|
243
|
+
* scope would skip it. Written where no sandboxed command writes; and the global files the settings came from or include,
|
|
244
|
+
* held or not, each read for what it includes in turn. Without a global config git says so and exits 128, and the copy
|
|
245
|
+
* holds the system settings alone, if any: git in the sandbox then reads the checkout's own settings besides. A config or
|
|
246
|
+
* an include that holds for this checkout that git cannot read otherwise (a bad line, a file it may not open) stops the
|
|
247
|
+
* run, with git's words: what it includes in turn would stay readable, unknown to the deny list. One held for another
|
|
248
|
+
* checkout only is named in the debug log and stays denied, and the run goes on, though what it includes in turn stays
|
|
249
|
+
* unknown. Also the credential helper programs the system config names, where they are (git's exec path, Apple's two), for
|
|
250
|
+
* the deny list: GIT_CONFIG_NOSYSTEM keeps git in the sandbox from naming them, and a script that unsets it, or runs one by
|
|
251
|
+
* its path, finds the program unreadable, so no keychain is asked (the denied keychain folder would answer it nothing anyway).
|
|
222
252
|
*/
|
|
223
253
|
function scrubbedGitConfig(root, home, log) {
|
|
224
254
|
const env = gitEnvironment({ ...process.env, HOME: home });
|
|
225
|
-
const list = (args,
|
|
255
|
+
const list = (args, o = {}) => {
|
|
226
256
|
try {
|
|
227
|
-
return execFileSync('git', ['config', ...args, '-z', '--list', '--show-origin'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024, env });
|
|
257
|
+
return execFileSync('git', ['config', ...args, '-z', '--list', '--show-scope', '--show-origin'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024, env: { ...env, ...o.env } });
|
|
228
258
|
}
|
|
229
259
|
catch (err) {
|
|
230
260
|
const said = String(err.stderr ?? err.message).trim().split('\n').pop() ?? '';
|
|
231
261
|
if (/unable to read config file .*: No such file or directory/.test(said))
|
|
232
262
|
return '';
|
|
233
|
-
if (unread) {
|
|
234
|
-
unread(said);
|
|
263
|
+
if (o.unread) {
|
|
264
|
+
o.unread(said);
|
|
235
265
|
return '';
|
|
236
266
|
}
|
|
237
|
-
throw new UserError(`Impeccable keeps your
|
|
267
|
+
throw new UserError(`Impeccable keeps your git settings and the files they include out of the sandbox, and git could not read them (${said}). Fix that file, or the setting that includes it, and run impeccable-reviews again.`, { event: 'setup_command_check_failed', outcome: 'sandbox_failed' });
|
|
238
268
|
}
|
|
239
269
|
};
|
|
240
|
-
|
|
241
|
-
const
|
|
270
|
+
// The system scope first, so a global setting wins over the system one in the copy as it does for git.
|
|
271
|
+
const listing = list(['--includes'], { env: { GIT_CONFIG_GLOBAL: '/dev/null' } }) + list(['--global', '--includes']);
|
|
272
|
+
const { text, files, helpers } = scrubGitConfig(listing);
|
|
242
273
|
const seen = new Set(files);
|
|
243
|
-
const includes = gitIncludes(listing, home);
|
|
274
|
+
const includes = gitIncludes(listing, home, 'global');
|
|
244
275
|
for (const file of includes) {
|
|
245
276
|
if (seen.has(file))
|
|
246
277
|
continue;
|
|
247
278
|
seen.add(file);
|
|
248
279
|
// The listing above read every include that holds for this checkout and stopped on one git cannot read, so one that
|
|
249
280
|
// fails here holds for another checkout only.
|
|
250
|
-
includes.push(...gitIncludes(list(['--file', file, '--no-includes'], (said) => log.write('git include not read', { file, said })), home));
|
|
281
|
+
includes.push(...gitIncludes(list(['--file', file, '--no-includes'], { unread: (said) => log.write('git include not read', { file, said }) }), home));
|
|
251
282
|
}
|
|
252
283
|
const path = join(ownFolder(), `gitconfig-${randomBytes(6).toString('hex')}`);
|
|
253
284
|
writeFileSync(path, text, { mode: 0o600 });
|
|
254
|
-
|
|
285
|
+
const execPath = execFileSync('git', ['--exec-path'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env }).trim();
|
|
286
|
+
const programs = helpers.flatMap((name) => [...new Set([execPath, ...APPLE_GIT_CORE])].map((dir) => join(dir, `git-credential-${name}`))).filter(present);
|
|
287
|
+
return { path, files: [...seen], helpers: programs };
|
|
255
288
|
}
|
|
256
289
|
/** Whether `path` is there, a dangling symlink too. */
|
|
257
290
|
function present(path) {
|
|
@@ -379,12 +412,23 @@ export function allowedDomains(s) {
|
|
|
379
412
|
names.push(...(HOST_APIS[agreed] ?? []));
|
|
380
413
|
return [...new Set(names)];
|
|
381
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Nothing that asks for a password, a passphrase or a host's key, on the terminal, in a window or in Git Credential Manager's:
|
|
417
|
+
* the fence's own reads of the remote run with it, and so does every command in the sandbox, where a `git ls-remote` of a
|
|
418
|
+
* repository git has no sign-in for would otherwise wait on a prompt in the person's terminal, where the screen is drawing.
|
|
419
|
+
*/
|
|
420
|
+
export const NO_PROMPT = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never' };
|
|
382
421
|
/**
|
|
383
422
|
* What a command in the sandbox also gets: Node's fetch through the sandbox's proxy; no browser downloads, which land in
|
|
384
|
-
* caches it does not write, from hosts it does not reach;
|
|
385
|
-
* the sandbox keeps
|
|
423
|
+
* caches it does not write, from hosts it does not reach; no git hooks installed by husky, which writes the git config
|
|
424
|
+
* the sandbox keeps; no prompt for a sign-in; and no system git config, which the sandbox lets git read (Apple's sits
|
|
425
|
+
* under Xcode or the Command Line Tools, not in a denied folder) and which names the osxkeychain credential helper there,
|
|
426
|
+
* so that git in the sandbox has no credential helper at all, as it has none from the global config: it asks for no
|
|
427
|
+
* sign-in rather than fail in a helper. What keeps the sign-in from a script is the denied keychain folder, which holds
|
|
428
|
+
* for a script that unsets this variable too (the login keychain, where git's helper and gh keep their items, is a file
|
|
429
|
+
* the asking process opens, so the helper and `security` find no item); this variable takes the asking away. The kept settings come through the scrubbed copy.
|
|
386
430
|
*/
|
|
387
|
-
const INSIDE = { NODE_USE_ENV_PROXY: '1', CYPRESS_INSTALL_BINARY: '0', PUPPETEER_SKIP_DOWNLOAD: '1', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', HUSKY: '0' };
|
|
431
|
+
const INSIDE = { NODE_USE_ENV_PROXY: '1', CYPRESS_INSTALL_BINARY: '0', PUPPETEER_SKIP_DOWNLOAD: '1', PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', HUSKY: '0', GIT_CONFIG_NOSYSTEM: '1', ...NO_PROMPT };
|
|
388
432
|
/** And, for the agent's shell, npm's cache and corepack's package managers in the run's scratch folder: what a sandboxed install fetched stays with the run, and ~/.npm, which the person's npm and npx read from, is never written. */
|
|
389
433
|
const scratchCaches = (scratch) => ({ npm_config_cache: join(scratch, 'npm-cache'), COREPACK_HOME: join(scratch, 'corepack'), COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' });
|
|
390
434
|
/**
|
|
@@ -468,7 +512,7 @@ export async function startSandbox(o) {
|
|
|
468
512
|
}
|
|
469
513
|
})();
|
|
470
514
|
const global = scrubbedGitConfig(git.root, home, o.log);
|
|
471
|
-
const policy = sandboxPolicy({ git, scratch: realpathSync(o.scratch), home, platform, worktrees, gitConfigs: global.files });
|
|
515
|
+
const policy = sandboxPolicy({ git, scratch: realpathSync(o.scratch), home, platform, worktrees, gitConfigs: [...global.files, ...global.helpers] });
|
|
472
516
|
const files = checkoutFiles(git.root);
|
|
473
517
|
const sources = { host: o.host, files, read: (path) => readText(join(git.root, path)), npmrc: readText(join(home, '.npmrc')) };
|
|
474
518
|
let domains = allowedDomains({ ...sources, hosts: o.hosts() });
|
|
@@ -562,7 +606,7 @@ export async function startSandbox(o) {
|
|
|
562
606
|
const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, undefined, options.signal);
|
|
563
607
|
running++;
|
|
564
608
|
try {
|
|
565
|
-
// Git in the sandbox reads the scrubbed copy of the global settings: the person's own are denied, and git dies on a global config it cannot read.
|
|
609
|
+
// Git in the sandbox reads the scrubbed copy of the system and global settings: the person's own global files are denied, the system ones turned off (INSIDE), and git dies on a global config it cannot read.
|
|
566
610
|
return await exec(wrapped, cwd, { ...options, env: { ...(options.env ?? process.env), ...INSIDE, ...scratchCaches(o.scratch), ...pnpmEnvironment(home, platform), GIT_CONFIG_GLOBAL: global.path } });
|
|
567
611
|
}
|
|
568
612
|
finally {
|