iterate-skill-installer 3.0.1 → 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 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 args = ['-sSL', '--fail-with-body', '--max-time', CURL_MAX_TIME_SECONDS, '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-H', 'User-Agent: iterate-skill-installer'];
140
- if (token) {
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 args = ['-sSL', '--fail-with-body', '--max-time', CURL_MAX_TIME_SECONDS, '-o', destPath, '-H', 'User-Agent: iterate-skill-installer'];
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
- if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {
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) => {