@indigoai-us/hq-cli 5.5.2 → 5.5.4

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.
Files changed (123) hide show
  1. package/dist/bin/hq-auth-refresh.js +26 -13
  2. package/dist/commands/_patterns.d.ts +3 -0
  3. package/dist/commands/_patterns.js +6 -0
  4. package/dist/commands/add.js +4 -1
  5. package/dist/commands/auth.js +4 -1
  6. package/dist/commands/cloud.js +4 -1
  7. package/dist/commands/groups.d.ts +3 -0
  8. package/dist/commands/groups.js +344 -0
  9. package/dist/commands/list.js +4 -1
  10. package/dist/commands/login.js +4 -1
  11. package/dist/commands/logout.js +4 -1
  12. package/dist/commands/onboard.js +4 -1
  13. package/dist/commands/pack-install.js +4 -1
  14. package/dist/commands/pkg-install.js +4 -1
  15. package/dist/commands/pkg-list.js +4 -1
  16. package/dist/commands/pkg-remove.js +4 -1
  17. package/dist/commands/pkg-update.js +4 -1
  18. package/dist/commands/secrets.d.ts +9 -0
  19. package/dist/commands/secrets.js +263 -25
  20. package/dist/commands/sync.js +4 -1
  21. package/dist/commands/team-sync.js +4 -1
  22. package/dist/commands/update.js +4 -1
  23. package/dist/commands/whoami.js +4 -1
  24. package/dist/index.js +21 -2
  25. package/dist/sentry-before-send.d.ts +3 -0
  26. package/dist/sentry-before-send.js +219 -0
  27. package/dist/sentry-dsn.generated.d.ts +2 -0
  28. package/dist/sentry-dsn.generated.js +5 -0
  29. package/dist/sentry.d.ts +4 -0
  30. package/dist/sentry.js +22 -0
  31. package/dist/strategies/link.js +4 -1
  32. package/dist/strategies/merge.js +4 -1
  33. package/dist/types.js +4 -1
  34. package/dist/utils/cognito-session.d.ts +1 -1
  35. package/dist/utils/cognito-session.js +8 -5
  36. package/dist/utils/git.js +4 -1
  37. package/dist/utils/hq-root.js +4 -1
  38. package/dist/utils/integrity.js +4 -1
  39. package/dist/utils/manifest.js +4 -1
  40. package/dist/utils/registry-client.js +4 -1
  41. package/dist/utils/registry.js +4 -1
  42. package/dist/utils/secrets-cache.js +6 -2
  43. package/package.json +4 -3
  44. package/scripts/generate-dsn.mjs +26 -0
  45. package/src/bin/hq-auth-refresh.ts +23 -16
  46. package/src/commands/_patterns.ts +2 -0
  47. package/src/commands/groups.ts +357 -0
  48. package/src/commands/secrets.ts +306 -26
  49. package/src/index.ts +17 -1
  50. package/src/sentry-before-send.test.ts +359 -0
  51. package/src/sentry-before-send.ts +221 -0
  52. package/src/sentry.test.ts +24 -0
  53. package/src/sentry.ts +19 -0
  54. package/src/utils/cognito-session.ts +4 -4
  55. package/src/utils/secrets-cache.ts +2 -1
  56. package/dist/bin/hq-auth-refresh.d.ts.map +0 -1
  57. package/dist/bin/hq-auth-refresh.js.map +0 -1
  58. package/dist/commands/add.d.ts.map +0 -1
  59. package/dist/commands/add.js.map +0 -1
  60. package/dist/commands/auth.d.ts.map +0 -1
  61. package/dist/commands/auth.js.map +0 -1
  62. package/dist/commands/cloud.d.ts.map +0 -1
  63. package/dist/commands/cloud.js.map +0 -1
  64. package/dist/commands/list.d.ts.map +0 -1
  65. package/dist/commands/list.js.map +0 -1
  66. package/dist/commands/login.d.ts.map +0 -1
  67. package/dist/commands/login.js.map +0 -1
  68. package/dist/commands/logout.d.ts.map +0 -1
  69. package/dist/commands/logout.js.map +0 -1
  70. package/dist/commands/onboard.d.ts.map +0 -1
  71. package/dist/commands/onboard.js.map +0 -1
  72. package/dist/commands/pack-install.d.ts.map +0 -1
  73. package/dist/commands/pack-install.js.map +0 -1
  74. package/dist/commands/pkg-install.d.ts.map +0 -1
  75. package/dist/commands/pkg-install.js.map +0 -1
  76. package/dist/commands/pkg-list.d.ts.map +0 -1
  77. package/dist/commands/pkg-list.js.map +0 -1
  78. package/dist/commands/pkg-remove.d.ts.map +0 -1
  79. package/dist/commands/pkg-remove.js.map +0 -1
  80. package/dist/commands/pkg-update.d.ts.map +0 -1
  81. package/dist/commands/pkg-update.js.map +0 -1
  82. package/dist/commands/secrets.d.ts.map +0 -1
  83. package/dist/commands/secrets.js.map +0 -1
  84. package/dist/commands/sync.d.ts.map +0 -1
  85. package/dist/commands/sync.js.map +0 -1
  86. package/dist/commands/team-sync.d.ts.map +0 -1
  87. package/dist/commands/team-sync.js.map +0 -1
  88. package/dist/commands/update.d.ts.map +0 -1
  89. package/dist/commands/update.js.map +0 -1
  90. package/dist/commands/whoami.d.ts.map +0 -1
  91. package/dist/commands/whoami.js.map +0 -1
  92. package/dist/index.d.ts.map +0 -1
  93. package/dist/index.js.map +0 -1
  94. package/dist/strategies/link.d.ts.map +0 -1
  95. package/dist/strategies/link.js.map +0 -1
  96. package/dist/strategies/merge.d.ts.map +0 -1
  97. package/dist/strategies/merge.js.map +0 -1
  98. package/dist/types.d.ts.map +0 -1
  99. package/dist/types.js.map +0 -1
  100. package/dist/utils/auth.d.ts +0 -27
  101. package/dist/utils/auth.d.ts.map +0 -1
  102. package/dist/utils/auth.js +0 -155
  103. package/dist/utils/auth.js.map +0 -1
  104. package/dist/utils/cognito-session.d.ts.map +0 -1
  105. package/dist/utils/cognito-session.js.map +0 -1
  106. package/dist/utils/git.d.ts.map +0 -1
  107. package/dist/utils/git.js.map +0 -1
  108. package/dist/utils/hq-root.d.ts.map +0 -1
  109. package/dist/utils/hq-root.js.map +0 -1
  110. package/dist/utils/integrity.d.ts.map +0 -1
  111. package/dist/utils/integrity.js.map +0 -1
  112. package/dist/utils/manifest.d.ts.map +0 -1
  113. package/dist/utils/manifest.js.map +0 -1
  114. package/dist/utils/registry-client.d.ts.map +0 -1
  115. package/dist/utils/registry-client.js.map +0 -1
  116. package/dist/utils/registry.d.ts.map +0 -1
  117. package/dist/utils/registry.js.map +0 -1
  118. package/dist/utils/secrets-cache.d.ts.map +0 -1
  119. package/dist/utils/secrets-cache.js.map +0 -1
  120. package/dist/utils/token-store.d.ts +0 -28
  121. package/dist/utils/token-store.d.ts.map +0 -1
  122. package/dist/utils/token-store.js +0 -68
  123. package/dist/utils/token-store.js.map +0 -1
