@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
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* HQ CLI - Module management, package management, and cloud sync for HQ
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
!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]="
|
|
6
|
+
!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]="004dc716-c660-5745-94db-988ac49d102e")}catch(e){}}();
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { initSentry, Sentry } from "./sentry.js";
|
|
9
9
|
import { registerAddCommand } from "./commands/add.js";
|
|
@@ -24,6 +24,7 @@ import { registerPackageRemoveCommand } from "./commands/pkg-remove.js";
|
|
|
24
24
|
import { registerPackageUpdateCommand } from "./commands/pkg-update.js";
|
|
25
25
|
import { registerPackageListCommand } from "./commands/pkg-list.js";
|
|
26
26
|
import { registerPacksCommand } from "./commands/packs.js";
|
|
27
|
+
import { registerPublishCommand } from "./commands/publish.js";
|
|
27
28
|
import { registerTeamSyncCommand } from "./commands/team-sync.js";
|
|
28
29
|
import { registerAuthCommands } from "./commands/auth.js";
|
|
29
30
|
import { registerSecretsCommand } from "./commands/secrets.js";
|
|
@@ -38,6 +39,8 @@ import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
|
38
39
|
import { registerMeetingsCommand } from "./commands/meetings.js";
|
|
39
40
|
import { registerSourcesCommand } from "./commands/sources.js";
|
|
40
41
|
import { registerSignalsCommand } from "./commands/signals.js";
|
|
42
|
+
import { registerMasterSyncCommand } from "./commands/master-sync.js";
|
|
43
|
+
import { registerRescueCommand } from "./commands/rescue.js";
|
|
41
44
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
42
45
|
import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
|
|
43
46
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
@@ -84,6 +87,10 @@ registerPacksCommand(program);
|
|
|
84
87
|
// "hq remove <slug>" = "hq packages remove <slug>"
|
|
85
88
|
registerPackageInstallCommand(program);
|
|
86
89
|
registerPackageRemoveCommand(program);
|
|
90
|
+
// Marketplace publish (top-level — packer + authenticated upload, US-004)
|
|
91
|
+
// "hq publish <skill-or-worker-path>" packages and submits a pack to the
|
|
92
|
+
// marketplace via POST /v1/listings.
|
|
93
|
+
registerPublishCommand(program);
|
|
87
94
|
// Cloud sync subcommand group
|
|
88
95
|
const syncCmd = program
|
|
89
96
|
.command("sync")
|
|
@@ -132,6 +139,14 @@ registerMeetingsCommand(program);
|
|
|
132
139
|
registerSourcesCommand(program);
|
|
133
140
|
// Signals read surface (subcommand group — hq signals list|get|types|entities)
|
|
134
141
|
registerSignalsCommand(program);
|
|
142
|
+
// Skill/personal-overlay mirroring + workers-registry regen. Internal command
|
|
143
|
+
// invoked by the hq-core master-sync hook shim (formerly the master-sync.sh
|
|
144
|
+
// hook body). Implementation lives in @indigoai-us/hq-cloud.
|
|
145
|
+
registerMasterSyncCommand(program);
|
|
146
|
+
// Drift-preserving HQ-core re-sync (top-level — `hq rescue`). CLI sibling of
|
|
147
|
+
// the HQ Sync app's "Update / Restore" pill; drives the same replace-rescue.sh
|
|
148
|
+
// shipped from @indigoai-us/hq-cloud.
|
|
149
|
+
registerRescueCommand(program);
|
|
135
150
|
(async () => {
|
|
136
151
|
try {
|
|
137
152
|
Sentry.addBreadcrumb({
|
|
@@ -158,4 +173,4 @@ registerSignalsCommand(program);
|
|
|
158
173
|
}
|
|
159
174
|
})();
|
|
160
175
|
//# sourceMappingURL=index.js.map
|
|
161
|
-
//# debugId=
|
|
176
|
+
//# debugId=004dc716-c660-5745-94db-988ac49d102e
|
|
@@ -89,6 +89,27 @@ export interface BuildNarrowPlanInput {
|
|
|
89
89
|
* files yet.
|
|
90
90
|
*/
|
|
91
91
|
export declare function buildNarrowPlan(input: BuildNarrowPlanInput): NarrowPlan;
|
|
92
|
+
/**
|
|
93
|
+
* Recursive readdir walk yielding regular files (and dangling-target
|
|
94
|
+
* symlinks — those are recorded as files for narrow-plan purposes; the
|
|
95
|
+
* delete path uses `fs.unlinkSync` which handles symlinks correctly).
|
|
96
|
+
*
|
|
97
|
+
* Uses `lstat` rather than `stat` so a symlink's size doesn't follow the
|
|
98
|
+
* target chain (matches the share-engine convention).
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* Normalize OS-native path separators to POSIX "/" for vault keys.
|
|
102
|
+
*
|
|
103
|
+
* `path.relative()` yields "\\"-separated paths on Windows, but vault S3 keys
|
|
104
|
+
* are always "/"-separated — the server splits keys on "/" to rebuild the tree
|
|
105
|
+
* and the journal + grants endpoint use that same namespace. Emit POSIX keys
|
|
106
|
+
* regardless of client OS so a Windows narrow-plan lines up with the
|
|
107
|
+
* forward-slash keys everywhere else. Mirrors the same normalization on
|
|
108
|
+
* @indigoai-us/hq-cloud's upload path. Converting "\\" explicitly (rather than
|
|
109
|
+
* only path.sep) keeps the result correct — and the regression test meaningful
|
|
110
|
+
* — on a POSIX CI too.
|
|
111
|
+
*/
|
|
112
|
+
export declare function toPosixKey(p: string): string;
|
|
92
113
|
/**
|
|
93
114
|
* Render the dry-run summary as plain text (no chalk — keep it pure). The
|
|
94
115
|
* CLI wrapper can colorize lines afterwards if desired.
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* for the destructive side effects (delete, tombstone, PUT sync-config).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
!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]="
|
|
27
|
+
!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]="8f0265e5-8320-54bb-b2c9-b03ee9581af8")}catch(e){}}();
|
|
28
28
|
import * as fs from "node:fs";
|
|
29
29
|
import * as path from "node:path";
|
|
30
30
|
import { hashFile, isCoveredByAny, } from "@indigoai-us/hq-cloud";
|
|
@@ -109,6 +109,21 @@ function emptyPlan() {
|
|
|
109
109
|
* Uses `lstat` rather than `stat` so a symlink's size doesn't follow the
|
|
110
110
|
* target chain (matches the share-engine convention).
|
|
111
111
|
*/
|
|
112
|
+
/**
|
|
113
|
+
* Normalize OS-native path separators to POSIX "/" for vault keys.
|
|
114
|
+
*
|
|
115
|
+
* `path.relative()` yields "\\"-separated paths on Windows, but vault S3 keys
|
|
116
|
+
* are always "/"-separated — the server splits keys on "/" to rebuild the tree
|
|
117
|
+
* and the journal + grants endpoint use that same namespace. Emit POSIX keys
|
|
118
|
+
* regardless of client OS so a Windows narrow-plan lines up with the
|
|
119
|
+
* forward-slash keys everywhere else. Mirrors the same normalization on
|
|
120
|
+
* @indigoai-us/hq-cloud's upload path. Converting "\\" explicitly (rather than
|
|
121
|
+
* only path.sep) keeps the result correct — and the regression test meaningful
|
|
122
|
+
* — on a POSIX CI too.
|
|
123
|
+
*/
|
|
124
|
+
export function toPosixKey(p) {
|
|
125
|
+
return p.split("\\").join("/");
|
|
126
|
+
}
|
|
112
127
|
function walkLocal(dir,
|
|
113
128
|
// Rel-root for `relPath` — the company walk root (`<hqRoot>/companies/<slug>`),
|
|
114
129
|
// so emitted `relPath`s are company-relative. Fixed across recursion.
|
|
@@ -127,7 +142,7 @@ relRoot, emit) {
|
|
|
127
142
|
}
|
|
128
143
|
for (const entry of entries) {
|
|
129
144
|
const absPath = path.join(dir, entry.name);
|
|
130
|
-
const relPath = path.relative(relRoot, absPath);
|
|
145
|
+
const relPath = toPosixKey(path.relative(relRoot, absPath));
|
|
131
146
|
if (entry.isSymbolicLink()) {
|
|
132
147
|
// Record the link as a file-like entry. Don't descend — narrow is
|
|
133
148
|
// about pruning files that the LOCAL tree has materialized here; a
|
|
@@ -253,4 +268,4 @@ export function formatBytes(n) {
|
|
|
253
268
|
return `${v.toFixed(2)} ${units[i]}`;
|
|
254
269
|
}
|
|
255
270
|
//# sourceMappingURL=local-tree-diff.js.map
|
|
256
|
-
//# debugId=
|
|
271
|
+
//# debugId=8f0265e5-8320-54bb-b2c9-b03ee9581af8
|
package/dist/types.d.ts
CHANGED
|
@@ -57,6 +57,17 @@ export interface SyncResult {
|
|
|
57
57
|
filesChanged?: number;
|
|
58
58
|
}
|
|
59
59
|
export type PackContributeKey = 'workers' | 'knowledge' | 'skills' | 'commands' | 'hooks' | 'policies' | 'scripts';
|
|
60
|
+
/**
|
|
61
|
+
* Pack authorship attribution (US-001). OPTIONAL and backwards-compatible —
|
|
62
|
+
* packs published before this field still validate. When present, install can
|
|
63
|
+
* attribute the pack to a creator: `uid` is the HQ person UID, `handle` the
|
|
64
|
+
* creator's marketplace handle, `displayName` the human-readable name.
|
|
65
|
+
*/
|
|
66
|
+
export interface PackAuthor {
|
|
67
|
+
uid: string;
|
|
68
|
+
handle: string;
|
|
69
|
+
displayName: string;
|
|
70
|
+
}
|
|
60
71
|
export interface PackManifest {
|
|
61
72
|
name: string;
|
|
62
73
|
version: string;
|
|
@@ -71,5 +82,16 @@ export interface PackManifest {
|
|
|
71
82
|
repository?: string;
|
|
72
83
|
keywords?: string[];
|
|
73
84
|
conditional?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Pack authorship attribution (US-001). Optional — absent on legacy packs.
|
|
87
|
+
*/
|
|
88
|
+
author?: PackAuthor;
|
|
89
|
+
/**
|
|
90
|
+
* Declared capabilities the pack touches (US-001), e.g. hooks/scripts/
|
|
91
|
+
* network/fs/secrets. Optional, free-form string entries — surfaced to the
|
|
92
|
+
* user at install time for an at-a-glance trust signal. Reserved here; not
|
|
93
|
+
* yet enforced.
|
|
94
|
+
*/
|
|
95
|
+
capabilities?: string[];
|
|
74
96
|
}
|
|
75
97
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -6,6 +6,17 @@ export interface VaultApiOptions {
|
|
|
6
6
|
query?: Record<string, string>;
|
|
7
7
|
}
|
|
8
8
|
export declare function vaultApiFetch(opts: VaultApiOptions): Promise<Response>;
|
|
9
|
+
/**
|
|
10
|
+
* Public (NONE-auth) GET against the vault API — no bearer token. The
|
|
11
|
+
* marketplace browse endpoints (`GET /v1/listings`, `GET /v1/listings/{id}`)
|
|
12
|
+
* from US-005 are public so a logged-out user can resolve + download an
|
|
13
|
+
* approved pack. Distinct from `vaultApiFetch`, which always attaches a
|
|
14
|
+
* bearer token.
|
|
15
|
+
*/
|
|
16
|
+
export declare function vaultApiFetchPublic(opts: {
|
|
17
|
+
path: string;
|
|
18
|
+
query?: Record<string, string>;
|
|
19
|
+
}): Promise<Response>;
|
|
9
20
|
export declare function getCompanyUid(token: string, companySlug: string | undefined): Promise<string>;
|
|
10
21
|
export declare function resolveCallerPersonUid(token: string): Promise<string>;
|
|
11
22
|
export declare function getEntityUid(token: string, opts: {
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -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]="
|
|
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=
|
|
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.
|
|
3
|
+
"version": "5.34.0",
|
|
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.
|
|
18
|
+
"@indigoai-us/hq-cloud": "~5.48.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
|
+
});
|