iterate-skill-installer 3.5.2 → 3.6.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/bin/cli.js +27 -7
- package/lib/installer.js +333 -104
- package/package.json +2 -2
package/bin/cli.js
CHANGED
|
@@ -42,28 +42,48 @@ Examples:
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
async function run() {
|
|
45
|
-
|
|
45
|
+
let options;
|
|
46
|
+
try {
|
|
47
|
+
options = parseArgs(process.argv.slice(2));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
// Hard argument errors are THROWN by parseArgs (library code never calls
|
|
50
|
+
// process.exit); report them cleanly at this boundary.
|
|
51
|
+
console.error(`Error: ${err.message}`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
46
54
|
|
|
47
55
|
if (options.mode === 'help') {
|
|
48
56
|
printHelp();
|
|
49
|
-
|
|
57
|
+
return 0;
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
if (options.mode === 'version') {
|
|
53
61
|
console.log(`iterate-skill-installer ${VERSION}`);
|
|
54
|
-
|
|
62
|
+
return 0;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
try {
|
|
58
66
|
const code = await main(options);
|
|
59
67
|
// Normalize: a null/non-numeric code (main should never produce one, but a
|
|
60
68
|
// signal-killed child upstream does) must surface as a failure, never as
|
|
61
|
-
// the success exit 0 that
|
|
62
|
-
|
|
69
|
+
// the success exit 0 that a null exit code would yield.
|
|
70
|
+
return typeof code === 'number' ? code : 1;
|
|
63
71
|
} catch (err) {
|
|
64
72
|
console.error(`\nUnexpected error: ${err.message}`);
|
|
65
|
-
|
|
73
|
+
return 1;
|
|
66
74
|
}
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
|
|
77
|
+
// CLI boundary: set process.exitCode rather than calling process.exit(), so
|
|
78
|
+
// buffered stdout/stderr still flush and no caller cleanup is skipped. The
|
|
79
|
+
// trailing catch is the top-level net: an unexpected rejection (e.g. a throw
|
|
80
|
+
// before run()'s inner try) becomes a clean message + non-zero code, never an
|
|
81
|
+
// unhandled-rejection stack trace.
|
|
82
|
+
run()
|
|
83
|
+
.then((code) => {
|
|
84
|
+
process.exitCode = typeof code === 'number' ? code : 1;
|
|
85
|
+
})
|
|
86
|
+
.catch((err) => {
|
|
87
|
+
console.error(`\nUnexpected error: ${err && err.message ? err.message : err}`);
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
});
|
package/lib/installer.js
CHANGED
|
@@ -298,7 +298,19 @@ function isGithubApiUrl(url) {
|
|
|
298
298
|
function normalizeToken(raw) {
|
|
299
299
|
if (raw == null) return null;
|
|
300
300
|
const trimmed = String(raw).trim();
|
|
301
|
-
|
|
301
|
+
if (trimmed === '') return null;
|
|
302
|
+
// Trim only removes the ends. A PAT never contains interior whitespace or
|
|
303
|
+
// control characters; one that does (a CR/LF smuggled in from a file, a
|
|
304
|
+
// tab-pasted secret) would either split the Authorization header
|
|
305
|
+
// (CRLF/header injection) or fail as a baffling 401 that looks like an
|
|
306
|
+
// expired token. Reject it here, at the single boundary both the CLI and
|
|
307
|
+
// buildAuthFlags normalize through.
|
|
308
|
+
if (/[\s\x00-\x1f\x7f]/.test(trimmed)) {
|
|
309
|
+
throw new InstallerError(
|
|
310
|
+
'GitHub token contains whitespace or control characters; re-copy it (check GITHUB_TOKEN / --token).',
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
return trimmed;
|
|
302
314
|
}
|
|
303
315
|
|
|
304
316
|
// GitHub PAT prefixes accepted by scripts/install.py (kept in sync so the two
|
|
@@ -339,9 +351,14 @@ function validateTokenShape(token) {
|
|
|
339
351
|
* loudly instead of leaking the PAT to a foreign host.
|
|
340
352
|
*/
|
|
341
353
|
function buildAuthFlags(url, token) {
|
|
342
|
-
|
|
354
|
+
// Re-normalize here as well as at the CLI boundary: this is the exact point
|
|
355
|
+
// the token is interpolated into a header, so a raw CR/LF (e.g. a token read
|
|
356
|
+
// from a Windows-created file) must never reach curl — an embedded newline
|
|
357
|
+
// would split the header (CRLF/header injection) or silently break auth.
|
|
358
|
+
const clean = normalizeToken(token);
|
|
359
|
+
if (!clean) return [];
|
|
343
360
|
if (!isGithubApiUrl(url)) return [];
|
|
344
|
-
return ['-H', `Authorization: Bearer ${
|
|
361
|
+
return ['-H', `Authorization: Bearer ${clean}`, '--max-redirs', '0'];
|
|
345
362
|
}
|
|
346
363
|
|
|
347
364
|
async function fetchJson(url, token) {
|
|
@@ -349,7 +366,10 @@ async function fetchJson(url, token) {
|
|
|
349
366
|
// and avoids Node-specific certificate issues in some environments.
|
|
350
367
|
const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
|
|
351
368
|
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', ...buildAuthFlags(url, token)];
|
|
352
|
-
|
|
369
|
+
// `--` ends curl's option parsing so a URL that begins with `-` (e.g. a
|
|
370
|
+
// hostile/odd `browser_download_url` served by the API) is treated as a URL,
|
|
371
|
+
// never smuggled in as a curl flag.
|
|
372
|
+
args.push('--', url);
|
|
353
373
|
// stdoutMustBeComplete with an explicit cap: the default 1 MiB TailBuffer
|
|
354
374
|
// would truncate a large /releases payload and JSON.parse then failed with a
|
|
355
375
|
// baffling "Unexpected end of JSON input". The cap now matches the
|
|
@@ -381,7 +401,9 @@ async function downloadFile(url, destPath, token, { progress = false, maxBytes =
|
|
|
381
401
|
const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
|
|
382
402
|
const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(maxBytes), '-o', destPath, '-H', 'User-Agent: iterate-skill-installer', ...buildAuthFlags(url, token)];
|
|
383
403
|
if (progress) args.push('--progress-bar');
|
|
384
|
-
|
|
404
|
+
// `--` ends curl's option parsing so a `-`-leading URL can never be read as
|
|
405
|
+
// a curl flag (option injection); it is always the final positional URL.
|
|
406
|
+
args.push('--', url);
|
|
385
407
|
if (progress) {
|
|
386
408
|
// Let curl write its progress bar directly to the terminal.
|
|
387
409
|
await runCommand('curl', args, { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
@@ -429,8 +451,16 @@ function enforceDownloadedSize(destPath, maxBytes) {
|
|
|
429
451
|
// project config and shows up in the invoking environment for audit.
|
|
430
452
|
const ALLOW_UNSIGNED_ENV = 'ITERATE_ALLOW_UNSIGNED';
|
|
431
453
|
|
|
454
|
+
// Only these (case-insensitive) values enable the override. A bare "any
|
|
455
|
+
// non-empty string" check meant `ITERATE_ALLOW_UNSIGNED=0` (or "false", or a
|
|
456
|
+
// stray "no") ENABLED unsigned installs — the exact opposite of what the
|
|
457
|
+
// operator intended, and a silent downgrade of the authenticity gate.
|
|
458
|
+
const ALLOW_UNSIGNED_TRUTHY = new Set(['1', 'true', 'yes']);
|
|
459
|
+
|
|
432
460
|
function allowUnsigned() {
|
|
433
|
-
|
|
461
|
+
const raw = process.env[ALLOW_UNSIGNED_ENV];
|
|
462
|
+
if (raw == null) return false;
|
|
463
|
+
return ALLOW_UNSIGNED_TRUTHY.has(String(raw).trim().toLowerCase());
|
|
434
464
|
}
|
|
435
465
|
|
|
436
466
|
/**
|
|
@@ -499,6 +529,31 @@ function sha256File(filePath) {
|
|
|
499
529
|
});
|
|
500
530
|
}
|
|
501
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Constant-time, case-insensitive comparison of two SHA256 hex digests.
|
|
534
|
+
*
|
|
535
|
+
* Casing must never decide the outcome: `sha256sum`/other tools may emit
|
|
536
|
+
* upper-case hex, and a case-sensitive `===` would reject a perfectly good
|
|
537
|
+
* release. Both sides are lower-cased and compared with `timingSafeEqual` —
|
|
538
|
+
* the expected hash travels over the network beside the tarball, so an
|
|
539
|
+
* early-exit string inequality would leak prefix information usable to forge a
|
|
540
|
+
* checksum. The length guard keeps `timingSafeEqual` from throwing on
|
|
541
|
+
* mismatched lengths (an empty/short value is refused outright).
|
|
542
|
+
*
|
|
543
|
+
* Exported so the comparison itself is unit-testable (the inline version it
|
|
544
|
+
* replaces could only be exercised by a full network install).
|
|
545
|
+
*/
|
|
546
|
+
function constantTimeHashEqual(actualHash, expectedHash) {
|
|
547
|
+
if (typeof actualHash !== 'string' || typeof expectedHash !== 'string') return false;
|
|
548
|
+
const actualBuf = Buffer.from(actualHash.trim().toLowerCase(), 'utf8');
|
|
549
|
+
const expectedBuf = Buffer.from(expectedHash.trim().toLowerCase(), 'utf8');
|
|
550
|
+
return (
|
|
551
|
+
actualBuf.length === expectedBuf.length &&
|
|
552
|
+
actualBuf.length > 0 &&
|
|
553
|
+
crypto.timingSafeEqual(actualBuf, expectedBuf)
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
502
557
|
// Best-effort GPG authenticity verification of a downloaded artifact against
|
|
503
558
|
// the embedded signing key. Returns one of:
|
|
504
559
|
// 'verified' — `gpg` present and the detached signature is valid.
|
|
@@ -511,7 +566,14 @@ function sha256File(filePath) {
|
|
|
511
566
|
// defense-in-depth layer, not the only gate.
|
|
512
567
|
// Runs inside an isolated temporary GNUPGHOME so the end user's own keyring and
|
|
513
568
|
// gpg-agent are never touched or modified.
|
|
514
|
-
|
|
569
|
+
// gpg calls are local and short. Without a timeout a wedged gpg-agent (or a
|
|
570
|
+
// pinentry dialog nobody can see) hangs the installer forever; SHA256 already
|
|
571
|
+
// gates integrity, so treating a stuck authenticity check as 'unavailable' is
|
|
572
|
+
// both honest and non-blocking.
|
|
573
|
+
const GPG_TIMEOUT_MS = 30 * 1000;
|
|
574
|
+
|
|
575
|
+
function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor, tmpDir, options = {}) {
|
|
576
|
+
const timeout = options.timeoutMs ?? GPG_TIMEOUT_MS;
|
|
515
577
|
// NOTE: gpg derives its agent socket from the homedir
|
|
516
578
|
// (<homedir>/S.gpg-agent) and unix sockets cap out around 104 characters, so
|
|
517
579
|
// keep this path SHORT — a deep tmpdir made gpg fail with "File name too
|
|
@@ -524,22 +586,37 @@ function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor,
|
|
|
524
586
|
const importResult = spawnSync(
|
|
525
587
|
'gpg',
|
|
526
588
|
['--homedir', gpgHome, '--batch', '--yes', '--quiet', '--no-autostart', '--import', keyPath],
|
|
527
|
-
{ encoding: 'utf8' }
|
|
589
|
+
{ encoding: 'utf8', timeout, killSignal: 'SIGKILL' }
|
|
528
590
|
);
|
|
529
591
|
if (importResult.error) {
|
|
530
|
-
|
|
531
|
-
|
|
592
|
+
if (importResult.error.code === 'ENOENT') {
|
|
593
|
+
return { status: 'unavailable', detail: 'gpg binary not found' };
|
|
594
|
+
}
|
|
595
|
+
if (importResult.error.code === 'ETIMEDOUT' || importResult.signal === 'SIGKILL') {
|
|
596
|
+
return { status: 'unavailable', detail: `gpg import timed out after ${timeout} ms` };
|
|
597
|
+
}
|
|
598
|
+
return { status: 'unavailable', detail: importResult.error.message };
|
|
532
599
|
}
|
|
533
600
|
if (importResult.status !== 0) {
|
|
534
|
-
|
|
601
|
+
// Failure to import the release key means we cannot establish authenticity
|
|
602
|
+
// — not that the artifact is forged. Classifying it 'invalid' claimed
|
|
603
|
+
// evidence of tampering that this run never had, and only 'unavailable'
|
|
604
|
+
// matches the documented non-blocking policy.
|
|
605
|
+
return {
|
|
606
|
+
status: 'unavailable',
|
|
607
|
+
detail: (importResult.stderr || '').trim() || 'failed to import the signing key',
|
|
608
|
+
};
|
|
535
609
|
}
|
|
536
610
|
|
|
537
611
|
const verifyResult = spawnSync(
|
|
538
612
|
'gpg',
|
|
539
613
|
['--homedir', gpgHome, '--batch', '--quiet', '--no-autostart', '--verify', signaturePath, artifactPath],
|
|
540
|
-
{ encoding: 'utf8' }
|
|
614
|
+
{ encoding: 'utf8', timeout, killSignal: 'SIGKILL' }
|
|
541
615
|
);
|
|
542
616
|
if (verifyResult.error) {
|
|
617
|
+
if (verifyResult.error.code === 'ETIMEDOUT' || verifyResult.signal === 'SIGKILL') {
|
|
618
|
+
return { status: 'unavailable', detail: `gpg verify timed out after ${timeout} ms` };
|
|
619
|
+
}
|
|
543
620
|
return { status: 'unavailable', detail: `gpg launch failed: ${verifyResult.error.message}` };
|
|
544
621
|
}
|
|
545
622
|
// gpg exit 0 = good signature; 1 = bad signature; 2 = fatal (bad key usage).
|
|
@@ -747,77 +824,135 @@ function runCommand(bin, args, options = {}) {
|
|
|
747
824
|
|
|
748
825
|
async function rejectLinkEntries(tarballPath, { listingCap = MAX_LISTING_CHARS } = {}) {
|
|
749
826
|
// Inspect the verbose listing and reject any symbolic ("l") or hard ("h")
|
|
750
|
-
// link entry
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
// a
|
|
827
|
+
// link entry — and any device ("b" block / "c" character), FIFO ("p") or
|
|
828
|
+
// socket special node. We deliberately reject *all* links rather than
|
|
829
|
+
// validating their targets: the release tarball contains none, so any link is
|
|
830
|
+
// a sign of a tampered archive and refusing it outright is the safest
|
|
831
|
+
// response to a symlink-escape attempt (see extractTarball above). Special
|
|
832
|
+
// nodes are rejected for the same reason: neither the release nor its
|
|
833
|
+
// installer ever ships one, and a FIFO/device could wedge or bypass a later
|
|
834
|
+
// read of the extracted tree.
|
|
754
835
|
//
|
|
755
836
|
// stdoutMustBeComplete: this listing is a SECURITY input. The old 1 MiB
|
|
756
837
|
// tail buffer silently dropped the OLDEST entries, so an archive with enough
|
|
757
838
|
// filler members to push its listing past the cap hid a symlink/hardlink
|
|
758
839
|
// entry from this scan entirely (while `tar -xzf` still extracted it).
|
|
759
840
|
// A listing over the cap now fails closed instead.
|
|
841
|
+
//
|
|
842
|
+
// Returns the COMPLETE verbose listing so callers (extractTarball) can reuse
|
|
843
|
+
// it for the pre-extraction declared-size check without a second tar run.
|
|
760
844
|
const verbose = await runCommand('tar', ['-tvzf', tarballPath], {
|
|
761
845
|
stdoutCap: listingCap,
|
|
762
846
|
stdoutMustBeComplete: true,
|
|
763
847
|
});
|
|
848
|
+
const TYPE_LABELS = {
|
|
849
|
+
l: 'symbolic link',
|
|
850
|
+
h: 'hard link',
|
|
851
|
+
b: 'block device node',
|
|
852
|
+
c: 'character device node',
|
|
853
|
+
p: 'FIFO',
|
|
854
|
+
s: 'socket',
|
|
855
|
+
};
|
|
764
856
|
for (const line of verbose.split(/\r?\n/)) {
|
|
765
857
|
if (!line) {
|
|
766
858
|
continue;
|
|
767
859
|
}
|
|
768
860
|
const type = line[0];
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
'Refusing to extract tarball: archive contains a symbolic link, which the release never ships.',
|
|
772
|
-
);
|
|
773
|
-
}
|
|
774
|
-
if (type === 'h') {
|
|
861
|
+
const label = TYPE_LABELS[type];
|
|
862
|
+
if (label) {
|
|
775
863
|
throw new InstallerError(
|
|
776
|
-
|
|
864
|
+
`Refusing to extract tarball: archive contains a ${label}, which the release never ships.`,
|
|
777
865
|
);
|
|
778
866
|
}
|
|
779
867
|
}
|
|
868
|
+
return verbose;
|
|
780
869
|
}
|
|
781
870
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
//
|
|
789
|
-
// Extraction uses --strip-components=1, which relies on the archive having
|
|
790
|
-
// exactly one top-level directory (see README "Release tarball structure").
|
|
791
|
-
// Archives that violate that contract — multiple top-level directories, or
|
|
792
|
-
// entries with no path left after stripping — are rejected outright.
|
|
793
|
-
//
|
|
794
|
-
// A name-based `../` check alone is not enough: a tarball can smuggle a
|
|
795
|
-
// symlink whose in-archive name looks safe but whose target points outside
|
|
796
|
-
// destDir, then place files "through" that symlink (e.g. `link/passwd` where
|
|
797
|
-
// `link -> /etc`). Because extraction happens with a system `tar`, the write
|
|
798
|
-
// would follow the link before we could validate — so any symbolic or hard
|
|
799
|
-
// link is rejected *before* extraction. The release tarball never uses links.
|
|
800
|
-
const absoluteDest = path.resolve(destDir);
|
|
801
|
-
fs.mkdirSync(absoluteDest, { recursive: true });
|
|
871
|
+
// A month abbreviation ("Jan".."Dec") appears in the timestamp column of the
|
|
872
|
+
// bsdtar verbose listing; GNU tar prints an ISO date ("2023-01-01"). Both mark
|
|
873
|
+
// the end of the size/user fields, so the numeric token immediately before the
|
|
874
|
+
// first such token is the entry's declared uncompressed size in EITHER format.
|
|
875
|
+
const MONTH_ABBR_RE = /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/;
|
|
876
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
802
877
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
878
|
+
/**
|
|
879
|
+
* Parse one `tar -tvzf` line's declared uncompressed size, or null when the
|
|
880
|
+
* line cannot be parsed. `size` is the numeric field immediately preceding the
|
|
881
|
+
* timestamp column (see MONTH_ABBR_RE / ISO_DATE_RE).
|
|
882
|
+
*/
|
|
883
|
+
function declaredEntrySize(line) {
|
|
884
|
+
const tokens = line.trim().split(/\s+/);
|
|
885
|
+
for (let i = 1; i < tokens.length; i += 1) {
|
|
886
|
+
if (ISO_DATE_RE.test(tokens[i]) || MONTH_ABBR_RE.test(tokens[i])) {
|
|
887
|
+
const candidate = tokens[i - 1];
|
|
888
|
+
return /^\d+$/.test(candidate) ? Number(candidate) : null;
|
|
889
|
+
}
|
|
813
890
|
}
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Sum the declared uncompressed sizes in a `tar -tvzf` listing.
|
|
896
|
+
*
|
|
897
|
+
* This is the PRE-extraction half of the decompression-bomb guard: `tar -xzf`
|
|
898
|
+
* has no size bound and the download cap only limits the COMPRESSED size, so a
|
|
899
|
+
* small highly-compressible archive can declare (and would expand to) many GiB.
|
|
900
|
+
* Reading the declared sizes out of the listing lets us refuse BEFORE writing
|
|
901
|
+
* anything to disk. Returns the total plus how many entries were parsed so a
|
|
902
|
+
* caller can distinguish "empty" from "unparseable format".
|
|
903
|
+
*/
|
|
904
|
+
function sumDeclaredSizes(verboseListing) {
|
|
905
|
+
let total = 0;
|
|
906
|
+
let parsed = 0;
|
|
907
|
+
let entries = 0;
|
|
908
|
+
for (const line of verboseListing.split(/\r?\n/)) {
|
|
909
|
+
if (!line.trim()) continue;
|
|
910
|
+
entries += 1;
|
|
911
|
+
const size = declaredEntrySize(line);
|
|
912
|
+
if (size !== null) {
|
|
913
|
+
total += size;
|
|
914
|
+
parsed += 1;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return { total, parsed, entries };
|
|
918
|
+
}
|
|
814
919
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
920
|
+
/**
|
|
921
|
+
* Refuse a listing whose sizes could not all be read.
|
|
922
|
+
*
|
|
923
|
+
* A partially parsed listing used to sum only the entries it understood, so a
|
|
924
|
+
* localized tar (e.g. `oct.` instead of `Oct`, or a numeric date format) made
|
|
925
|
+
* the bomb guard compare a bogus small total and let the archive through. All
|
|
926
|
+
* entries must be understood before the total may be trusted.
|
|
927
|
+
*/
|
|
928
|
+
function assertDeclaredSizesKnown(declared) {
|
|
929
|
+
if (declared.parsed === declared.entries) return;
|
|
930
|
+
const unknown = declared.entries - declared.parsed;
|
|
931
|
+
throw new InstallerError(
|
|
932
|
+
`Refusing to extract tarball: could not read the declared size of ${unknown} of ` +
|
|
933
|
+
`${declared.entries} entries. This is commonly a localized tar listing; rerun with LC_ALL=C.`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Validate every archive member NAME before extraction.
|
|
939
|
+
*
|
|
940
|
+
* Rejects, in order:
|
|
941
|
+
* - absolute paths (`/etc/...`, `C:\...`), which invite ambiguity between
|
|
942
|
+
* tar implementations about what the "first component" is;
|
|
943
|
+
* - any `..` path component (path traversal), which — even when the
|
|
944
|
+
* top-level-strip logic happens to keep it inside destDir — violates the
|
|
945
|
+
* release's "single top-level directory" contract and must never appear;
|
|
946
|
+
* - archives without exactly one top-level directory (required by
|
|
947
|
+
* --strip-components=1);
|
|
948
|
+
* - entries that still escape destDir after the top-level component is
|
|
949
|
+
* stripped.
|
|
950
|
+
*
|
|
951
|
+
* Pure and exported so the guards are unit-testable without crafting hostile
|
|
952
|
+
* tarballs. Returns the resolved top-level directory name.
|
|
953
|
+
*/
|
|
954
|
+
function validateEntryNames(entries, absoluteDest) {
|
|
955
|
+
// Reject absolute-path entries outright before any other accounting.
|
|
821
956
|
for (const entry of entries) {
|
|
822
957
|
if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) {
|
|
823
958
|
throw new InstallerError(
|
|
@@ -826,15 +961,20 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
|
|
|
826
961
|
}
|
|
827
962
|
}
|
|
828
963
|
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
|
|
834
|
-
|
|
964
|
+
// Reject any ``..`` component. A name-based resolve() check below already
|
|
965
|
+
// catches escapes, but an explicit rejection is unambiguous and also refuses
|
|
966
|
+
// the "stripped away" cases (e.g. a lone `..` top-level) that resolve back
|
|
967
|
+
// inside destDir while still violating the archive contract.
|
|
968
|
+
for (const entry of entries) {
|
|
969
|
+
if (entry.split('/').includes('..')) {
|
|
970
|
+
throw new InstallerError(
|
|
971
|
+
`Refusing to extract tarball: entry "${entry}" contains a ".." path component.`,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
835
975
|
|
|
836
|
-
//
|
|
837
|
-
//
|
|
976
|
+
// Enforce the single-top-level-directory contract required by
|
|
977
|
+
// --strip-components=1.
|
|
838
978
|
const topLevelComponents = new Set(entries.map((entry) => entry.split('/')[0]));
|
|
839
979
|
if (topLevelComponents.size !== 1) {
|
|
840
980
|
const tops = [...topLevelComponents].join(', ');
|
|
@@ -844,8 +984,8 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
|
|
|
844
984
|
}
|
|
845
985
|
const topLevel = entries[0].split('/')[0];
|
|
846
986
|
|
|
847
|
-
//
|
|
848
|
-
//
|
|
987
|
+
// Validate every entry stays inside destDir after stripping the single
|
|
988
|
+
// top-level directory component (--strip-components=1).
|
|
849
989
|
for (const entry of entries) {
|
|
850
990
|
const stripped = entry.split('/').slice(1).join('/');
|
|
851
991
|
if (stripped === '') {
|
|
@@ -867,7 +1007,74 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
|
|
|
867
1007
|
}
|
|
868
1008
|
}
|
|
869
1009
|
|
|
1010
|
+
return topLevel;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function extractTarball(
|
|
1014
|
+
tarballPath,
|
|
1015
|
+
destDir,
|
|
1016
|
+
{ listingCap = MAX_LISTING_CHARS, declaredSizeCap = MAX_EXTRACTED_BYTES } = {},
|
|
1017
|
+
) {
|
|
1018
|
+
// Use the system tar command (available on macOS, Linux, and Windows 10+),
|
|
1019
|
+
// but defend against path-traversal tarballs: before extracting we list the
|
|
1020
|
+
// archive contents and refuse to extract any entry whose resolved path
|
|
1021
|
+
// escapes destDir (e.g. a "../" prefix). This keeps the installer
|
|
1022
|
+
// dependency-free while closing the theoretical escape vector.
|
|
1023
|
+
//
|
|
1024
|
+
// Extraction uses --strip-components=1, which relies on the archive having
|
|
1025
|
+
// exactly one top-level directory (see README "Release tarball structure").
|
|
1026
|
+
// Archives that violate that contract — multiple top-level directories, or
|
|
1027
|
+
// entries with no path left after stripping — are rejected outright.
|
|
1028
|
+
//
|
|
1029
|
+
// A name-based `../` check alone is not enough: a tarball can smuggle a
|
|
1030
|
+
// symlink whose in-archive name looks safe but whose target points outside
|
|
1031
|
+
// destDir, then place files "through" that symlink (e.g. `link/passwd` where
|
|
1032
|
+
// `link -> /etc`). Because extraction happens with a system `tar`, the write
|
|
1033
|
+
// would follow the link before we could validate — so any symbolic or hard
|
|
1034
|
+
// link is rejected *before* extraction. The release tarball never uses links.
|
|
1035
|
+
// Note: dest is created AFTER every guard below (not before), so a refused
|
|
1036
|
+
// archive never leaves a directory behind. `tar -xzf` requires the target to
|
|
1037
|
+
// exist, so mkdir stays inside this function rather than with the caller.
|
|
1038
|
+
const absoluteDest = path.resolve(destDir);
|
|
1039
|
+
|
|
1040
|
+
// 1. List archive entries (no extraction yet). Must be complete — see
|
|
1041
|
+
// rejectLinkEntries: every guard below reads THIS listing, and a tail
|
|
1042
|
+
// buffer would let a big archive hide entries from all of them.
|
|
1043
|
+
const listing = await runCommand('tar', ['-tzf', tarballPath], {
|
|
1044
|
+
stdoutCap: listingCap,
|
|
1045
|
+
stdoutMustBeComplete: true,
|
|
1046
|
+
});
|
|
1047
|
+
const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
1048
|
+
if (entries.length === 0) {
|
|
1049
|
+
throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// 1a/2/3. Reject absolute paths and `..` components, enforce the single
|
|
1053
|
+
// top-level directory, and prove every entry stays inside destDir.
|
|
1054
|
+
validateEntryNames(entries, absoluteDest);
|
|
1055
|
+
|
|
1056
|
+
// 1b. Reject any symbolic/hard link — and any device/FIFO/socket node —
|
|
1057
|
+
// before touching the tree. The verbose listing's first character is the
|
|
1058
|
+
// entry type for both GNU tar and bsdtar (macOS): '-' regular, 'd' directory,
|
|
1059
|
+
// 'l' symbolic link, 'h' hard link, 'b'/'c' device, 'p' FIFO. The COMPLETE
|
|
1060
|
+
// listing is returned so the declared-size check below can reuse it.
|
|
1061
|
+
const verbose = await rejectLinkEntries(tarballPath, { listingCap });
|
|
1062
|
+
|
|
1063
|
+
// 1c. PRE-extraction decompression-bomb check: refuse an archive whose
|
|
1064
|
+
// declared uncompressed total exceeds the budget, using the sizes from
|
|
1065
|
+
// the listing we already parsed. This fails before a single byte is
|
|
1066
|
+
// written, complementing the post-extraction re-measure in step 5.
|
|
1067
|
+
const declared = sumDeclaredSizes(verbose);
|
|
1068
|
+
assertDeclaredSizesKnown(declared);
|
|
1069
|
+
if (declared.total > declaredSizeCap) {
|
|
1070
|
+
throw new InstallerError(
|
|
1071
|
+
`Refusing to extract tarball: declared uncompressed size ${declared.total} bytes ` +
|
|
1072
|
+
`exceeds the safety budget (${declaredSizeCap} bytes).`,
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
870
1076
|
// 4. Now extract safely — the content has been validated above.
|
|
1077
|
+
fs.mkdirSync(absoluteDest, { recursive: true });
|
|
871
1078
|
await runCommand('tar', ['-xzf', tarballPath, '-C', absoluteDest, '--strip-components=1']);
|
|
872
1079
|
|
|
873
1080
|
// 5. Decompression-bomb guard (mirrors scripts/install.py). ``tar`` itself
|
|
@@ -1164,8 +1371,10 @@ function buildPythonInstallArgs(options) {
|
|
|
1164
1371
|
*
|
|
1165
1372
|
* Kept as a pure function (exported) so the flag surface — including the
|
|
1166
1373
|
* skill-only ``--no-cli`` switch — can be unit-tested without spawning the
|
|
1167
|
-
* full download-and-install pipeline.
|
|
1168
|
-
*
|
|
1374
|
+
* full download-and-install pipeline. Hard argument errors (a value-consuming
|
|
1375
|
+
* flag missing its value, an unknown option) THROW an ``InstallerError`` for
|
|
1376
|
+
* the CLI boundary to report; library code must never call ``process.exit``
|
|
1377
|
+
* (which would skip the caller's cleanup/flush and is untestable in-process).
|
|
1169
1378
|
*/
|
|
1170
1379
|
function parseArgs(argv) {
|
|
1171
1380
|
const options = {
|
|
@@ -1188,40 +1397,61 @@ function parseArgs(argv) {
|
|
|
1188
1397
|
const arg = argv[i];
|
|
1189
1398
|
const next = argv[i + 1];
|
|
1190
1399
|
|
|
1400
|
+
// Accept `--flag=value` as well as `--flag value`: `--ai=trae` is the
|
|
1401
|
+
// spelling users copy from other CLIs, and it used to die as an unknown
|
|
1402
|
+
// option. Split it here so every value-taking switch shares one path.
|
|
1403
|
+
let name = arg;
|
|
1404
|
+
let inlineValue = null;
|
|
1405
|
+
if (arg.startsWith('--')) {
|
|
1406
|
+
const eq = arg.indexOf('=');
|
|
1407
|
+
if (eq !== -1) {
|
|
1408
|
+
name = arg.slice(0, eq);
|
|
1409
|
+
inlineValue = arg.slice(eq + 1);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1191
1413
|
// -h/--help and -v/--version short-circuit parsing: later arguments (even
|
|
1192
1414
|
// malformed or value-consuming flags) must not turn `--help`/`--version`
|
|
1193
1415
|
// into an error exit. bin/cli.js exits 0 on these modes before installing.
|
|
1194
|
-
if (
|
|
1416
|
+
if (name === '--help' || name === '-h') {
|
|
1195
1417
|
options.mode = 'help';
|
|
1196
1418
|
break;
|
|
1197
1419
|
}
|
|
1198
|
-
if (
|
|
1420
|
+
if (name === '--version' || name === '-v') {
|
|
1199
1421
|
options.mode = 'version';
|
|
1200
1422
|
break;
|
|
1201
1423
|
}
|
|
1202
1424
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1425
|
+
// Boolean flags never take a value; `--force=1` is a typo worth naming
|
|
1426
|
+
// rather than silently treating as --force.
|
|
1427
|
+
if (inlineValue !== null && ['--global', '--force', '--no-cli'].includes(name)) {
|
|
1428
|
+
throw new InstallerError(`${name} does not take a value`);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
switch (name) {
|
|
1432
|
+
case '--ai': {
|
|
1433
|
+
const value = inlineValue !== null ? inlineValue : next;
|
|
1434
|
+
if (!value || value.startsWith('-')) {
|
|
1435
|
+
throw new InstallerError('--ai requires a value (a flag was found instead)');
|
|
1208
1436
|
}
|
|
1209
|
-
options.ai =
|
|
1210
|
-
i++;
|
|
1437
|
+
options.ai = value;
|
|
1438
|
+
if (inlineValue === null) i++;
|
|
1211
1439
|
break;
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1440
|
+
}
|
|
1441
|
+
case '--target': {
|
|
1442
|
+
const value = inlineValue !== null ? inlineValue : next;
|
|
1443
|
+
if (!value || value.startsWith('-')) {
|
|
1444
|
+
throw new InstallerError('--target requires a value (a flag was found instead)');
|
|
1216
1445
|
}
|
|
1217
1446
|
if (options.globalExplicit) {
|
|
1218
1447
|
warning('--target overrides --global: installing into the project directory instead.');
|
|
1219
1448
|
}
|
|
1220
|
-
options.target =
|
|
1449
|
+
options.target = value;
|
|
1221
1450
|
options.global = false;
|
|
1222
1451
|
options.targetExplicit = true;
|
|
1223
|
-
i++;
|
|
1452
|
+
if (inlineValue === null) i++;
|
|
1224
1453
|
break;
|
|
1454
|
+
}
|
|
1225
1455
|
case '--global':
|
|
1226
1456
|
if (options.targetExplicit) {
|
|
1227
1457
|
warning('--global overrides --target: installing into the user home directory instead.');
|
|
@@ -1238,18 +1468,18 @@ function parseArgs(argv) {
|
|
|
1238
1468
|
// user who only wants the skill is not surprised by a global install.
|
|
1239
1469
|
options.noCli = true;
|
|
1240
1470
|
break;
|
|
1241
|
-
case '--token':
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1471
|
+
case '--token': {
|
|
1472
|
+
const value = inlineValue !== null ? inlineValue : next;
|
|
1473
|
+
if (!value || value.startsWith('-')) {
|
|
1474
|
+
throw new InstallerError('--token requires a value (a flag was found instead)');
|
|
1245
1475
|
}
|
|
1246
|
-
options.token = normalizeToken(
|
|
1247
|
-
i++;
|
|
1476
|
+
options.token = normalizeToken(value);
|
|
1477
|
+
if (inlineValue === null) i++;
|
|
1248
1478
|
break;
|
|
1479
|
+
}
|
|
1249
1480
|
default:
|
|
1250
1481
|
if (arg.startsWith('-')) {
|
|
1251
|
-
|
|
1252
|
-
process.exit(1);
|
|
1482
|
+
throw new InstallerError(`unknown option ${name}`);
|
|
1253
1483
|
}
|
|
1254
1484
|
// A bare positional argument is not a valid installer flag; warn so a
|
|
1255
1485
|
// typo like "npx iterate-skill-installer trae" is not silently ignored.
|
|
@@ -1416,16 +1646,8 @@ async function main(options = {}) {
|
|
|
1416
1646
|
return 1;
|
|
1417
1647
|
}
|
|
1418
1648
|
const actualHash = await sha256File(tarballPath);
|
|
1419
|
-
// Constant-time compare (
|
|
1420
|
-
|
|
1421
|
-
// would leak prefix information usable to forge a checksum. The length
|
|
1422
|
-
// guard keeps timingSafeEqual from throwing on mismatched lengths.
|
|
1423
|
-
const actualBuf = Buffer.from(actualHash.toLowerCase(), 'utf8');
|
|
1424
|
-
const expectedBuf = Buffer.from(expectedHash.toLowerCase(), 'utf8');
|
|
1425
|
-
const hashMatches =
|
|
1426
|
-
actualBuf.length === expectedBuf.length &&
|
|
1427
|
-
actualBuf.length > 0 &&
|
|
1428
|
-
crypto.timingSafeEqual(actualBuf, expectedBuf);
|
|
1649
|
+
// Constant-time, case-insensitive compare (see constantTimeHashEqual).
|
|
1650
|
+
const hashMatches = constantTimeHashEqual(actualHash, expectedHash);
|
|
1429
1651
|
if (!hashMatches) {
|
|
1430
1652
|
error('Checksum mismatch — refusing to install.');
|
|
1431
1653
|
return 1;
|
|
@@ -1558,9 +1780,14 @@ module.exports = {
|
|
|
1558
1780
|
resolveInstallMode,
|
|
1559
1781
|
askYesNo,
|
|
1560
1782
|
parseChecksums,
|
|
1783
|
+
constantTimeHashEqual,
|
|
1561
1784
|
enforceDownloadedSize,
|
|
1562
1785
|
extractTarball,
|
|
1563
1786
|
rejectLinkEntries,
|
|
1787
|
+
sumDeclaredSizes,
|
|
1788
|
+
assertDeclaredSizesKnown,
|
|
1789
|
+
declaredEntrySize,
|
|
1790
|
+
validateEntryNames,
|
|
1564
1791
|
isGithubApiUrl,
|
|
1565
1792
|
normalizeToken,
|
|
1566
1793
|
validateTokenShape,
|
|
@@ -1577,6 +1804,8 @@ module.exports = {
|
|
|
1577
1804
|
FullBuffer,
|
|
1578
1805
|
measureTree,
|
|
1579
1806
|
enforceExtractedSize,
|
|
1807
|
+
verifyGpgDetachedSignature,
|
|
1808
|
+
GPG_TIMEOUT_MS,
|
|
1580
1809
|
MAX_LISTING_CHARS,
|
|
1581
1810
|
MAX_EXTRACTED_BYTES,
|
|
1582
1811
|
MAX_EXTRACTED_MEMBER_BYTES,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-skill-installer",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "One-command installer for iterate-skill across AI coding assistants",
|
|
5
5
|
"main": "lib/installer.js",
|
|
6
6
|
"files": [
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"iterate-skill-installer": "bin/cli.js"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
|
-
"test": "node --check bin/cli.js && node --check lib/installer.js && node test/mode.test.js && node test/tarball.test.js"
|
|
17
|
+
"test": "node --check bin/cli.js && node --check lib/installer.js && node test/mode.test.js && node test/tarball.test.js && node test/security.test.js"
|
|
18
18
|
},
|
|
19
19
|
"keywords": [
|
|
20
20
|
"iterate-skill",
|