@@ -0,0 +1,359 @@
1
+ // src/sentry-before-send.test.ts — canonical fleet Vitest suite.
2
+ // This file is byte-identical across every consumer repo (no per-repo
3
+ // line-1 variation, unlike sentry-before-send.ts). The body-identity
4
+ // gate each consumer runs is `diff hq-console/src/sentry-before-send.test.ts
5
+ // <consumer>/src/sentry-before-send.test.ts` returns empty (no tail -n +2).
6
+ import { describe, expect, it } from "vitest";
7
+ import type { ErrorEvent, EventHint } from "@sentry/nextjs";
8
+ import { beforeSend } from "./sentry-before-send";
9
+
10
+ const REDACTED = "[Filtered]";
11
+
12
+ function run(event: ErrorEvent): ErrorEvent {
13
+ const result = beforeSend(event, {} as EventHint);
14
+ if (!result) throw new Error("beforeSend dropped event unexpectedly");
15
+ return result;
16
+ }
17
+
18
+ describe("scrubHeaders — SENSITIVE_HEADER_NAMES case-insensitive redaction", () => {
19
+ const sensitiveHeaders = [
20
+ "authorization",
21
+ "proxy-authorization",
22
+ "cookie",
23
+ "set-cookie",
24
+ "x-api-key",
25
+ "x-auth-token",
26
+ "x-access-token",
27
+ "x-session-token",
28
+ ];
29
+
30
+ it.each(sensitiveHeaders)("redacts %s header (and case variants)", (name) => {
31
+ const upper = name.toUpperCase();
32
+ const out = run({
33
+ request: {
34
+ headers: { [upper]: "leaky-value", "x-trace-id": "keep-me" },
35
+ },
36
+ } as unknown as ErrorEvent);
37
+ expect((out.request!.headers as Record<string, unknown>)[upper]).toBe(REDACTED);
38
+ expect((out.request!.headers as Record<string, unknown>)["x-trace-id"]).toBe("keep-me");
39
+ });
40
+
41
+ it("redacts a raw Cookie header that lands on request.headers.cookie", () => {
42
+ const out = run({
43
+ request: {
44
+ headers: {
45
+ Cookie: "authjs.session-token=abc; other=keep",
46
+ "x-trace-id": "keep-me",
47
+ },
48
+ },
49
+ } as unknown as ErrorEvent);
50
+ expect((out.request!.headers as Record<string, unknown>).Cookie).toBe(REDACTED);
51
+ expect((out.request!.headers as Record<string, unknown>)["x-trace-id"]).toBe("keep-me");
52
+ });
53
+ });
54
+
55
+ describe("scrubCookies — SENSITIVE_COOKIE_NAMES (Auth.js v5 + v4 legacy + Clerk) + raw-string fallback", () => {
56
+ // v5 (Auth.js): session / callback-url use __Secure-; csrf-token uses
57
+ // __Host- (NOT __Secure-). OAuth-state families (pkce / state / nonce /
58
+ // challenge) each pair bare + __Secure-.
59
+ const v5Names = [
60
+ "authjs.session-token",
61
+ "__secure-authjs.session-token",
62
+ "authjs.csrf-token",
63
+ "__host-authjs.csrf-token",
64
+ "authjs.callback-url",
65
+ "__secure-authjs.callback-url",
66
+ "authjs.pkce.code_verifier",
67
+ "__secure-authjs.pkce.code_verifier",
68
+ "authjs.state",
69
+ "__secure-authjs.state",
70
+ "authjs.nonce",
71
+ "__secure-authjs.nonce",
72
+ "authjs.challenge",
73
+ "__secure-authjs.challenge",
74
+ ];
75
+
76
+ // v4 (NextAuth legacy): same prefix discipline — csrf uses __Host-, others
77
+ // use __Secure-. Kept in the set so accidental downgrades / mixed-version
78
+ // monorepos don't silently leak.
79
+ const v4Names = [
80
+ "next-auth.session-token",
81
+ "__secure-next-auth.session-token",
82
+ "next-auth.csrf-token",
83
+ "__host-next-auth.csrf-token",
84
+ "next-auth.callback-url",
85
+ "__secure-next-auth.callback-url",
86
+ "next-auth.pkce.code_verifier",
87
+ "__secure-next-auth.pkce.code_verifier",
88
+ "next-auth.state",
89
+ "__secure-next-auth.state",
90
+ ];
91
+
92
+ const clerkNames = ["__clerk_db_jwt", "__session"];
93
+
94
+ it.each([...v5Names, ...v4Names, ...clerkNames])(
95
+ "redacts cookie %s (case-insensitive)",
96
+ (name) => {
97
+ const out = run({
98
+ request: {
99
+ cookies: { [name.toUpperCase()]: "secret-session-value", keep_me: "ok" },
100
+ },
101
+ } as unknown as ErrorEvent);
102
+ const cookies = out.request!.cookies as Record<string, unknown>;
103
+ expect(cookies[name.toUpperCase()]).toBe(REDACTED);
104
+ expect(cookies.keep_me).toBe("ok");
105
+ },
106
+ );
107
+
108
+ it("does NOT include a __Secure-authjs.csrf-token assertion — Auth.js v5 never emits that name (uses __Host- instead)", () => {
109
+ // This test is a read-through spec comment: the set MUST NOT have an
110
+ // entry for __Secure-authjs.csrf-token. If a maintainer ever adds one
111
+ // mechanically-by-pattern, this test documents why it is wrong.
112
+ const out = run({
113
+ request: {
114
+ cookies: { "__Secure-authjs.csrf-token": "does-not-exist-in-prod" },
115
+ },
116
+ } as unknown as ErrorEvent);
117
+ // The value is NOT redacted because the set does not contain that name.
118
+ // This is deliberate — see SENSITIVE_COOKIE_NAMES comment in the module.
119
+ const cookies = out.request!.cookies as Record<string, unknown>;
120
+ expect(cookies["__Secure-authjs.csrf-token"]).toBe("does-not-exist-in-prod");
121
+ });
122
+
123
+ it("wholesale-redacts a raw-string Cookie header", () => {
124
+ const out = run({
125
+ request: { cookies: "anything=here; other=keep" },
126
+ } as unknown as ErrorEvent);
127
+ expect(out.request!.cookies).toBe(REDACTED);
128
+ });
129
+
130
+ it("leaves undefined cookies alone", () => {
131
+ const out = run({ request: { headers: {} } } as unknown as ErrorEvent);
132
+ expect(out.request!.cookies).toBeUndefined();
133
+ });
134
+ });
135
+
136
+ describe("scrubFields — recursive redaction with case-insensitivity, arrays, nested, cycles", () => {
137
+ it("redacts top-level sensitive field names case-insensitively", () => {
138
+ const out = run({
139
+ extra: {
140
+ token: "abc",
141
+ Password: "pw",
142
+ APIKEY: "k",
143
+ api_key: "k2",
144
+ IdToken: "id",
145
+ ACCESSTOKEN: "at",
146
+ RefreshToken: "rt",
147
+ keep: "yes",
148
+ },
149
+ } as unknown as ErrorEvent);
150
+ const extra = out.extra as Record<string, unknown>;
151
+ expect(extra.token).toBe(REDACTED);
152
+ expect(extra.Password).toBe(REDACTED);
153
+ expect(extra.APIKEY).toBe(REDACTED);
154
+ expect(extra.api_key).toBe(REDACTED);
155
+ expect(extra.IdToken).toBe(REDACTED);
156
+ expect(extra.ACCESSTOKEN).toBe(REDACTED);
157
+ expect(extra.RefreshToken).toBe(REDACTED);
158
+ expect(extra.keep).toBe("yes");
159
+ });
160
+
161
+ it("recurses into nested objects and arrays", () => {
162
+ const out = run({
163
+ extra: {
164
+ nested: { deeper: { token: "t", safe: 1 } },
165
+ items: [{ password: "pw" }, { other: "ok" }],
166
+ },
167
+ } as unknown as ErrorEvent);
168
+ const extra = out.extra as Record<string, any>;
169
+ expect(extra.nested.deeper.token).toBe(REDACTED);
170
+ expect(extra.nested.deeper.safe).toBe(1);
171
+ expect(extra.items[0].password).toBe(REDACTED);
172
+ expect(extra.items[1].other).toBe("ok");
173
+ });
174
+
175
+ it("handles circular references without stack overflow and scrubs fields reachable only through the cycle", () => {
176
+ const cyclic: Record<string, unknown> = { token: "leak" };
177
+ cyclic.self = cyclic;
178
+ const out = run({ extra: { outer: cyclic } } as unknown as ErrorEvent);
179
+ const outer = (out.extra as any).outer;
180
+ // Top-level field is scrubbed.
181
+ expect(outer.token).toBe(REDACTED);
182
+ // The Map<original, scrubbed> cycle fix means re-visits return the
183
+ // memoized scrubbed copy, not the un-scrubbed original — so
184
+ // `outer.self.token` is ALSO [Filtered], not the leaked value.
185
+ expect(outer.self.token).toBe(REDACTED);
186
+ expect(outer.self).toBe(outer);
187
+ });
188
+
189
+ it("scalar-string defense-in-depth: redacts embedded credentials in extra.note even when the key is NOT in SENSITIVE_FIELD_NAMES", () => {
190
+ const out = run({
191
+ extra: { note: "Something broke, password=hunter2 failed" },
192
+ } as unknown as ErrorEvent);
193
+ const extra = out.extra as Record<string, unknown>;
194
+ expect(extra.note).toContain(`password=${REDACTED}`);
195
+ expect(extra.note).not.toContain("hunter2");
196
+ });
197
+
198
+ it("scalar-string defense-in-depth: redacts a Bearer in user.bio (key NOT in SENSITIVE_FIELD_NAMES)", () => {
199
+ const out = run({
200
+ user: { id: "u1", email: "a@b.c", bio: "authorization: Bearer leak" },
201
+ } as unknown as ErrorEvent);
202
+ const user = out.user as Record<string, unknown>;
203
+ expect(user.id).toBe("u1");
204
+ expect(user.email).toBe("a@b.c");
205
+ expect(user.bio).toContain(`Bearer ${REDACTED}`);
206
+ expect(user.bio).not.toContain("leak");
207
+ });
208
+ });
209
+
210
+ describe("scrubString — regex passes for kv, scheme+credential, JWT triplets", () => {
211
+ it("redacts key=value and key: value pairs", () => {
212
+ const out = run({
213
+ message: "password=hunter2 and api-key: abc123 should be redacted",
214
+ } as unknown as ErrorEvent);
215
+ expect(out.message).toContain(`password=${REDACTED}`);
216
+ expect(out.message).toContain(`api-key:${REDACTED}`);
217
+ expect(out.message).not.toContain("hunter2");
218
+ expect(out.message).not.toContain("abc123");
219
+ });
220
+
221
+ it("redacts Bearer / Basic credentials", () => {
222
+ const out = run({
223
+ message: "Got header Bearer abcDEF._~+/=- and Basic Zm9vOmJhcg==",
224
+ } as unknown as ErrorEvent);
225
+ expect(out.message).toContain(`Bearer ${REDACTED}`);
226
+ expect(out.message).toContain(`Basic ${REDACTED}`);
227
+ });
228
+
229
+ it("redacts non-JWT-shaped accessToken/refreshToken/id_token values in free text", () => {
230
+ const out = run({
231
+ message:
232
+ "refreshToken=opaqueBase64NotAJwt-xyz and accessToken: anotherOpaqueValue and id_token=short",
233
+ } as unknown as ErrorEvent);
234
+ expect(out.message).toContain(`refreshToken=${REDACTED}`);
235
+ expect(out.message).toContain(`accessToken:${REDACTED}`);
236
+ expect(out.message).toContain(`id_token=${REDACTED}`);
237
+ expect(out.message).not.toContain("opaqueBase64NotAJwt-xyz");
238
+ expect(out.message).not.toContain("anotherOpaqueValue");
239
+ expect(out.message).not.toContain("=short");
240
+ });
241
+
242
+ it("redacts bare JWT triplets", () => {
243
+ const jwt = "eyJhbGciOi12345.eyJzdWIiOi67890.sig_abcdef1234";
244
+ const out = run({ message: `token was ${jwt} yeah` } as unknown as ErrorEvent);
245
+ expect(out.message).not.toContain(jwt);
246
+ expect(out.message).toContain(REDACTED);
247
+ });
248
+ });
249
+
250
+ describe("scrubQueryString — parse + fallback", () => {
251
+ it("parses well-formed query strings with a leading ? and redacts sensitive keys", () => {
252
+ const out = run({
253
+ request: {
254
+ query_string: "?foo=bar&token=leak&password=pw&keep=me",
255
+ },
256
+ } as unknown as ErrorEvent);
257
+ const qs = out.request!.query_string as string;
258
+ expect(qs.startsWith("?")).toBe(true);
259
+ expect(qs).toContain("foo=bar");
260
+ expect(qs).toContain(`token=${REDACTED}`);
261
+ expect(qs).toContain(`password=${REDACTED}`);
262
+ expect(qs).toContain("keep=me");
263
+ });
264
+
265
+ it("parses no-leading-? strings as well", () => {
266
+ const out = run({
267
+ request: { query_string: "token=leak&keep=1" },
268
+ } as unknown as ErrorEvent);
269
+ const qs = out.request!.query_string as string;
270
+ expect(qs.startsWith("?")).toBe(false);
271
+ expect(qs).toContain(`token=${REDACTED}`);
272
+ expect(qs).toContain("keep=1");
273
+ });
274
+
275
+ it("leaves non-string query_string alone", () => {
276
+ const out = run({
277
+ request: { query_string: undefined },
278
+ } as unknown as ErrorEvent);
279
+ expect(out.request!.query_string).toBeUndefined();
280
+ });
281
+ });
282
+
283
+ describe("event-level walk — exception.values, message, breadcrumbs, user, extra, contexts, request.data", () => {
284
+ it("scrubs exception.values[].value strings", () => {
285
+ const out = run({
286
+ exception: {
287
+ values: [
288
+ { type: "Error", value: "password=sekret failed" },
289
+ { type: "Error", value: "clean" },
290
+ ],
291
+ },
292
+ } as unknown as ErrorEvent);
293
+ expect(out.exception!.values![0].value).toContain(`password=${REDACTED}`);
294
+ expect(out.exception!.values![0].value).not.toContain("sekret");
295
+ expect(out.exception!.values![1].value).toBe("clean");
296
+ });
297
+
298
+ it("scrubs event.message", () => {
299
+ const out = run({ message: "Bearer leaked.jwt.token" } as unknown as ErrorEvent);
300
+ expect(out.message).toContain(`Bearer ${REDACTED}`);
301
+ });
302
+
303
+ it("scrubs breadcrumbs.message (string) and breadcrumbs.data (structured)", () => {
304
+ const out = run({
305
+ breadcrumbs: [
306
+ {
307
+ category: "fetch",
308
+ message: "GET /api?token=abc",
309
+ data: { url: "/api", password: "leak" },
310
+ },
311
+ ],
312
+ } as unknown as ErrorEvent);
313
+ const bc = out.breadcrumbs![0];
314
+ expect(bc.message).toContain(`token=${REDACTED}`);
315
+ expect((bc.data as Record<string, unknown>).password).toBe(REDACTED);
316
+ expect((bc.data as Record<string, unknown>).url).toBe("/api");
317
+ });
318
+
319
+ it("scrubs event.user fields", () => {
320
+ const out = run({
321
+ user: { id: "u1", email: "a@b.c", token: "bad", IdToken: "cognito" },
322
+ } as unknown as ErrorEvent);
323
+ const user = out.user as Record<string, unknown>;
324
+ expect(user.id).toBe("u1");
325
+ expect(user.email).toBe("a@b.c");
326
+ expect(user.token).toBe(REDACTED);
327
+ expect(user.IdToken).toBe(REDACTED);
328
+ });
329
+
330
+ it("scrubs event.extra", () => {
331
+ const out = run({ extra: { password: "x", safe: "y" } } as unknown as ErrorEvent);
332
+ expect((out.extra as Record<string, unknown>).password).toBe(REDACTED);
333
+ expect((out.extra as Record<string, unknown>).safe).toBe("y");
334
+ });
335
+
336
+ it("scrubs event.contexts", () => {
337
+ const out = run({
338
+ contexts: {
339
+ auth: { token: "x", scheme: "bearer" },
340
+ runtime: { name: "node", version: "22" },
341
+ },
342
+ } as unknown as ErrorEvent);
343
+ const auth = (out.contexts as any).auth;
344
+ const runtime = (out.contexts as any).runtime;
345
+ expect(auth.token).toBe(REDACTED);
346
+ expect(auth.scheme).toBe("bearer");
347
+ expect(runtime.name).toBe("node");
348
+ });
349
+
350
+ it("scrubs structured request body via request.data", () => {
351
+ const out = run({
352
+ request: { data: { username: "u", password: "pw", nested: { token: "t" } } },
353
+ } as unknown as ErrorEvent);
354
+ const data = out.request!.data as Record<string, any>;
355
+ expect(data.username).toBe("u");
356
+ expect(data.password).toBe(REDACTED);
357
+ expect(data.nested.token).toBe(REDACTED);
358
+ });
359
+ });
@@ -0,0 +1,221 @@
1
+ // src/sentry-before-send.ts — canonical fleet scrubber
2
+ // Line 1 is per-repo — see "Per-repo `import type` line" table in Step 3.
3
+ // hq-console uses @sentry/nextjs. Consumer steps override to their own SDK.
4
+ import type { ErrorEvent, EventHint } from "@sentry/node";
5
+
6
+ const SENSITIVE_FIELD_NAMES = new Set([
7
+ "password", "secret", "apikey", "api_key", "token",
8
+ // Cognito token-response fields captured as error context.
9
+ "idtoken", "accesstoken", "refreshtoken",
10
+ ]);
11
+
12
+ // Stored lowercased; membership tested against `name.toLowerCase()`.
13
+ // MUST cover BOTH Auth.js v5 (current in hq-console, pinned to
14
+ // next-auth@5.0.0-beta.25 which resolves @auth/core@0.37.2) AND legacy
15
+ // NextAuth v4 name spaces — covering both is cheap and survives accidental
16
+ // downgrades or mixed-version repos. Grounded against @auth/core@0.37.2's
17
+ // src/lib/utils/cookie.ts (re-ground via `grep -nE
18
+ // '(sessionToken|callbackUrl|csrfToken|pkceCodeVerifier|state|nonce|webauthnChallenge): \{'
19
+ // node_modules/.pnpm/@auth+core@0.37.2/node_modules/@auth/core/src/lib/utils/cookie.ts`
20
+ // — seven matches, one per cookie family): two distinct prefixing patterns
21
+ // coexist there, and the entries below mirror BOTH exactly:
22
+ // • session-token / callback-url / pkce.code_verifier / state / nonce /
23
+ // challenge each pair the bare form (HTTP dev, cookiePrefix="") with
24
+ // the __Secure- form (HTTPS prod, cookiePrefix="__Secure-").
25
+ // • csrf-token is special: the source explicitly uses __Host- (NOT
26
+ // __Secure-) under HTTPS for stricter cookie scoping (the csrfToken
27
+ // entry in @auth/core's cookie.ts is the only one whose name template
28
+ // uses `__Host-` instead of `${cookiePrefix}`; the comment above it
29
+ // reads "Default to __Host- for CSRF token for additional protection
30
+ // if using useSecureCookies"). The set therefore pairs the bare form
31
+ // with __Host-, and does NOT include __Secure-authjs.csrf-token
32
+ // because @auth/core never emits that name. The legacy v4 entry mirrors
33
+ // the same pattern (bare next-auth.csrf-token + __Host-next-auth.csrf-token,
34
+ // no __Secure- variant).
35
+ // Missing any of the four OAuth-state families (pkce.code_verifier, state,
36
+ // nonce, challenge) ships a cookie-leak in the same class as the v4-only
37
+ // defect Round-4 called out. This set is the single authoritative spec;
38
+ // all of Steps 3/8/9a/12/13 reproduce it identically.
39
+ //
40
+ // MAINTAINER NOTE: Do NOT extend this set by mechanically pairing every
41
+ // cookie with bare + __Secure- + __Host-. The pairing follows the source:
42
+ // session-style cookies use __Secure-; csrf-style cookies use __Host-.
43
+ // New entries added in the future must be grounded against the actual
44
+ // emitter (Auth.js source for v5/v4, vendor docs for Clerk, etc.) rather
45
+ // than copied from the existing pattern.
46
+ const SENSITIVE_COOKIE_NAMES = new Set([
47
+ // ==== Auth.js v5 (next-auth@5 / @auth/core) ====
48
+ // Session JWT — bare + __Secure-
49
+ "authjs.session-token",
50
+ "__secure-authjs.session-token",
51
+ // CSRF double-submit token — bare + __Host- ONLY (@auth/core's cookie.ts
52
+ // csrfToken entry uses __Host-, never __Secure-)
53
+ "authjs.csrf-token",
54
+ "__host-authjs.csrf-token",
55
+ // Post-auth redirect URL — bare + __Secure-
56
+ "authjs.callback-url",
57
+ "__secure-authjs.callback-url",
58
+ // OAuth PKCE verifier — leaking this enables interception of an in-flight
59
+ // OAuth code exchange.
60
+ "authjs.pkce.code_verifier",
61
+ "__secure-authjs.pkce.code_verifier",
62
+ // OAuth CSRF/replay protection
63
+ "authjs.state",
64
+ "__secure-authjs.state",
65
+ // OpenID Connect nonce — replay protection
66
+ "authjs.nonce",
67
+ "__secure-authjs.nonce",
68
+ // OAuth WebAuthn challenge
69
+ "authjs.challenge",
70
+ "__secure-authjs.challenge",
71
+ // ==== NextAuth v4 legacy names (kept so accidental downgrades /
72
+ // mixed-version monorepos don't silently leak). Same prefix
73
+ // discipline as v5: csrf-token uses __Host-, others use __Secure-. ====
74
+ "next-auth.session-token",
75
+ "__secure-next-auth.session-token",
76
+ "next-auth.csrf-token",
77
+ "__host-next-auth.csrf-token",
78
+ "next-auth.callback-url",
79
+ "__secure-next-auth.callback-url",
80
+ "next-auth.pkce.code_verifier",
81
+ "__secure-next-auth.pkce.code_verifier",
82
+ "next-auth.state",
83
+ "__secure-next-auth.state",
84
+ // ==== Clerk (documented for future; no-op today) ====
85
+ "__clerk_db_jwt", "__session",
86
+ ]);
87
+
88
+ const REDACTED = "[Filtered]";
89
+
90
+ const SENSITIVE_HEADER_NAMES = new Set([
91
+ "authorization", "proxy-authorization",
92
+ "cookie", "set-cookie",
93
+ "x-api-key", "x-auth-token", "x-access-token", "x-session-token",
94
+ ]);
95
+
96
+ function scrubHeaders(h?: Record<string, unknown>): Record<string, unknown> | undefined {
97
+ if (!h) return h;
98
+ const out: Record<string, unknown> = {};
99
+ for (const [k, v] of Object.entries(h)) {
100
+ out[k] = SENSITIVE_HEADER_NAMES.has(k.toLowerCase()) ? REDACTED : v;
101
+ }
102
+ return out;
103
+ }
104
+
105
+ type CookieField = Record<string, unknown> | string | undefined;
106
+
107
+ function scrubCookies(c: CookieField): CookieField {
108
+ if (!c) return c;
109
+ if (typeof c === "string") return REDACTED; // raw Cookie header — wholesale redact
110
+ const out: Record<string, unknown> = {};
111
+ for (const [k, v] of Object.entries(c)) {
112
+ out[k] = SENSITIVE_COOKIE_NAMES.has(k.toLowerCase()) ? REDACTED : v;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ const SENSITIVE_STRING_PATTERNS: RegExp[] = [
118
+ // kv pairs; `(?:id|access|refresh)[_-]?token` catches Cognito-style free-text
119
+ // `accessToken=...`, `refreshToken: ...`, `id_token=...` which bare `\btoken` misses.
120
+ /\b(password|secret|api[_-]?key|(?:id|access|refresh)[_-]?token|token)\s*[:=]\s*[^\s&"'<>]+/gi,
121
+ /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi,
122
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
123
+ ];
124
+
125
+ function scrubString(s: string): string {
126
+ let out = s;
127
+ for (const re of SENSITIVE_STRING_PATTERNS) {
128
+ out = out.replace(re, (m) => {
129
+ const sep = m.search(/[:=\s]/);
130
+ return sep > 0 ? `${m.slice(0, sep + 1)}${REDACTED}` : REDACTED;
131
+ });
132
+ }
133
+ return out;
134
+ }
135
+
136
+ function scrubQueryString(qs: string): string {
137
+ try {
138
+ const params = new URLSearchParams(qs.startsWith("?") ? qs.slice(1) : qs);
139
+ const out: string[] = [];
140
+ for (const [k, v] of params) {
141
+ out.push(`${k}=${SENSITIVE_FIELD_NAMES.has(k.toLowerCase()) ? REDACTED : v}`);
142
+ }
143
+ const joined = out.join("&");
144
+ return qs.startsWith("?") ? `?${joined}` : joined;
145
+ } catch {
146
+ return scrubString(qs);
147
+ }
148
+ }
149
+
150
+ function scrubFields<T>(value: T, memo: Map<object, unknown> = new Map()): T {
151
+ if (value === null || typeof value !== "object") return value;
152
+ const obj = value as unknown as object;
153
+ // On cycle revisit, return the SAME scrubbed copy built on first visit —
154
+ // NOT the original. Returning the original would leak fields reachable
155
+ // only through the cycle (e.g., a.self.token).
156
+ if (memo.has(obj)) return memo.get(obj) as T;
157
+
158
+ if (Array.isArray(value)) {
159
+ const arr: unknown[] = [];
160
+ memo.set(obj, arr);
161
+ for (const v of value) arr.push(scrubFields(v, memo));
162
+ return arr as unknown as T;
163
+ }
164
+
165
+ const out: Record<string, unknown> = {};
166
+ memo.set(obj, out);
167
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
168
+ if (SENSITIVE_FIELD_NAMES.has(k.toLowerCase())) {
169
+ out[k] = REDACTED;
170
+ } else if (v !== null && typeof v === "object") {
171
+ out[k] = scrubFields(v, memo);
172
+ } else if (typeof v === "string") {
173
+ // SCALAR-STRING DEFENSE-IN-DEPTH. A stringified log line stashed via
174
+ // setExtra / setContext / setUser can embed `password=...` or a Bearer
175
+ // token even when the outer key (`note`, `bio`, `log_line`, …) isn't
176
+ // in SENSITIVE_FIELD_NAMES. Route scalar strings through scrubString so
177
+ // these don't bypass the scrubber on their way into Sentry.
178
+ out[k] = scrubString(v);
179
+ } else {
180
+ out[k] = v;
181
+ }
182
+ }
183
+ return out as unknown as T;
184
+ }
185
+
186
+ export function beforeSend(event: ErrorEvent, _hint: EventHint): ErrorEvent | null {
187
+ if (event.request) {
188
+ const req = event.request as { headers?: unknown; cookies?: unknown; data?: unknown; query_string?: unknown };
189
+ event.request = {
190
+ ...event.request,
191
+ headers: scrubHeaders(req.headers as Record<string, unknown> | undefined) as typeof event.request.headers,
192
+ cookies: scrubCookies(req.cookies as CookieField) as typeof event.request.cookies,
193
+ data: scrubFields(req.data),
194
+ query_string:
195
+ typeof req.query_string === "string"
196
+ ? scrubQueryString(req.query_string)
197
+ : (req.query_string as typeof event.request.query_string),
198
+ };
199
+ }
200
+ if (typeof event.message === "string") event.message = scrubString(event.message);
201
+ if (event.exception?.values) {
202
+ event.exception = {
203
+ ...event.exception,
204
+ values: event.exception.values.map((v) => ({
205
+ ...v,
206
+ value: typeof v.value === "string" ? scrubString(v.value) : v.value,
207
+ })),
208
+ };
209
+ }
210
+ if (event.user) event.user = scrubFields(event.user as Record<string, unknown>) as typeof event.user;
211
+ if (event.breadcrumbs) {
212
+ event.breadcrumbs = event.breadcrumbs.map((b) => ({
213
+ ...b,
214
+ message: typeof b.message === "string" ? scrubString(b.message) : b.message,
215
+ data: b.data ? scrubFields(b.data) : b.data,
216
+ }));
217
+ }
218
+ if (event.extra) event.extra = scrubFields(event.extra);
219
+ if (event.contexts) event.contexts = scrubFields(event.contexts);
220
+ return event;
221
+ }
@@ -0,0 +1,24 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import * as Sentry from "@sentry/node";
3
+ import { initSentry } from "./sentry.js";
4
+
5
+ vi.mock("./sentry-dsn.generated.js", () => ({ BUNDLED_DSN: "" }));
6
+
7
+ describe("initSentry — empty BUNDLED_DSN no-op", () => {
8
+ afterEach(() => {
9
+ vi.unstubAllEnvs();
10
+ });
11
+
12
+ it("fakeTransport.send is never called after captureException + flush(2000) when no DSN configured", async () => {
13
+ vi.stubEnv("SENTRY_DSN", "");
14
+
15
+ const fakeTransport = { send: vi.fn().mockResolvedValue(undefined) };
16
+
17
+ initSentry();
18
+
19
+ Sentry.captureException(new Error("no-dsn-should-not-send"));
20
+ await Sentry.flush(2000);
21
+
22
+ expect(fakeTransport.send).not.toHaveBeenCalled();
23
+ });
24
+ });
package/src/sentry.ts ADDED
@@ -0,0 +1,19 @@
1
+ import * as Sentry from "@sentry/node";
2
+ import { BUNDLED_DSN } from "./sentry-dsn.generated.js";
3
+ import { beforeSend } from "./sentry-before-send.js";
4
+
5
+ export function initSentry(): void {
6
+ const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
7
+ if (!dsn) return;
8
+ Sentry.init({
9
+ dsn,
10
+ release: `hq-cli@${process.env.npm_package_version ?? "0.0.0"}`,
11
+ environment: process.env.HQ_CLI_ENV ?? "production",
12
+ initialScope: {
13
+ tags: { repo: "hq-cli" },
14
+ },
15
+ beforeSend,
16
+ });
17
+ }
18
+
19
+ export { Sentry };
@@ -6,7 +6,7 @@
6
6
  * - `hq auth refresh` and the standalone `hq-auth-refresh` bin invoked by
7
7
  * the deploy skill (.claude/skills/deploy/SKILL.md step 4)
8
8
  *
9
- * Defaults point at the shared hq-vault-dev Cognito pool. They mirror
9
+ * Defaults point at the shared vault-indigo-hq-dev Cognito pool. They mirror
10
10
  * tools/vlt-e2e/e2e-create-company-smoke.ts so the CLI and the in-tree demo script
11
11
  * stay drift-free. Override any of them via env:
12
12
  *
@@ -31,8 +31,8 @@ import {
31
31
 
32
32
  export const DEFAULT_COGNITO: CognitoAuthConfig = {
33
33
  region: process.env.AWS_REGION ?? "us-east-1",
34
- userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "hq-vault-dev",
35
- clientId: process.env.HQ_COGNITO_CLIENT_ID ?? "4mmujmjq3srakdueg656b9m0mp",
34
+ userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "vault-indigo-hq-dev",
35
+ clientId: process.env.HQ_COGNITO_CLIENT_ID ?? "7r7an9keh0u6hlsvepl74tvqb0",
36
36
  port: process.env.HQ_COGNITO_CALLBACK_PORT
37
37
  ? Number(process.env.HQ_COGNITO_CALLBACK_PORT)
38
38
  : 8765,
@@ -40,7 +40,7 @@ export const DEFAULT_COGNITO: CognitoAuthConfig = {
40
40
 
41
41
  export const DEFAULT_VAULT_API_URL =
42
42
  process.env.HQ_VAULT_API_URL ??
43
- "https://tqdwdqxv75.execute-api.us-east-1.amazonaws.com";
43
+ "https://ky8cgbl4yh.execute-api.us-east-1.amazonaws.com";
44
44
 
45
45
  export const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
46
46
 
@@ -17,7 +17,7 @@ function ensureCacheDir(companyUid: string): void {
17
17
 
18
18
  function validateInputs(companyUid: string, name: string): boolean {
19
19
  if (!companyUid || companyUid.includes("/") || companyUid.includes("..")) return false;
20
- if (!/^[A-Z][A-Z0-9_]*$/.test(name)) return false;
20
+ if (!/^[A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*$/.test(name)) return false;
21
21
  return true;
22
22
  }
23
23
 
@@ -99,6 +99,7 @@ export function writeCache(companyUid: string, name: string, value: string): voi
99
99
 
100
100
  const out = Buffer.concat([timestamp, iv, authTag, encrypted]);
101
101
  const filePath = path.join(CACHE_DIR, companyUid, name);
102
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
102
103
  const tmpPath = `${filePath}.tmp.${process.pid}`;
103
104
  fs.writeFileSync(tmpPath, out, { mode: 0o600 });
104
105
  fs.renameSync(tmpPath, filePath);
@@ -1 +0,0 @@
1
- {"version":3,"file":"hq-auth-refresh.d.ts","sourceRoot":"","sources":["../../src/bin/hq-auth-refresh.ts"],"names":[],"mappings":";AAEA;;;;;;;;;GASG"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"hq-auth-refresh.js","sourceRoot":"","sources":["../../src/bin/hq-auth-refresh.ts"],"names":[],"mappings":";AAEA;;;;;;;;;GASG;AAEH,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAEnE,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,MAAM,oBAAoB,EAAE,CAAC;IAC5C,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;QACrB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;QAClB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,oBAAoB,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAC9D,CAAC;IACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,oBAAoB,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CACzE,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"add.d.ts","sourceRoot":"","sources":["../../src/commands/add.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAIpC,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAkEzD"}