iterate-skill-installer 3.1.0 → 3.2.0
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/lib/installer.js +78 -7
- package/package.json +1 -1
- package/test/mode.test.js +12 -1
package/lib/installer.js
CHANGED
|
@@ -47,6 +47,18 @@ const CHECKSUMS_ASSET_NAME = 'SHA256SUMS.txt';
|
|
|
47
47
|
// must never hang the installer forever.
|
|
48
48
|
const CURL_MAX_TIME_SECONDS = '120';
|
|
49
49
|
|
|
50
|
+
// Byte caps so a misbehaving upstream can never exhaust disk or memory:
|
|
51
|
+
// JSON API responses are tiny, checksums are a few KB, and the release
|
|
52
|
+
// tarball is far smaller than the generous ceiling below.
|
|
53
|
+
const JSON_MAX_BYTES = 10 * 1024 * 1024; // 10 MiB
|
|
54
|
+
const CHECKSUMS_MAX_BYTES = 1 * 1024 * 1024; // 1 MiB
|
|
55
|
+
const TARBALL_MAX_BYTES = 100 * 1024 * 1024; // 100 MiB
|
|
56
|
+
|
|
57
|
+
// Curl added --fail-with-body in 7.76. On older curl the installer must fall
|
|
58
|
+
// back to plain --fail so the flag is never rejected by the system curl.
|
|
59
|
+
const CURL_FAIL_WITH_BODY_MIN = { major: 7, minor: 76 };
|
|
60
|
+
let CURL_SUPPORTS_FAIL_WITH_BODY = null; // lazily probed once
|
|
61
|
+
|
|
50
62
|
// Package version read from package.json at runtime so the --version/-v flag
|
|
51
63
|
// and the published npm version always stay in sync.
|
|
52
64
|
const VERSION = require('../package.json').version;
|
|
@@ -133,11 +145,50 @@ function commandExists(bin) {
|
|
|
133
145
|
return result.status === 0;
|
|
134
146
|
}
|
|
135
147
|
|
|
148
|
+
async function supportsCurlFailWithBody() {
|
|
149
|
+
// Lazily probe ``curl --version`` once; the result is cached for the rest
|
|
150
|
+
// of the run. Falls back to the plain ``--fail`` flag (no probing) when
|
|
151
|
+
// curl itself cannot be invoked or its output is unexpected.
|
|
152
|
+
if (CURL_SUPPORTS_FAIL_WITH_BODY === null) {
|
|
153
|
+
try {
|
|
154
|
+
const versionOut = await runCommand('curl', ['--version']);
|
|
155
|
+
const match = versionOut.match(/curl (\d+)\.(\d+)\.\d+/);
|
|
156
|
+
if (!match) {
|
|
157
|
+
CURL_SUPPORTS_FAIL_WITH_BODY = false;
|
|
158
|
+
} else {
|
|
159
|
+
const major = Number(match[1]);
|
|
160
|
+
const minor = Number(match[2]);
|
|
161
|
+
CURL_SUPPORTS_FAIL_WITH_BODY =
|
|
162
|
+
major > CURL_FAIL_WITH_BODY_MIN.major ||
|
|
163
|
+
(major === CURL_FAIL_WITH_BODY_MIN.major &&
|
|
164
|
+
minor >= CURL_FAIL_WITH_BODY_MIN.minor);
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
CURL_SUPPORTS_FAIL_WITH_BODY = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return CURL_SUPPORTS_FAIL_WITH_BODY;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isGithubApiUrl(url) {
|
|
174
|
+
// True only for the GitHub API host. Release-asset and other URLs are
|
|
175
|
+
// public and must never receive the caller's PAT (credential surface
|
|
176
|
+
// minimalism) — mirrors scripts/install.py::_is_github_api_url.
|
|
177
|
+
let hostname = null;
|
|
178
|
+
try {
|
|
179
|
+
hostname = new URL(url).hostname.toLowerCase();
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return hostname === 'api.github.com' || hostname.endsWith('.api.github.com');
|
|
184
|
+
}
|
|
185
|
+
|
|
136
186
|
async function fetchJson(url, token) {
|
|
137
187
|
// Prefer curl over Node.js fetch because curl uses the system CA store
|
|
138
188
|
// and avoids Node-specific certificate issues in some environments.
|
|
139
|
-
const
|
|
140
|
-
|
|
189
|
+
const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
|
|
190
|
+
const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(JSON_MAX_BYTES), '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-H', 'User-Agent: iterate-skill-installer'];
|
|
191
|
+
if (token && isGithubApiUrl(url)) {
|
|
141
192
|
args.push('-H', `Authorization: Bearer ${token}`);
|
|
142
193
|
}
|
|
143
194
|
args.push(url);
|
|
@@ -152,14 +203,20 @@ async function fetchJson(url, token) {
|
|
|
152
203
|
/**
|
|
153
204
|
* Download a file with curl.
|
|
154
205
|
*
|
|
206
|
+
* The caller's PAT is only attached for GitHub API hosts; release assets are
|
|
207
|
+
* public downloads and must not receive it. ``maxBytes`` is enforced with
|
|
208
|
+
* curl's ``--max-filesize`` so a misbehaving upstream is aborted rather than
|
|
209
|
+
* exhausting disk.
|
|
210
|
+
*
|
|
155
211
|
* When ``progress`` is enabled (used for the tarball), curl's ``--progress-bar``
|
|
156
212
|
* writes a live percentage bar straight to the terminal (stderr is inherited so
|
|
157
213
|
* the bar is visible; stdout stays captured for non-progress downloads).
|
|
158
214
|
*/
|
|
159
|
-
async function downloadFile(url, destPath, token, { progress = false } = {}) {
|
|
160
|
-
const
|
|
215
|
+
async function downloadFile(url, destPath, token, { progress = false, maxBytes = TARBALL_MAX_BYTES } = {}) {
|
|
216
|
+
const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
|
|
217
|
+
const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(maxBytes), '-o', destPath, '-H', 'User-Agent: iterate-skill-installer'];
|
|
161
218
|
if (progress) args.push('--progress-bar');
|
|
162
|
-
if (token) {
|
|
219
|
+
if (token && isGithubApiUrl(url)) {
|
|
163
220
|
args.push('-H', `Authorization: Bearer ${token}`);
|
|
164
221
|
}
|
|
165
222
|
args.push(url);
|
|
@@ -651,7 +708,9 @@ async function main(options = {}) {
|
|
|
651
708
|
await downloadFile(tarballAsset.browser_download_url, tarballPath, token, { progress: true });
|
|
652
709
|
|
|
653
710
|
info('Downloading checksums...');
|
|
654
|
-
await downloadFile(checksumAsset.browser_download_url, checksumPath, token
|
|
711
|
+
await downloadFile(checksumAsset.browser_download_url, checksumPath, token, {
|
|
712
|
+
maxBytes: CHECKSUMS_MAX_BYTES,
|
|
713
|
+
});
|
|
655
714
|
|
|
656
715
|
info('Verifying checksum...');
|
|
657
716
|
const checksums = parseChecksums(fs.readFileSync(checksumPath, 'utf8'));
|
|
@@ -661,7 +720,17 @@ async function main(options = {}) {
|
|
|
661
720
|
return 1;
|
|
662
721
|
}
|
|
663
722
|
const actualHash = await sha256File(tarballPath);
|
|
664
|
-
|
|
723
|
+
// Constant-time compare (timingSafeEqual): the expected hash travels
|
|
724
|
+
// over the network with the tarball, so an early-exit string inequality
|
|
725
|
+
// would leak prefix information usable to forge a checksum. The length
|
|
726
|
+
// guard keeps timingSafeEqual from throwing on mismatched lengths.
|
|
727
|
+
const actualBuf = Buffer.from(actualHash.toLowerCase(), 'utf8');
|
|
728
|
+
const expectedBuf = Buffer.from(expectedHash.toLowerCase(), 'utf8');
|
|
729
|
+
const hashMatches =
|
|
730
|
+
actualBuf.length === expectedBuf.length &&
|
|
731
|
+
actualBuf.length > 0 &&
|
|
732
|
+
crypto.timingSafeEqual(actualBuf, expectedBuf);
|
|
733
|
+
if (!hashMatches) {
|
|
665
734
|
error('Checksum mismatch — refusing to install.');
|
|
666
735
|
return 1;
|
|
667
736
|
}
|
|
@@ -752,4 +821,6 @@ module.exports = {
|
|
|
752
821
|
parseChecksums,
|
|
753
822
|
extractTarball,
|
|
754
823
|
rejectLinkEntries,
|
|
824
|
+
isGithubApiUrl,
|
|
825
|
+
supportsCurlFailWithBody,
|
|
755
826
|
};
|
package/package.json
CHANGED
package/test/mode.test.js
CHANGED
|
@@ -9,7 +9,7 @@ const assert = require('node:assert');
|
|
|
9
9
|
const fs = require('node:fs');
|
|
10
10
|
const path = require('node:path');
|
|
11
11
|
const os = require('node:os');
|
|
12
|
-
const { resolveInstallMode, parseChecksums, parseArgs, buildPythonInstallArgs } = require('../lib/installer');
|
|
12
|
+
const { resolveInstallMode, parseChecksums, parseArgs, buildPythonInstallArgs, isGithubApiUrl } = require('../lib/installer');
|
|
13
13
|
|
|
14
14
|
const originalCwd = process.cwd();
|
|
15
15
|
const originalHome = os.homedir;
|
|
@@ -212,6 +212,17 @@ async function run() {
|
|
|
212
212
|
assert.deepStrictEqual(buildPythonInstallArgs({ ai: null, target: null, force: false, globalInstall: false }), [], 'no flags -> empty argv');
|
|
213
213
|
|
|
214
214
|
console.log('mode.test.js: all buildPythonInstallArgs tests passed');
|
|
215
|
+
|
|
216
|
+
// isGithubApiUrl: the caller's PAT must only ever be attached to the
|
|
217
|
+
// api.github.com host — never to public release-asset / other URLs
|
|
218
|
+
// (credential surface minimalism, mirroring scripts/install.py).
|
|
219
|
+
assert.strictEqual(isGithubApiUrl('https://api.github.com/repos/jingzhao-l/iterate-skill/releases/latest'), true, 'api host should match');
|
|
220
|
+
assert.strictEqual(isGithubApiUrl('https://api.github.com.evil.com/x'), false, 'lookalike host must not match');
|
|
221
|
+
assert.strictEqual(isGithubApiUrl('https://release-assets.githubusercontent.com/foo.tar.gz'), false, 'asset host must not receive the token');
|
|
222
|
+
assert.strictEqual(isGithubApiUrl('https://github.com/x'), false, 'github.com is not the API host');
|
|
223
|
+
assert.strictEqual(isGithubApiUrl('not a url'), false, 'malformed url must not match');
|
|
224
|
+
|
|
225
|
+
console.log('mode.test.js: all isGithubApiUrl tests passed');
|
|
215
226
|
}
|
|
216
227
|
|
|
217
228
|
run().catch((err) => {
|