@indigoai-us/hq-cli 5.32.0 → 5.33.1

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.
@@ -1,5 +1,5 @@
1
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]="e1ca2b83-1de0-5000-ab66-f573000a579b")}catch(e){}}();
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]="bdd04f0a-8d43-5072-916d-a801a2f25731")}catch(e){}}();
3
3
  import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
4
4
  import { Sentry } from '../sentry.js';
5
5
  export async function vaultApiFetch(opts) {
@@ -35,6 +35,43 @@ export async function vaultApiFetch(opts) {
35
35
  }
36
36
  return response;
37
37
  }
38
+ /**
39
+ * Public (NONE-auth) GET against the vault API — no bearer token. The
40
+ * marketplace browse endpoints (`GET /v1/listings`, `GET /v1/listings/{id}`)
41
+ * from US-005 are public so a logged-out user can resolve + download an
42
+ * approved pack. Distinct from `vaultApiFetch`, which always attaches a
43
+ * bearer token.
44
+ */
45
+ export async function vaultApiFetchPublic(opts) {
46
+ const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
47
+ if (opts.query) {
48
+ for (const [k, v] of Object.entries(opts.query)) {
49
+ url.searchParams.set(k, v);
50
+ }
51
+ }
52
+ const safeUrl = url.search
53
+ ? `${url.origin}${url.pathname}?<redacted>`
54
+ : `${url.origin}${url.pathname}`;
55
+ Sentry.addBreadcrumb({
56
+ category: 'http',
57
+ message: `GET ${opts.path}`,
58
+ level: 'info',
59
+ data: { url: safeUrl, method: 'GET' },
60
+ });
61
+ const response = await fetch(url.toString(), {
62
+ method: 'GET',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ });
65
+ if (!response.ok) {
66
+ Sentry.addBreadcrumb({
67
+ category: 'http',
68
+ message: `GET ${opts.path} → ${response.status}`,
69
+ level: 'warning',
70
+ data: { url: safeUrl, status: response.status },
71
+ });
72
+ }
73
+ return response;
74
+ }
38
75
  async function resolveCompanyUid(token, slug) {
39
76
  const res = await vaultApiFetch({
40
77
  token,
@@ -106,4 +143,4 @@ export async function getEntityUid(token, opts) {
106
143
  return getCompanyUid(token, opts.companySlug);
107
144
  }
108
145
  //# sourceMappingURL=vault-api.js.map
109
- //# debugId=e1ca2b83-1de0-5000-ab66-f573000a579b
146
+ //# debugId=bdd04f0a-8d43-5072-916d-a801a2f25731
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.32.0",
3
+ "version": "5.33.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "clean": "rm -rf dist"
16
16
  },
17
17
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "~5.44.0",
18
+ "@indigoai-us/hq-cloud": "~5.47.0",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Deterministic, platform-independent malicious-tar fixture builder.
3
+ *
4
+ * WHY THIS EXISTS (cross-platform zip-slip coverage). The adversarial
5
+ * safe-extract / marketplace-security suites need archives that GENUINELY carry
6
+ * hostile entries — a `../` traversal name, an absolute path, an escaping
7
+ * symlink/hardlink. The obvious approach (shell out to the system `tar` to
8
+ * CREATE the archive) is NOT portable: GNU tar (Linux / CI / prod Lambdas)
9
+ * STRIPS the leading `../` and absolute `/` when it WRITES an archive, so the
10
+ * resulting "malicious" fixture contains a benign `evil-escape` entry and the
11
+ * guard has nothing to reject — the test passes on macOS bsdtar (which
12
+ * preserves them) but fails on Linux. The guard itself is correct; only the
13
+ * fixture construction was platform-dependent.
14
+ *
15
+ * Fix: WRITE THE TAR BYTES DIRECTLY here. A tar archive is just a sequence of
16
+ * 512-byte ustar headers, each optionally followed by the file's content padded
17
+ * to a 512-byte boundary, terminated by two zero blocks. By emitting the bytes
18
+ * ourselves we control the recorded entry names verbatim — `../evil-escape`,
19
+ * `/tmp/evil`, a symlink with linkname `../../etc/passwd` — identically on every
20
+ * platform, with no create-time stripping. The reading side (`tar -tvf`, which
21
+ * safeExtractTarball's pre-flight uses) does NOT strip anything; it faithfully
22
+ * lists whatever bytes we wrote, so the guard sees the real attack everywhere.
23
+ */
24
+
25
+ import { gzipSync } from 'node:zlib';
26
+ import * as fs from 'node:fs';
27
+
28
+ const BLOCK = 512;
29
+
30
+ /** ustar typeflag values we use. */
31
+ export type TarType = 'file' | 'symlink' | 'hardlink';
32
+
33
+ const TYPEFLAG: Record<TarType, string> = {
34
+ file: '0', // regular file
35
+ symlink: '2', // symbolic link
36
+ hardlink: '1', // hard link
37
+ };
38
+
39
+ export interface TarEntrySpec {
40
+ /** Recorded entry name — written VERBATIM (may contain `..`, be absolute…). */
41
+ name: string;
42
+ type?: TarType; // default 'file'
43
+ /** For sym/hardlinks: the recorded link target (also written verbatim). */
44
+ linkname?: string;
45
+ /** File content (regular files only). */
46
+ content?: string | Buffer;
47
+ }
48
+
49
+ /** Write an ASCII string into a fixed-width field at `offset`, NUL-padded. */
50
+ function writeField(buf: Buffer, offset: number, width: number, value: string): void {
51
+ // Truncate defensively; ustar fields are fixed-width.
52
+ const s = value.slice(0, width);
53
+ buf.write(s, offset, 'ascii');
54
+ // Remaining bytes are already 0 from Buffer.alloc.
55
+ }
56
+
57
+ /** Write an octal numeric field: `width-1` octal digits, space-or-NUL terminated. */
58
+ function writeOctal(buf: Buffer, offset: number, width: number, value: number): void {
59
+ // Classic ustar numeric field: zero-padded octal in (width-1) chars + NUL.
60
+ const digits = width - 1;
61
+ const oct = value.toString(8).padStart(digits, '0').slice(-digits);
62
+ buf.write(oct, offset, 'ascii');
63
+ buf[offset + digits] = 0; // NUL terminator
64
+ }
65
+
66
+ /**
67
+ * Build one 512-byte ustar header (plus content blocks for regular files) for a
68
+ * single entry. The header checksum is computed exactly per spec: sum every
69
+ * header byte treating the 8 checksum bytes themselves as ASCII spaces, then
70
+ * write that sum as 6 octal digits + NUL + space.
71
+ */
72
+ export function makeTarEntry(spec: TarEntrySpec): Buffer {
73
+ const type = spec.type ?? 'file';
74
+ const content =
75
+ type === 'file'
76
+ ? Buffer.isBuffer(spec.content)
77
+ ? spec.content
78
+ : Buffer.from(spec.content ?? '', 'utf-8')
79
+ : Buffer.alloc(0); // links carry no content payload
80
+
81
+ const header = Buffer.alloc(BLOCK); // zero-filled
82
+
83
+ writeField(header, 0, 100, spec.name); // name
84
+ writeOctal(header, 100, 8, 0o644); // mode
85
+ writeOctal(header, 108, 8, 0); // uid
86
+ writeOctal(header, 116, 8, 0); // gid
87
+ writeOctal(header, 124, 12, content.length); // size (0 for links)
88
+ writeOctal(header, 136, 12, 0); // mtime (deterministic: epoch)
89
+ // checksum field (148, 8) — filled below; start as spaces for the sum.
90
+ header.fill(' '.charCodeAt(0), 148, 156);
91
+ writeField(header, 156, 1, TYPEFLAG[type]); // typeflag
92
+ if (spec.linkname !== undefined) {
93
+ writeField(header, 157, 100, spec.linkname); // linkname (verbatim)
94
+ }
95
+ writeField(header, 257, 6, 'ustar'); // magic "ustar\0"
96
+ writeField(header, 263, 2, '00'); // version "00"
97
+ // uname/gname left empty; devmajor/devminor zero (already NUL).
98
+
99
+ // Header checksum: unsigned sum of all 512 bytes (with the checksum field as
100
+ // spaces, which we set above). Written as 6 octal digits, NUL, space.
101
+ let sum = 0;
102
+ for (let i = 0; i < BLOCK; i++) sum += header[i];
103
+ const cksum = sum.toString(8).padStart(6, '0').slice(-6);
104
+ header.write(cksum, 148, 'ascii');
105
+ header[154] = 0; // NUL
106
+ header[155] = ' '.charCodeAt(0); // space
107
+
108
+ if (type !== 'file' || content.length === 0) return header;
109
+
110
+ // Content padded up to a 512-byte boundary.
111
+ const pad = (BLOCK - (content.length % BLOCK)) % BLOCK;
112
+ return Buffer.concat([header, content, Buffer.alloc(pad)]);
113
+ }
114
+
115
+ /** Concatenate entries and append the two zero blocks that end every tar. */
116
+ export function makeTar(entries: TarEntrySpec[]): Buffer {
117
+ const blocks = entries.map(makeTarEntry);
118
+ blocks.push(Buffer.alloc(BLOCK * 2)); // end-of-archive marker
119
+ return Buffer.concat(blocks);
120
+ }
121
+
122
+ /** Build the tar, gzip it, and write it to `outPath`. Returns `outPath`. */
123
+ export function writeMaliciousTarGz(outPath: string, entries: TarEntrySpec[]): string {
124
+ fs.writeFileSync(outPath, gzipSync(makeTar(entries)));
125
+ return outPath;
126
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Marketplace artifact verification (US-021, INSTALL side) tests.
3
+ *
4
+ * Proves the install-time guarantee: the bytes we install are EXACTLY the bytes
5
+ * a moderator approved. Covers the two E2E behaviors required by the story:
6
+ * - an approved artifact whose S3 object is mutated → hash mismatch → REFUSE.
7
+ * - signature verification against the platform Ed25519 PUBLIC key.
8
+ *
9
+ * SHARED CONTRACT mirror — we reproduce the publish-side signing here with an
10
+ * ephemeral in-test keypair (no key material in the repo) to prove both sides
11
+ * agree byte-for-byte.
12
+ */
13
+
14
+ import { describe, it, expect } from 'vitest';
15
+ import {
16
+ generateKeyPairSync,
17
+ createHash,
18
+ sign as cryptoSign,
19
+ } from 'node:crypto';
20
+ import {
21
+ verifyArtifact,
22
+ computeArtifactHash,
23
+ ArtifactVerificationError,
24
+ ARTIFACT_HASH_ALG,
25
+ } from './pack-install.js';
26
+
27
+ // Ephemeral platform keypair (regenerated each run — never committed).
28
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
29
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
30
+
31
+ /** Reproduce the hq-pro publish-side: sha256 hex hash + Ed25519 over the hash. */
32
+ function publishSide(bytes: Buffer): { contentHash: string; signature: string } {
33
+ const contentHash = createHash('sha256').update(bytes).digest('hex');
34
+ const signature = cryptoSign(
35
+ null,
36
+ Buffer.from(contentHash, 'utf-8'),
37
+ privateKey,
38
+ ).toString('base64');
39
+ return { contentHash, signature };
40
+ }
41
+
42
+ const APPROVED_BYTES = Buffer.from('the-approved-pack.tar.gz-bytes');
43
+
44
+ describe('computeArtifactHash', () => {
45
+ it('matches a lowercase-hex sha256 (shared contract with publish)', () => {
46
+ const expected = createHash('sha256').update(APPROVED_BYTES).digest('hex');
47
+ expect(computeArtifactHash(APPROVED_BYTES)).toBe(expected);
48
+ expect(ARTIFACT_HASH_ALG).toBe('sha256');
49
+ });
50
+ });
51
+
52
+ describe('verifyArtifact — integrity (hash)', () => {
53
+ it('passes when the downloaded bytes match the pinned hash + valid signature', () => {
54
+ const { contentHash, signature } = publishSide(APPROVED_BYTES);
55
+ expect(() =>
56
+ verifyArtifact({
57
+ tarballBytes: APPROVED_BYTES,
58
+ expectedHash: contentHash,
59
+ signature,
60
+ publicKey: publicKeyPem,
61
+ requireSignature: true,
62
+ }),
63
+ ).not.toThrow();
64
+ });
65
+
66
+ it('E2E: a MUTATED S3 object → hash mismatch → REFUSES', () => {
67
+ // Publish/approval pinned the hash of APPROVED_BYTES…
68
+ const { contentHash, signature } = publishSide(APPROVED_BYTES);
69
+ // …but the bytes we actually downloaded were swapped by an attacker.
70
+ const mutatedBytes = Buffer.from('EVIL-swapped-pack.tar.gz-bytes');
71
+
72
+ expect(() =>
73
+ verifyArtifact({
74
+ tarballBytes: mutatedBytes,
75
+ expectedHash: contentHash,
76
+ signature,
77
+ publicKey: publicKeyPem,
78
+ }),
79
+ ).toThrow(ArtifactVerificationError);
80
+
81
+ try {
82
+ verifyArtifact({
83
+ tarballBytes: mutatedBytes,
84
+ expectedHash: contentHash,
85
+ });
86
+ } catch (e) {
87
+ expect((e as Error).message).toMatch(/hash mismatch/i);
88
+ }
89
+ });
90
+
91
+ it('refuses when the listing provides no valid hash', () => {
92
+ expect(() =>
93
+ verifyArtifact({ tarballBytes: APPROVED_BYTES, expectedHash: '' }),
94
+ ).toThrow(/valid sha256 content hash/i);
95
+ expect(() =>
96
+ verifyArtifact({ tarballBytes: APPROVED_BYTES, expectedHash: 'not-a-hash' }),
97
+ ).toThrow(ArtifactVerificationError);
98
+ });
99
+ });
100
+
101
+ describe('verifyArtifact — authenticity (signature)', () => {
102
+ it('refuses a hash-valid artifact whose signature was forged by a different key', () => {
103
+ const { contentHash } = publishSide(APPROVED_BYTES);
104
+ // Attacker signs the (correct) hash with their OWN key.
105
+ const { privateKey: evilKey } = generateKeyPairSync('ed25519');
106
+ const forged = cryptoSign(
107
+ null,
108
+ Buffer.from(contentHash, 'utf-8'),
109
+ evilKey,
110
+ ).toString('base64');
111
+
112
+ expect(() =>
113
+ verifyArtifact({
114
+ tarballBytes: APPROVED_BYTES,
115
+ expectedHash: contentHash,
116
+ signature: forged,
117
+ publicKey: publicKeyPem, // platform key — won't validate the forgery
118
+ }),
119
+ ).toThrow(/signature is invalid/i);
120
+ });
121
+
122
+ it('refuses on a corrupt/garbage signature', () => {
123
+ const { contentHash } = publishSide(APPROVED_BYTES);
124
+ expect(() =>
125
+ verifyArtifact({
126
+ tarballBytes: APPROVED_BYTES,
127
+ expectedHash: contentHash,
128
+ signature: 'not-base64-or-a-real-sig!!!',
129
+ publicKey: publicKeyPem,
130
+ }),
131
+ ).toThrow(ArtifactVerificationError);
132
+ });
133
+
134
+ it('refuses on an invalid public key', () => {
135
+ const { contentHash, signature } = publishSide(APPROVED_BYTES);
136
+ expect(() =>
137
+ verifyArtifact({
138
+ tarballBytes: APPROVED_BYTES,
139
+ expectedHash: contentHash,
140
+ signature,
141
+ publicKey: 'not a real pem',
142
+ }),
143
+ ).toThrow(/public key/i);
144
+ });
145
+ });
146
+
147
+ describe('verifyArtifact — deferred-key window', () => {
148
+ it('hash-verifies but skips signature when none provided (requireSignature=false)', () => {
149
+ const { contentHash } = publishSide(APPROVED_BYTES);
150
+ expect(() =>
151
+ verifyArtifact({ tarballBytes: APPROVED_BYTES, expectedHash: contentHash }),
152
+ ).not.toThrow();
153
+ });
154
+
155
+ it('refuses an unsigned artifact when requireSignature=true', () => {
156
+ const { contentHash } = publishSide(APPROVED_BYTES);
157
+ expect(() =>
158
+ verifyArtifact({
159
+ tarballBytes: APPROVED_BYTES,
160
+ expectedHash: contentHash,
161
+ requireSignature: true,
162
+ }),
163
+ ).toThrow(/unsigned/i);
164
+ });
165
+
166
+ it('refuses when a signature is present but no public key is available (requireSignature=true)', () => {
167
+ const { contentHash, signature } = publishSide(APPROVED_BYTES);
168
+ expect(() =>
169
+ verifyArtifact({
170
+ tarballBytes: APPROVED_BYTES,
171
+ expectedHash: contentHash,
172
+ signature,
173
+ requireSignature: true,
174
+ }),
175
+ ).toThrow(/public key/i);
176
+ });
177
+ });