@indigoai-us/hq-cli 5.33.0 → 5.34.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/dist/commands/__fixtures__/make-tar.d.ts +46 -0
- package/dist/commands/__fixtures__/make-tar.js +105 -0
- package/dist/commands/master-sync.d.ts +11 -0
- package/dist/commands/master-sync.js +15 -0
- package/dist/commands/pack-install.d.ts +187 -1
- package/dist/commands/pack-install.js +405 -16
- package/dist/commands/packs.js +9 -4
- package/dist/commands/publish.d.ts +186 -0
- package/dist/commands/publish.js +375 -0
- package/dist/commands/rescue.d.ts +33 -0
- package/dist/commands/rescue.js +161 -0
- package/dist/commands/safe-extract.d.ts +154 -0
- package/dist/commands/safe-extract.js +347 -0
- package/dist/index.js +17 -2
- package/dist/lib/local-tree-diff.d.ts +21 -0
- package/dist/lib/local-tree-diff.js +18 -3
- package/dist/types.d.ts +22 -0
- package/dist/utils/vault-api.d.ts +11 -0
- package/dist/utils/vault-api.js +39 -2
- package/package.json +2 -2
- package/src/commands/__fixtures__/make-tar.ts +126 -0
- package/src/commands/artifact-verify.test.ts +177 -0
- package/src/commands/marketplace-install.test.ts +414 -0
- package/src/commands/marketplace-security.test.ts +646 -0
- package/src/commands/master-sync.ts +23 -0
- package/src/commands/pack-install.test.ts +209 -1
- package/src/commands/pack-install.ts +617 -15
- package/src/commands/packs.ts +8 -1
- package/src/commands/publish.test.ts +538 -0
- package/src/commands/publish.ts +517 -0
- package/src/commands/rescue.test.ts +39 -0
- package/src/commands/rescue.ts +210 -0
- package/src/commands/safe-extract.test.ts +459 -0
- package/src/commands/safe-extract.ts +444 -0
- package/src/index.ts +18 -0
- package/src/lib/local-tree-diff.test.ts +19 -0
- package/src/lib/local-tree-diff.ts +17 -1
- package/src/types.ts +23 -0
- package/src/utils/vault-api.ts +41 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="e0da62c9-7fd5-5514-b2d0-1b5b76ebcd21")}catch(e){}}();
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as yaml from 'js-yaml';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { rescue } from '@indigoai-us/hq-cloud';
|
|
9
|
+
import { findHqRoot } from '../utils/manifest.js';
|
|
10
|
+
const PROD_SOURCE = 'indigoai-us/hq-core';
|
|
11
|
+
const STAGING_SOURCE = 'indigoai-us/hq-core-staging';
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the source repo + ref from the user's flags. Pure + exported for
|
|
14
|
+
* tests. `latestTag` is the resolved latest release tag (prod only); ignored
|
|
15
|
+
* for staging and when an explicit `--ref` is given.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveRescueTarget(opts, latestTag) {
|
|
18
|
+
if (opts.staging) {
|
|
19
|
+
return { source: opts.source ?? STAGING_SOURCE, ref: opts.ref };
|
|
20
|
+
}
|
|
21
|
+
return { source: opts.source ?? PROD_SOURCE, ref: opts.ref ?? latestTag };
|
|
22
|
+
}
|
|
23
|
+
/** Resolve a GitHub token: prefer `gh auth token`, fall back to env. */
|
|
24
|
+
function resolveGhToken() {
|
|
25
|
+
try {
|
|
26
|
+
const res = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8' });
|
|
27
|
+
if (res.status === 0) {
|
|
28
|
+
const tok = res.stdout.trim();
|
|
29
|
+
if (tok)
|
|
30
|
+
return tok;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// gh not installed — fall through to env.
|
|
35
|
+
}
|
|
36
|
+
return process.env.GH_TOKEN || process.env.GITHUB_TOKEN || undefined;
|
|
37
|
+
}
|
|
38
|
+
function ghHeaders(token) {
|
|
39
|
+
const headers = {
|
|
40
|
+
Accept: 'application/vnd.github+json',
|
|
41
|
+
'User-Agent': 'hq-cli-rescue',
|
|
42
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
43
|
+
};
|
|
44
|
+
if (token)
|
|
45
|
+
headers.Authorization = `Bearer ${token}`;
|
|
46
|
+
return headers;
|
|
47
|
+
}
|
|
48
|
+
/** Latest release tag for a repo, e.g. `v12.3.0`. Throws on failure. */
|
|
49
|
+
async function getLatestReleaseTag(repo, token) {
|
|
50
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
|
51
|
+
headers: ghHeaders(token),
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
throw new Error(`GitHub releases/latest for ${repo}: HTTP ${res.status}`);
|
|
55
|
+
}
|
|
56
|
+
const body = (await res.json());
|
|
57
|
+
if (!body.tag_name)
|
|
58
|
+
throw new Error(`no tag_name in latest release for ${repo}`);
|
|
59
|
+
return body.tag_name;
|
|
60
|
+
}
|
|
61
|
+
/** Resolve a tag to its commit SHA, dereferencing annotated tags. */
|
|
62
|
+
async function resolveTagSha(repo, tag, token) {
|
|
63
|
+
const refRes = await fetch(`https://api.github.com/repos/${repo}/git/ref/tags/${encodeURIComponent(tag)}`, { headers: ghHeaders(token) });
|
|
64
|
+
if (!refRes.ok)
|
|
65
|
+
throw new Error(`git/ref/tags/${tag} for ${repo}: HTTP ${refRes.status}`);
|
|
66
|
+
const ref = (await refRes.json());
|
|
67
|
+
const obj = ref.object;
|
|
68
|
+
if (!obj?.sha)
|
|
69
|
+
throw new Error(`no object.sha for tag ${tag} in ${repo}`);
|
|
70
|
+
if (obj.type !== 'tag')
|
|
71
|
+
return obj.sha; // lightweight tag → already a commit
|
|
72
|
+
// Annotated tag → dereference to the commit it points at.
|
|
73
|
+
const tagRes = await fetch(`https://api.github.com/repos/${repo}/git/tags/${obj.sha}`, {
|
|
74
|
+
headers: ghHeaders(token),
|
|
75
|
+
});
|
|
76
|
+
if (!tagRes.ok)
|
|
77
|
+
throw new Error(`git/tags/${obj.sha} for ${repo}: HTTP ${tagRes.status}`);
|
|
78
|
+
const annotated = (await tagRes.json());
|
|
79
|
+
if (!annotated.object?.sha)
|
|
80
|
+
throw new Error(`annotated tag ${tag} has no target sha`);
|
|
81
|
+
return annotated.object.sha;
|
|
82
|
+
}
|
|
83
|
+
/** Read the installed HQ core version from `{hqRoot}/core/core.yaml`. */
|
|
84
|
+
function readInstalledVersion(hqRoot) {
|
|
85
|
+
const file = path.join(hqRoot, 'core', 'core.yaml');
|
|
86
|
+
if (!fs.existsSync(file))
|
|
87
|
+
return undefined;
|
|
88
|
+
try {
|
|
89
|
+
const doc = yaml.load(fs.readFileSync(file, 'utf8'));
|
|
90
|
+
const v = doc?.hqVersion;
|
|
91
|
+
return typeof v === 'string' ? v : undefined;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function registerRescueCommand(program) {
|
|
98
|
+
program
|
|
99
|
+
.command('rescue')
|
|
100
|
+
.description('Re-sync your HQ core to the latest release, preserving your local edits (drift)')
|
|
101
|
+
.option('--hq-root <path>', 'HQ root to operate on (defaults to auto-detected root)')
|
|
102
|
+
.option('--ref <ref>', 'Target tag/branch (default: latest hq-core release)')
|
|
103
|
+
.option('--source <repo>', 'Source repo (default: indigoai-us/hq-core)')
|
|
104
|
+
.option('--staging', 'Use the staging channel (indigoai-us/hq-core-staging@main)')
|
|
105
|
+
.option('--floor-sha <sha>', 'Pin the three-way history floor to a 40-char commit SHA')
|
|
106
|
+
.option('--paths <list>', 'Comma-separated top-level paths to narrow the rescue to')
|
|
107
|
+
.option('--check', 'Plan only — classify and report, change nothing on disk (--dry-run)')
|
|
108
|
+
.option('-y, --yes', 'Skip the confirmation prompt')
|
|
109
|
+
.option('--no-backup', 'Skip the pre-op safety snapshot under ~/.hq/backups')
|
|
110
|
+
.option('--cloud-update', 'Cloud-update mode')
|
|
111
|
+
.action(async (opts) => {
|
|
112
|
+
try {
|
|
113
|
+
const hqRoot = opts.hqRoot ?? findHqRoot();
|
|
114
|
+
const token = resolveGhToken();
|
|
115
|
+
let { source, ref } = resolveRescueTarget(opts);
|
|
116
|
+
let floorSha = opts.floorSha;
|
|
117
|
+
if (!opts.staging) {
|
|
118
|
+
// Prod: resolve the latest release tag when no explicit ref, and
|
|
119
|
+
// pin the floor to the installed version's commit when possible.
|
|
120
|
+
if (!ref) {
|
|
121
|
+
ref = await getLatestReleaseTag(source, token);
|
|
122
|
+
}
|
|
123
|
+
if (!floorSha) {
|
|
124
|
+
const installed = readInstalledVersion(hqRoot);
|
|
125
|
+
if (installed) {
|
|
126
|
+
try {
|
|
127
|
+
floorSha = await resolveTagSha(source, `v${installed}`, token);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
console.warn(chalk.yellow(`Warning: couldn't resolve the floor commit for v${installed} ` +
|
|
131
|
+
`(${err instanceof Error ? err.message : 'unknown error'}); ` +
|
|
132
|
+
`falling back to the on-disk sync stamp.`));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
console.log(chalk.dim(`Rescuing ${hqRoot} from ${source}${ref ? `@${ref}` : ''}` +
|
|
138
|
+
`${floorSha ? ` (floor ${floorSha.slice(0, 12)})` : ''}` +
|
|
139
|
+
`${opts.check ? ' [dry-run]' : ''}`));
|
|
140
|
+
const { status } = rescue({
|
|
141
|
+
hqRoot,
|
|
142
|
+
source,
|
|
143
|
+
ref,
|
|
144
|
+
floorSha,
|
|
145
|
+
paths: opts.paths ? opts.paths.split(',').map((p) => p.trim()).filter(Boolean) : undefined,
|
|
146
|
+
dryRun: opts.check,
|
|
147
|
+
assumeYes: opts.yes,
|
|
148
|
+
noBackup: opts.backup === false,
|
|
149
|
+
cloudUpdate: opts.cloudUpdate,
|
|
150
|
+
ghToken: token,
|
|
151
|
+
});
|
|
152
|
+
process.exit(status);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error');
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=rescue.js.map
|
|
161
|
+
//# debugId=e0da62c9-7fd5-5514-b2d0-1b5b76ebcd21
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safe-extract — hardened tarball extraction for the content-pack installer.
|
|
3
|
+
*
|
|
4
|
+
* THREAT MODEL (US-020). A pack tarball is UNTRUSTED input fetched from npm,
|
|
5
|
+
* a git remote, or a local path the user merely pointed at. Naively shelling
|
|
6
|
+
* out to `tar -xzf <tarball> -C <dir>` trusts the archive to behave; a hostile
|
|
7
|
+
* archive can:
|
|
8
|
+
*
|
|
9
|
+
* 1. Path traversal / "zip slip": an entry named `../../etc/cron.d/evil` or
|
|
10
|
+
* an absolute `/etc/passwd` escapes the intended target dir and clobbers
|
|
11
|
+
* arbitrary files (RCE if it lands a hook / cron / shell-rc).
|
|
12
|
+
* 2. Symlink/hardlink escape: an entry that is a symlink whose target points
|
|
13
|
+
* OUTSIDE the pack dir (e.g. `link -> /etc`), optionally followed by a
|
|
14
|
+
* second entry that writes "through" the link — the classic two-step
|
|
15
|
+
* tar symlink attack.
|
|
16
|
+
* 3. Decompression bomb: a tiny `.tgz` that inflates to terabytes (many
|
|
17
|
+
* files, or one enormous sparse/zero file), exhausting disk or memory.
|
|
18
|
+
*
|
|
19
|
+
* DEFENSE (defense-in-depth, default-deny):
|
|
20
|
+
* - PRE-FLIGHT: enumerate every entry's header (type, size, name, link
|
|
21
|
+
* target) via `tar -tv` WITHOUT extracting. Reject the whole archive if
|
|
22
|
+
* ANY entry fails a containment or cap check. Containment uses normalized
|
|
23
|
+
* path-prefix logic on the *resolved* path, never naive string matching.
|
|
24
|
+
* - CAPS: enforced against the header-reported uncompressed sizes during the
|
|
25
|
+
* pre-flight scan, so we abort BEFORE writing a single byte of a bomb.
|
|
26
|
+
* - STAGED + ATOMIC: extraction lands in a sibling staging dir on the SAME
|
|
27
|
+
* filesystem as the final destination; only after a successful, fully
|
|
28
|
+
* validated extract do we `fs.rename` it into place (atomic). Any failure
|
|
29
|
+
* mid-extract rolls the staging dir back entirely (`rm -rf` in finally) —
|
|
30
|
+
* no partial/half-wired pack is ever left behind.
|
|
31
|
+
* - POST-EXTRACT: an independent realpath containment sweep over everything
|
|
32
|
+
* actually written (we do NOT rely solely on the pre-flight scan or on the
|
|
33
|
+
* tar binary's own protections).
|
|
34
|
+
*/
|
|
35
|
+
export interface ExtractCaps {
|
|
36
|
+
/** Max total uncompressed bytes across all entries. Default 256 MiB. */
|
|
37
|
+
maxUncompressedBytes: number;
|
|
38
|
+
/** Max number of entries in the archive. Default 20,000. */
|
|
39
|
+
maxFileCount: number;
|
|
40
|
+
/** Max uncompressed bytes of any single entry. Default 64 MiB. */
|
|
41
|
+
maxSingleFileBytes: number;
|
|
42
|
+
}
|
|
43
|
+
export declare const DEFAULT_CAPS: ExtractCaps;
|
|
44
|
+
export declare class UnsafeArchiveError extends Error {
|
|
45
|
+
constructor(message: string);
|
|
46
|
+
}
|
|
47
|
+
export type EntryType = 'file' | 'dir' | 'symlink' | 'hardlink' | 'other';
|
|
48
|
+
export interface TarEntry {
|
|
49
|
+
type: EntryType;
|
|
50
|
+
/** Entry path as recorded in the archive (may contain `..`, be absolute…). */
|
|
51
|
+
name: string;
|
|
52
|
+
/** Uncompressed size in bytes from the header (0 for dirs/links). */
|
|
53
|
+
size: number;
|
|
54
|
+
/** For sym/hardlinks: the link target as recorded in the archive. */
|
|
55
|
+
linkTarget?: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parse the verbose listing produced by `tar -tv`. We use the verbose form so
|
|
59
|
+
* we get the type flag, size, and (for links) the `name -> target` /
|
|
60
|
+
* `name link to target` suffix without extracting anything.
|
|
61
|
+
*
|
|
62
|
+
* SECURITY-CRITICAL CROSS-PLATFORM NOTE. bsdtar (macOS/BSD) and GNU tar
|
|
63
|
+
* (Linux / prod Lambdas) format the verbose listing DIFFERENTLY, and an
|
|
64
|
+
* implementation that only understands one of them silently parses ZERO
|
|
65
|
+
* entries against the other — which means the pre-flight containment scan
|
|
66
|
+
* sees nothing to reject and every malicious archive sails through. The two
|
|
67
|
+
* shapes we must both handle:
|
|
68
|
+
*
|
|
69
|
+
* bsdtar: <mode> <links> <owner> <group> <size> <Mon> <DD> <HH:MM|YYYY> <name...>
|
|
70
|
+
* -rw-r--r-- 0 user group 1234 Jan 1 00:00 path/file
|
|
71
|
+
* lrwxr-xr-x 0 user group 0 Jan 1 00:00 link -> target
|
|
72
|
+
*
|
|
73
|
+
* GNU: <mode> <owner>/<group> <size> <YYYY-MM-DD> <HH:MM[:SS]> <name...>
|
|
74
|
+
* -rw-r--r-- user/group 1234 2026-01-01 00:00 path/file
|
|
75
|
+
* lrwxrwxrwx user/group 0 2026-01-01 00:00 link -> target
|
|
76
|
+
* hrw-r--r-- user/group 0 2026-01-01 00:00 hard link to target
|
|
77
|
+
*
|
|
78
|
+
* GNU has NO link-count column, joins owner/group with `/`, and renders the
|
|
79
|
+
* date ISO-style (`YYYY-MM-DD HH:MM`) instead of `Mon DD HH:MM`. The previous
|
|
80
|
+
* parser anchored on a `[A-Za-z]{3}` month token, so it matched NOTHING under
|
|
81
|
+
* GNU tar and returned [] — the cross-platform hole this fix closes.
|
|
82
|
+
*
|
|
83
|
+
* Robust strategy: do NOT anchor on the (format-divergent) date column. Anchor
|
|
84
|
+
* on the leading mode string (always present, position 0, stable type char),
|
|
85
|
+
* then take the SIZE as the last integer that appears before the date/time
|
|
86
|
+
* column, accepting BOTH date formats. The leading mode char is the type flag:
|
|
87
|
+
* '-' file, 'd' dir, 'l' symlink, 'h' hardlink (GNU prints 'h'); bsdtar
|
|
88
|
+
* prints hardlinks as files with a "link to" suffix, which we also detect.
|
|
89
|
+
*/
|
|
90
|
+
export declare function parseTarListing(verbose: string): TarEntry[];
|
|
91
|
+
/**
|
|
92
|
+
* True iff `candidate` is `base` itself or strictly nested under it, judged on
|
|
93
|
+
* normalized absolute paths with a trailing-separator guard so that a sibling
|
|
94
|
+
* like `/a/baseEVIL` is NOT considered "under" `/a/base`.
|
|
95
|
+
*/
|
|
96
|
+
export declare function isContained(base: string, candidate: string): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Resolve where an entry NAME would land under `targetDir` and assert it stays
|
|
99
|
+
* inside. Rejects absolute names and any `..` that escapes. Returns the
|
|
100
|
+
* resolved absolute path on success.
|
|
101
|
+
*/
|
|
102
|
+
export declare function resolveContainedPath(targetDir: string, entryName: string): string;
|
|
103
|
+
/**
|
|
104
|
+
* Validate a sym/hardlink's target stays inside `packDir`. The link is created
|
|
105
|
+
* AT `entryAbs` (already known-contained), so a relative target is resolved
|
|
106
|
+
* against the link's parent dir; an absolute target is checked as-is. Either
|
|
107
|
+
* way the final location must be contained in the pack dir.
|
|
108
|
+
*/
|
|
109
|
+
export declare function assertLinkTargetContained(packDir: string, entryAbs: string, linkTarget: string): void;
|
|
110
|
+
/**
|
|
111
|
+
* List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
|
|
112
|
+
* argv form — never a shell string — so a hostile filename can't break out.
|
|
113
|
+
*/
|
|
114
|
+
export declare function listTarball(tarballPath: string): TarEntry[];
|
|
115
|
+
/**
|
|
116
|
+
* Pre-flight validation against the entry headers. Enforces (a) containment of
|
|
117
|
+
* every entry name under `targetDir`, (b) containment of every link target
|
|
118
|
+
* under `targetDir`, and (c) the three decompression-bomb caps. Throws
|
|
119
|
+
* UnsafeArchiveError on the first violation — default-deny.
|
|
120
|
+
*/
|
|
121
|
+
export declare function validateEntries(entries: TarEntry[], targetDir: string, caps?: ExtractCaps): void;
|
|
122
|
+
/**
|
|
123
|
+
* Walk everything actually written under `stagingDir` and assert that no entry
|
|
124
|
+
* — and, for symlinks, no link target — escapes. This is the defense-in-depth
|
|
125
|
+
* backstop: it does not trust the pre-flight scan OR the tar binary. Uses
|
|
126
|
+
* realpath on the link's PARENT (which exists) plus lstat to inspect links
|
|
127
|
+
* without following them off-tree.
|
|
128
|
+
*/
|
|
129
|
+
export declare function assertExtractContained(stagingDir: string): void;
|
|
130
|
+
export interface SafeExtractOptions {
|
|
131
|
+
caps?: ExtractCaps;
|
|
132
|
+
/**
|
|
133
|
+
* Test seam: invoked AFTER `tar` extracts into the staging dir but BEFORE
|
|
134
|
+
* the post-extract sweep / atomic rename. Throwing here simulates a failure
|
|
135
|
+
* "mid-extract" and must leave NO staging or final dir behind.
|
|
136
|
+
*/
|
|
137
|
+
afterExtractHook?: (stagingDir: string) => void;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Safely extract `tarballPath` so its contents land atomically at `finalDir`.
|
|
141
|
+
*
|
|
142
|
+
* Sequence:
|
|
143
|
+
* 1. Pre-flight list + validate (containment + caps) — abort before writing.
|
|
144
|
+
* 2. Extract into a sibling `*.staging-<rand>` dir on the SAME filesystem.
|
|
145
|
+
* 3. Post-extract realpath containment sweep (independent backstop).
|
|
146
|
+
* 4. `afterExtractHook` (test failure-injection seam).
|
|
147
|
+
* 5. Atomic `fs.rename(staging -> finalDir)`.
|
|
148
|
+
* 6. finally: `rm -rf` the staging dir if it still exists (rollback).
|
|
149
|
+
*
|
|
150
|
+
* On ANY failure the staging dir is removed and `finalDir` is never created,
|
|
151
|
+
* so a half-extracted pack can never be wired.
|
|
152
|
+
*/
|
|
153
|
+
export declare function safeExtractTarball(tarballPath: string, finalDir: string, opts?: SafeExtractOptions): void;
|
|
154
|
+
//# sourceMappingURL=safe-extract.d.ts.map
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safe-extract — hardened tarball extraction for the content-pack installer.
|
|
3
|
+
*
|
|
4
|
+
* THREAT MODEL (US-020). A pack tarball is UNTRUSTED input fetched from npm,
|
|
5
|
+
* a git remote, or a local path the user merely pointed at. Naively shelling
|
|
6
|
+
* out to `tar -xzf <tarball> -C <dir>` trusts the archive to behave; a hostile
|
|
7
|
+
* archive can:
|
|
8
|
+
*
|
|
9
|
+
* 1. Path traversal / "zip slip": an entry named `../../etc/cron.d/evil` or
|
|
10
|
+
* an absolute `/etc/passwd` escapes the intended target dir and clobbers
|
|
11
|
+
* arbitrary files (RCE if it lands a hook / cron / shell-rc).
|
|
12
|
+
* 2. Symlink/hardlink escape: an entry that is a symlink whose target points
|
|
13
|
+
* OUTSIDE the pack dir (e.g. `link -> /etc`), optionally followed by a
|
|
14
|
+
* second entry that writes "through" the link — the classic two-step
|
|
15
|
+
* tar symlink attack.
|
|
16
|
+
* 3. Decompression bomb: a tiny `.tgz` that inflates to terabytes (many
|
|
17
|
+
* files, or one enormous sparse/zero file), exhausting disk or memory.
|
|
18
|
+
*
|
|
19
|
+
* DEFENSE (defense-in-depth, default-deny):
|
|
20
|
+
* - PRE-FLIGHT: enumerate every entry's header (type, size, name, link
|
|
21
|
+
* target) via `tar -tv` WITHOUT extracting. Reject the whole archive if
|
|
22
|
+
* ANY entry fails a containment or cap check. Containment uses normalized
|
|
23
|
+
* path-prefix logic on the *resolved* path, never naive string matching.
|
|
24
|
+
* - CAPS: enforced against the header-reported uncompressed sizes during the
|
|
25
|
+
* pre-flight scan, so we abort BEFORE writing a single byte of a bomb.
|
|
26
|
+
* - STAGED + ATOMIC: extraction lands in a sibling staging dir on the SAME
|
|
27
|
+
* filesystem as the final destination; only after a successful, fully
|
|
28
|
+
* validated extract do we `fs.rename` it into place (atomic). Any failure
|
|
29
|
+
* mid-extract rolls the staging dir back entirely (`rm -rf` in finally) —
|
|
30
|
+
* no partial/half-wired pack is ever left behind.
|
|
31
|
+
* - POST-EXTRACT: an independent realpath containment sweep over everything
|
|
32
|
+
* actually written (we do NOT rely solely on the pre-flight scan or on the
|
|
33
|
+
* tar binary's own protections).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="372e4f76-03b2-5287-ac66-249543f88937")}catch(e){}}();
|
|
37
|
+
import { execFileSync } from 'child_process';
|
|
38
|
+
import * as fs from 'fs';
|
|
39
|
+
import * as path from 'path';
|
|
40
|
+
export const DEFAULT_CAPS = {
|
|
41
|
+
// A content pack is docs/skills/policies/small scripts. 256 MiB is already
|
|
42
|
+
// generous; anything larger is almost certainly a bomb or a mistake.
|
|
43
|
+
maxUncompressedBytes: 256 * 1024 * 1024,
|
|
44
|
+
maxFileCount: 20_000,
|
|
45
|
+
maxSingleFileBytes: 64 * 1024 * 1024,
|
|
46
|
+
};
|
|
47
|
+
export class UnsafeArchiveError extends Error {
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'UnsafeArchiveError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Parse the verbose listing produced by `tar -tv`. We use the verbose form so
|
|
55
|
+
* we get the type flag, size, and (for links) the `name -> target` /
|
|
56
|
+
* `name link to target` suffix without extracting anything.
|
|
57
|
+
*
|
|
58
|
+
* SECURITY-CRITICAL CROSS-PLATFORM NOTE. bsdtar (macOS/BSD) and GNU tar
|
|
59
|
+
* (Linux / prod Lambdas) format the verbose listing DIFFERENTLY, and an
|
|
60
|
+
* implementation that only understands one of them silently parses ZERO
|
|
61
|
+
* entries against the other — which means the pre-flight containment scan
|
|
62
|
+
* sees nothing to reject and every malicious archive sails through. The two
|
|
63
|
+
* shapes we must both handle:
|
|
64
|
+
*
|
|
65
|
+
* bsdtar: <mode> <links> <owner> <group> <size> <Mon> <DD> <HH:MM|YYYY> <name...>
|
|
66
|
+
* -rw-r--r-- 0 user group 1234 Jan 1 00:00 path/file
|
|
67
|
+
* lrwxr-xr-x 0 user group 0 Jan 1 00:00 link -> target
|
|
68
|
+
*
|
|
69
|
+
* GNU: <mode> <owner>/<group> <size> <YYYY-MM-DD> <HH:MM[:SS]> <name...>
|
|
70
|
+
* -rw-r--r-- user/group 1234 2026-01-01 00:00 path/file
|
|
71
|
+
* lrwxrwxrwx user/group 0 2026-01-01 00:00 link -> target
|
|
72
|
+
* hrw-r--r-- user/group 0 2026-01-01 00:00 hard link to target
|
|
73
|
+
*
|
|
74
|
+
* GNU has NO link-count column, joins owner/group with `/`, and renders the
|
|
75
|
+
* date ISO-style (`YYYY-MM-DD HH:MM`) instead of `Mon DD HH:MM`. The previous
|
|
76
|
+
* parser anchored on a `[A-Za-z]{3}` month token, so it matched NOTHING under
|
|
77
|
+
* GNU tar and returned [] — the cross-platform hole this fix closes.
|
|
78
|
+
*
|
|
79
|
+
* Robust strategy: do NOT anchor on the (format-divergent) date column. Anchor
|
|
80
|
+
* on the leading mode string (always present, position 0, stable type char),
|
|
81
|
+
* then take the SIZE as the last integer that appears before the date/time
|
|
82
|
+
* column, accepting BOTH date formats. The leading mode char is the type flag:
|
|
83
|
+
* '-' file, 'd' dir, 'l' symlink, 'h' hardlink (GNU prints 'h'); bsdtar
|
|
84
|
+
* prints hardlinks as files with a "link to" suffix, which we also detect.
|
|
85
|
+
*/
|
|
86
|
+
export function parseTarListing(verbose) {
|
|
87
|
+
const entries = [];
|
|
88
|
+
for (const raw of verbose.split('\n')) {
|
|
89
|
+
const line = raw.replace(/\r$/, '');
|
|
90
|
+
if (line.trim() === '')
|
|
91
|
+
continue;
|
|
92
|
+
const modeChar = line[0];
|
|
93
|
+
// Only accept lines that actually begin with a tar mode string (10 chars:
|
|
94
|
+
// a type flag followed by 9 permission chars, possibly with a trailing
|
|
95
|
+
// ACL/xattr '+'/'@'/'.'). This skips non-entry noise without depending on
|
|
96
|
+
// the date column at all.
|
|
97
|
+
if (!/^[-dlhpscbD][-rwxXsStT]{9}[.+@]?\s/.test(line))
|
|
98
|
+
continue;
|
|
99
|
+
// The size is the last run of digits that sits immediately before the
|
|
100
|
+
// date/time column. We accept BOTH date renderings:
|
|
101
|
+
// bsdtar: `Mon DD HH:MM` / `Mon DD YYYY`
|
|
102
|
+
// GNU: `YYYY-MM-DD HH:MM[:SS]`
|
|
103
|
+
// Anchoring the size on "the integer right before a recognized date" works
|
|
104
|
+
// identically whether or not a link-count column is present (bsdtar) and
|
|
105
|
+
// whether owner/group is one slash-joined token (GNU) or two columns
|
|
106
|
+
// (bsdtar), because we only ever look at the integer adjacent to the date.
|
|
107
|
+
const dateRe = /\s(\d+)\s+(?:[A-Za-z]{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{2}|\d{4})|\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}(?::\d{2})?)\s+/;
|
|
108
|
+
const m = dateRe.exec(line);
|
|
109
|
+
if (!m)
|
|
110
|
+
continue;
|
|
111
|
+
const size = Number.parseInt(m[1], 10);
|
|
112
|
+
const nameAndLink = line.slice(m.index + m[0].length).trim();
|
|
113
|
+
let type;
|
|
114
|
+
let name = nameAndLink;
|
|
115
|
+
let linkTarget;
|
|
116
|
+
if (modeChar === 'd') {
|
|
117
|
+
type = 'dir';
|
|
118
|
+
name = name.replace(/\/+$/, '');
|
|
119
|
+
}
|
|
120
|
+
else if (modeChar === 'l') {
|
|
121
|
+
type = 'symlink';
|
|
122
|
+
const arrow = nameAndLink.indexOf(' -> ');
|
|
123
|
+
if (arrow >= 0) {
|
|
124
|
+
name = nameAndLink.slice(0, arrow);
|
|
125
|
+
linkTarget = nameAndLink.slice(arrow + 4);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (modeChar === 'h') {
|
|
129
|
+
type = 'hardlink';
|
|
130
|
+
// GNU: "name link to target"
|
|
131
|
+
const m = / link to /.exec(nameAndLink);
|
|
132
|
+
if (m) {
|
|
133
|
+
name = nameAndLink.slice(0, m.index);
|
|
134
|
+
linkTarget = nameAndLink.slice(m.index + m[0].length);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else if (modeChar === '-') {
|
|
138
|
+
// bsdtar renders hardlinks as a file entry with a trailing "link to".
|
|
139
|
+
const m = / link to /.exec(nameAndLink);
|
|
140
|
+
if (m) {
|
|
141
|
+
type = 'hardlink';
|
|
142
|
+
name = nameAndLink.slice(0, m.index);
|
|
143
|
+
linkTarget = nameAndLink.slice(m.index + m[0].length);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
type = 'file';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
type = 'other';
|
|
151
|
+
}
|
|
152
|
+
entries.push({
|
|
153
|
+
type,
|
|
154
|
+
name,
|
|
155
|
+
size: Number.isFinite(size) ? size : 0,
|
|
156
|
+
linkTarget,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return entries;
|
|
160
|
+
}
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Containment — normalized prefix on the RESOLVED path (not string matching)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
/**
|
|
165
|
+
* True iff `candidate` is `base` itself or strictly nested under it, judged on
|
|
166
|
+
* normalized absolute paths with a trailing-separator guard so that a sibling
|
|
167
|
+
* like `/a/baseEVIL` is NOT considered "under" `/a/base`.
|
|
168
|
+
*/
|
|
169
|
+
export function isContained(base, candidate) {
|
|
170
|
+
const b = path.resolve(base);
|
|
171
|
+
const c = path.resolve(candidate);
|
|
172
|
+
if (c === b)
|
|
173
|
+
return true;
|
|
174
|
+
const withSep = b.endsWith(path.sep) ? b : b + path.sep;
|
|
175
|
+
return c.startsWith(withSep);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Resolve where an entry NAME would land under `targetDir` and assert it stays
|
|
179
|
+
* inside. Rejects absolute names and any `..` that escapes. Returns the
|
|
180
|
+
* resolved absolute path on success.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveContainedPath(targetDir, entryName) {
|
|
183
|
+
// path.resolve collapses `..` segments and makes absolute names absolute to
|
|
184
|
+
// the FS root (so `/etc/passwd` resolves to itself, which then fails
|
|
185
|
+
// containment) — exactly what we want for the check.
|
|
186
|
+
const resolved = path.resolve(targetDir, entryName);
|
|
187
|
+
if (!isContained(targetDir, resolved)) {
|
|
188
|
+
throw new UnsafeArchiveError(`Path traversal blocked: entry "${entryName}" resolves to "${resolved}", ` +
|
|
189
|
+
`outside extraction dir "${path.resolve(targetDir)}".`);
|
|
190
|
+
}
|
|
191
|
+
return resolved;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Validate a sym/hardlink's target stays inside `packDir`. The link is created
|
|
195
|
+
* AT `entryAbs` (already known-contained), so a relative target is resolved
|
|
196
|
+
* against the link's parent dir; an absolute target is checked as-is. Either
|
|
197
|
+
* way the final location must be contained in the pack dir.
|
|
198
|
+
*/
|
|
199
|
+
export function assertLinkTargetContained(packDir, entryAbs, linkTarget) {
|
|
200
|
+
const resolvedTarget = path.isAbsolute(linkTarget)
|
|
201
|
+
? path.resolve(linkTarget)
|
|
202
|
+
: path.resolve(path.dirname(entryAbs), linkTarget);
|
|
203
|
+
if (!isContained(packDir, resolvedTarget)) {
|
|
204
|
+
throw new UnsafeArchiveError(`Link escape blocked: entry "${path.relative(packDir, entryAbs)}" points to ` +
|
|
205
|
+
`"${linkTarget}" -> "${resolvedTarget}", outside pack dir "${path.resolve(packDir)}".`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Pre-flight: list entries, enforce containment + caps WITHOUT extracting
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
/**
|
|
212
|
+
* List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
|
|
213
|
+
* argv form — never a shell string — so a hostile filename can't break out.
|
|
214
|
+
*/
|
|
215
|
+
export function listTarball(tarballPath) {
|
|
216
|
+
const out = execFileSync('tar', ['-tvf', tarballPath], {
|
|
217
|
+
encoding: 'utf-8',
|
|
218
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
219
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
220
|
+
});
|
|
221
|
+
return parseTarListing(out);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Pre-flight validation against the entry headers. Enforces (a) containment of
|
|
225
|
+
* every entry name under `targetDir`, (b) containment of every link target
|
|
226
|
+
* under `targetDir`, and (c) the three decompression-bomb caps. Throws
|
|
227
|
+
* UnsafeArchiveError on the first violation — default-deny.
|
|
228
|
+
*/
|
|
229
|
+
export function validateEntries(entries, targetDir, caps = DEFAULT_CAPS) {
|
|
230
|
+
// Count real members (dirs included — they still occupy inodes; cheap to
|
|
231
|
+
// count and keeps "many empty dirs" bombs in scope).
|
|
232
|
+
if (entries.length > caps.maxFileCount) {
|
|
233
|
+
throw new UnsafeArchiveError(`Decompression-bomb guard: archive has ${entries.length} entries, ` +
|
|
234
|
+
`exceeding the limit of ${caps.maxFileCount}.`);
|
|
235
|
+
}
|
|
236
|
+
let total = 0;
|
|
237
|
+
for (const e of entries) {
|
|
238
|
+
if (e.type === 'other') {
|
|
239
|
+
throw new UnsafeArchiveError(`Unsupported entry type for "${e.name}" — only files, dirs, and ` +
|
|
240
|
+
`contained links are allowed.`);
|
|
241
|
+
}
|
|
242
|
+
// (a) name containment — rejects `..` traversal and absolute paths.
|
|
243
|
+
const entryAbs = resolveContainedPath(targetDir, e.name);
|
|
244
|
+
// (b) link-target containment.
|
|
245
|
+
if ((e.type === 'symlink' || e.type === 'hardlink') && e.linkTarget) {
|
|
246
|
+
assertLinkTargetContained(targetDir, entryAbs, e.linkTarget);
|
|
247
|
+
}
|
|
248
|
+
// (c) per-file + cumulative caps.
|
|
249
|
+
if (e.size > caps.maxSingleFileBytes) {
|
|
250
|
+
throw new UnsafeArchiveError(`Decompression-bomb guard: entry "${e.name}" is ${e.size} bytes, ` +
|
|
251
|
+
`exceeding the per-file limit of ${caps.maxSingleFileBytes}.`);
|
|
252
|
+
}
|
|
253
|
+
total += e.size;
|
|
254
|
+
if (total > caps.maxUncompressedBytes) {
|
|
255
|
+
throw new UnsafeArchiveError(`Decompression-bomb guard: cumulative uncompressed size exceeded ` +
|
|
256
|
+
`${caps.maxUncompressedBytes} bytes (reached ${total} before finishing the scan).`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Post-extract: independent realpath containment sweep
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
/**
|
|
264
|
+
* Walk everything actually written under `stagingDir` and assert that no entry
|
|
265
|
+
* — and, for symlinks, no link target — escapes. This is the defense-in-depth
|
|
266
|
+
* backstop: it does not trust the pre-flight scan OR the tar binary. Uses
|
|
267
|
+
* realpath on the link's PARENT (which exists) plus lstat to inspect links
|
|
268
|
+
* without following them off-tree.
|
|
269
|
+
*/
|
|
270
|
+
export function assertExtractContained(stagingDir) {
|
|
271
|
+
const realRoot = fs.realpathSync(stagingDir);
|
|
272
|
+
const walk = (dir) => {
|
|
273
|
+
for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
274
|
+
const abs = path.join(dir, dirent.name);
|
|
275
|
+
if (dirent.isSymbolicLink()) {
|
|
276
|
+
const target = fs.readlinkSync(abs);
|
|
277
|
+
const resolved = path.isAbsolute(target)
|
|
278
|
+
? path.resolve(target)
|
|
279
|
+
: path.resolve(path.dirname(abs), target);
|
|
280
|
+
if (!isContained(realRoot, resolved)) {
|
|
281
|
+
throw new UnsafeArchiveError(`Post-extract link escape: "${path.relative(realRoot, abs)}" -> "${target}".`);
|
|
282
|
+
}
|
|
283
|
+
continue; // do not descend through links
|
|
284
|
+
}
|
|
285
|
+
// Real path of the entry's parent must stay inside the root (catches a
|
|
286
|
+
// dir that is itself a link off-tree, belt-and-suspenders).
|
|
287
|
+
const realParent = fs.realpathSync(path.dirname(abs));
|
|
288
|
+
if (!isContained(realRoot, path.join(realParent, dirent.name))) {
|
|
289
|
+
throw new UnsafeArchiveError(`Post-extract path escape: "${abs}" resolves outside "${realRoot}".`);
|
|
290
|
+
}
|
|
291
|
+
if (dirent.isDirectory())
|
|
292
|
+
walk(abs);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
walk(realRoot);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Safely extract `tarballPath` so its contents land atomically at `finalDir`.
|
|
299
|
+
*
|
|
300
|
+
* Sequence:
|
|
301
|
+
* 1. Pre-flight list + validate (containment + caps) — abort before writing.
|
|
302
|
+
* 2. Extract into a sibling `*.staging-<rand>` dir on the SAME filesystem.
|
|
303
|
+
* 3. Post-extract realpath containment sweep (independent backstop).
|
|
304
|
+
* 4. `afterExtractHook` (test failure-injection seam).
|
|
305
|
+
* 5. Atomic `fs.rename(staging -> finalDir)`.
|
|
306
|
+
* 6. finally: `rm -rf` the staging dir if it still exists (rollback).
|
|
307
|
+
*
|
|
308
|
+
* On ANY failure the staging dir is removed and `finalDir` is never created,
|
|
309
|
+
* so a half-extracted pack can never be wired.
|
|
310
|
+
*/
|
|
311
|
+
export function safeExtractTarball(tarballPath, finalDir, opts = {}) {
|
|
312
|
+
const caps = opts.caps ?? DEFAULT_CAPS;
|
|
313
|
+
if (fs.existsSync(finalDir)) {
|
|
314
|
+
throw new Error(`safeExtractTarball: refusing to extract over existing dir "${finalDir}".`);
|
|
315
|
+
}
|
|
316
|
+
// 1. Pre-flight against headers (no bytes written yet).
|
|
317
|
+
const entries = listTarball(tarballPath);
|
|
318
|
+
validateEntries(entries, finalDir, caps);
|
|
319
|
+
// 2. Sibling staging dir on the same filesystem so the rename is atomic.
|
|
320
|
+
const parent = path.dirname(path.resolve(finalDir));
|
|
321
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
322
|
+
const stagingDir = fs.mkdtempSync(path.join(parent, `.${path.basename(finalDir)}.staging-`));
|
|
323
|
+
try {
|
|
324
|
+
// `--no-same-owner` avoids chown surprises when run as root; argv form, no
|
|
325
|
+
// shell. We deliberately re-extract here (not the pre-flight listing) and
|
|
326
|
+
// re-validate after, rather than trusting tar to honor any single flag.
|
|
327
|
+
execFileSync('tar', ['-xzf', tarballPath, '-C', stagingDir, '--no-same-owner'], { stdio: 'inherit' });
|
|
328
|
+
// 3. Independent backstop sweep over what actually landed.
|
|
329
|
+
assertExtractContained(stagingDir);
|
|
330
|
+
// 4. Failure-injection seam (tests).
|
|
331
|
+
opts.afterExtractHook?.(stagingDir);
|
|
332
|
+
// 5. Atomic commit. `fs.renameSync` is atomic within a filesystem; the
|
|
333
|
+
// staged contents are fully present and validated before this point, so
|
|
334
|
+
// the live location only ever sees a complete pack — never a partial.
|
|
335
|
+
fs.renameSync(stagingDir, finalDir);
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
// Rollback: if anything before the rename threw, the staging dir still
|
|
339
|
+
// exists and is removed here — no partial/half-wired pack survives. After a
|
|
340
|
+
// successful rename the staging dir is gone, so this is a no-op.
|
|
341
|
+
if (fs.existsSync(stagingDir)) {
|
|
342
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
//# sourceMappingURL=safe-extract.js.map
|
|
347
|
+
//# debugId=372e4f76-03b2-5287-ac66-249543f88937
|