@marcusrbrown/infra 0.9.8 → 0.9.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -717,3 +717,125 @@ describe('management auth probe (ban-awareness)', () => {
717
717
  expect(managementFetchUrls.length).toBe(1)
718
718
  })
719
719
  })
720
+
721
+ // ─── Ambient management key trusted-URL binding ───────────────────────────────
722
+
723
+ describe('cliproxyStatusAction — ambient key does not follow agent-supplied URLs', () => {
724
+ let originalEnv: Record<string, string | undefined>
725
+
726
+ beforeEach(() => {
727
+ originalEnv = {
728
+ CLIPROXY_URL: process.env.CLIPROXY_URL,
729
+ CLIPROXY_MANAGEMENT_KEY: process.env.CLIPROXY_MANAGEMENT_KEY,
730
+ }
731
+ process.env.CLIPROXY_MANAGEMENT_KEY = 'secret-ambient-key'
732
+ delete process.env.CLIPROXY_URL
733
+ })
734
+
735
+ afterEach(() => {
736
+ for (const [k, v] of Object.entries(originalEnv)) {
737
+ if (v === undefined) {
738
+ delete process.env[k]
739
+ } else {
740
+ process.env[k] = v
741
+ }
742
+ }
743
+ globalThis.fetch = originalFetch
744
+ })
745
+
746
+ it('does NOT send the ambient management key to a non-trusted --url override', async () => {
747
+ const capturedRequestsByHost: Record<string, string[]> = {}
748
+
749
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
750
+ const {hostname} = new URL(url)
751
+ const hdrs = init?.headers
752
+ const key = hdrs instanceof Headers ? hdrs.get('x-management-key') : null
753
+
754
+ if (!capturedRequestsByHost[hostname]) capturedRequestsByHost[hostname] = []
755
+ if (key) capturedRequestsByHost[hostname].push(key)
756
+
757
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
758
+ if (url.includes('/v0/management/'))
759
+ return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
760
+ return new Response('ok', {status: 200})
761
+ })
762
+
763
+ const {ctx} = createCapturedCtx()
764
+ try {
765
+ await cliproxyStatusAction({url: 'https://evil.example.com'}, ctx)
766
+ } catch {
767
+ // exit(1) expected or not — either way, we just care about key leakage
768
+ }
769
+
770
+ // The ambient secret key must NOT appear in any request to the evil host
771
+ expect(capturedRequestsByHost['evil.example.com'] ?? []).not.toContain('secret-ambient-key')
772
+ })
773
+
774
+ it('still uses the ambient key when --url matches the default trusted URL', async () => {
775
+ const capturedManagementKeys: string[] = []
776
+
777
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
778
+ const hdrs = init?.headers
779
+ const key = hdrs instanceof Headers ? hdrs.get('x-management-key') : null
780
+ if (url.includes('/v0/management/') && key) capturedManagementKeys.push(key)
781
+
782
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
783
+ if (url.includes('/v0/management/config'))
784
+ return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}})
785
+ if (url.includes('/v0/management/usage-queue'))
786
+ return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
787
+ if (url.includes('/v0/management/latest-version'))
788
+ return new Response(JSON.stringify({'latest-version': 'v1.0.0'}), {
789
+ status: 200,
790
+ headers: {'content-type': 'application/json'},
791
+ })
792
+ return new Response('ok', {status: 200})
793
+ })
794
+
795
+ const {ctx} = createCapturedCtx()
796
+ // Default URL is https://cliproxy.fro.bot — pass it explicitly
797
+ await cliproxyStatusAction({url: 'https://cliproxy.fro.bot'}, ctx)
798
+
799
+ expect(capturedManagementKeys).toContain('secret-ambient-key')
800
+ })
801
+
802
+ it('uses an explicit --key even when --url is a non-trusted host', async () => {
803
+ const capturedManagementKeys: string[] = []
804
+
805
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
806
+ const hdrs = init?.headers
807
+ const key = hdrs instanceof Headers ? hdrs.get('x-management-key') : null
808
+ if (url.includes('/v0/management/') && key) capturedManagementKeys.push(key)
809
+
810
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
811
+ if (url.includes('/v0/management/config'))
812
+ return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}})
813
+ if (url.includes('/v0/management/usage-queue'))
814
+ return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
815
+ if (url.includes('/v0/management/latest-version'))
816
+ return new Response(JSON.stringify({'latest-version': 'v1.0.0'}), {
817
+ status: 200,
818
+ headers: {'content-type': 'application/json'},
819
+ })
820
+ return new Response('ok', {status: 200})
821
+ })
822
+
823
+ const {ctx} = createCapturedCtx()
824
+ await cliproxyStatusAction({url: 'https://other-cliproxy.example.com', key: 'explicit-key'}, ctx)
825
+
826
+ expect(capturedManagementKeys).toContain('explicit-key')
827
+ })
828
+
829
+ it('falls through to the no-key warning path when URL is untrusted and no --key given', async () => {
830
+ globalThis.fetch = createFetchImplementation(async url => {
831
+ if (url.includes('/healthz')) return new Response('ok', {status: 200})
832
+ return new Response('[]', {status: 200})
833
+ })
834
+
835
+ const {ctx, captured} = createCapturedCtx()
836
+ await cliproxyStatusAction({url: 'https://other-cliproxy.example.com'}, ctx)
837
+
838
+ const output = [...captured.stdout, ...captured.stderr].join('\n')
839
+ expect(output).toMatch(/CLIPROXY_MANAGEMENT_KEY|no key|skipping/i)
840
+ })
841
+ })
@@ -385,7 +385,16 @@ export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCt
385
385
  try {
386
386
  const verbose = options.verbose === true
387
387
  const baseUrl = stripTrailingSlash(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
388
- const managementKey = options.key ?? process.env.CLIPROXY_MANAGEMENT_KEY
388
+
389
+ // Trusted URL: the canonical configured/default host, trailing-slash-normalized.
390
+ // Ambient env keys must ONLY follow to this trusted destination to prevent
391
+ // secret exfiltration when an agent passes an attacker-controlled --url.
392
+ const trustedUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
393
+ const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl
394
+
395
+ // An explicit --key is always honored (operator knows what they're doing).
396
+ // An ambient env key is only forwarded when the resolved baseUrl is trusted.
397
+ const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY)
389
398
 
390
399
  ctx.console.log('CLIProxyAPI status')
391
400
  ctx.console.log('')
@@ -58,7 +58,7 @@ describe('validateGatewayHost', () => {
58
58
 
59
59
  // ── Error message sanitization ──────────────────────────────────────────────
60
60
 
61
- it('truncates the invalid value in the error message to ~30 chars', () => {
61
+ it('omits the rejected value entirely from the error message', () => {
62
62
  const longMalicious = `-oProxyCommand=${'A'.repeat(100)}`
63
63
  let message = ''
64
64
  try {
@@ -66,8 +66,24 @@ describe('validateGatewayHost', () => {
66
66
  } catch (error) {
67
67
  message = error instanceof Error ? error.message : String(error)
68
68
  }
69
- // The excerpt in the message should not exceed 30 chars of the original value
69
+ // The value is never echoed (it may be a misdirected secret) — only the constraint is shown.
70
70
  expect(message).toContain('Invalid GATEWAY_HOST')
71
- expect(message.length).toBeLessThan(longMalicious.length + 50)
71
+ expect(message).not.toContain('ProxyCommand')
72
+ expect(message).not.toContain('AAAA')
73
+ })
74
+
75
+ // ── Secret value redaction ───────────────────────────────────────────────────
76
+
77
+ it('does not echo the rejected value in the error message', () => {
78
+ const secretValue = 'AWS_SECRET_KEY=AKIA1234567890EXAMPLE'
79
+ let message = ''
80
+ try {
81
+ validateGatewayHost(secretValue)
82
+ } catch (error) {
83
+ message = error instanceof Error ? error.message : String(error)
84
+ }
85
+ expect(message).toContain('Invalid GATEWAY_HOST')
86
+ expect(message).not.toContain('AKIA1234567890EXAMPLE')
87
+ expect(message).not.toContain('AWS_SECRET_KEY')
72
88
  })
73
89
  })
@@ -13,7 +13,8 @@ const VALID_HOST_RE = /^[a-z\d][a-z\d.\-]*$/i
13
13
  * Rejects: empty strings, values starting with `-`, and anything containing
14
14
  * characters outside `[A-Za-z0-9.-]`.
15
15
  *
16
- * @throws {Error} with a sanitized excerpt of the invalid value.
16
+ * @throws {Error} when empty or not a valid hostname. The rejected value is
17
+ * never included in the message (it may be a misdirected secret).
17
18
  * @returns The validated host string (unchanged).
18
19
  */
19
20
  export function validateGatewayHost(host: string): string {
@@ -22,9 +23,10 @@ export function validateGatewayHost(host: string): string {
22
23
  }
23
24
 
24
25
  if (!VALID_HOST_RE.test(host)) {
25
- // Truncate to 30 chars and strip non-printable bytes before echoing back
26
- const excerpt = host.slice(0, 30).replaceAll(/[^\u0020-\u007E]/g, '?')
27
- throw new Error(`Invalid GATEWAY_HOST: "${excerpt}" — must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`}`)
26
+ // Do NOT echo the value — it may be a secret that was redirected via --key
27
+ throw new Error(
28
+ `Invalid GATEWAY_HOST: value is not a valid hostname (must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`})`,
29
+ )
28
30
  }
29
31
 
30
32
  return host
@@ -481,3 +481,70 @@ describe('gatewayStatusAction — ctx capture (Tier-2)', () => {
481
481
  expect(captured.exit?.code).toBe(1)
482
482
  })
483
483
  })
484
+
485
+ // ─── SSH error redaction — host value must not leak in returned error strings ──
486
+
487
+ describe('getGatewayComposeStatus — SSH error redaction', () => {
488
+ it('does not include the resolved host value in the error when SSH fails', async () => {
489
+ const {getGatewayComposeStatus} = await import('./status')
490
+ const secretLookingHost = 'TOPSECRETHOSTNAME'
491
+
492
+ const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
493
+ const encoder = new TextEncoder()
494
+ return {
495
+ stdout: new ReadableStream({
496
+ start(c) {
497
+ c.enqueue(encoder.encode(''))
498
+ c.close()
499
+ },
500
+ }),
501
+ stderr: new ReadableStream({
502
+ start(c) {
503
+ c.enqueue(encoder.encode(`ssh: Could not resolve hostname ${secretLookingHost}: Name or service not known`))
504
+ c.close()
505
+ },
506
+ }),
507
+ exited: Promise.resolve(255),
508
+ }
509
+ }
510
+
511
+ const result = await getGatewayComposeStatus(secretLookingHost, mockSpawn)
512
+
513
+ expect(result.ok).toBe(false)
514
+ expect(result.error).toBeDefined()
515
+ expect(result.error).not.toContain(secretLookingHost)
516
+ })
517
+
518
+ it('does not leak the host even when OpenSSH lowercases it in stderr', async () => {
519
+ const {getGatewayComposeStatus} = await import('./status')
520
+ // A misdirected secret-shaped value passes the hostname regex and becomes the host;
521
+ // OpenSSH lowercases it in the "Could not resolve hostname" stderr line.
522
+ const secretHost = 'AKIAIOSFODNN7EXAMPLE'
523
+
524
+ const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
525
+ const encoder = new TextEncoder()
526
+ return {
527
+ stdout: new ReadableStream({
528
+ start(c) {
529
+ c.close()
530
+ },
531
+ }),
532
+ stderr: new ReadableStream({
533
+ start(c) {
534
+ c.enqueue(
535
+ encoder.encode(`ssh: Could not resolve hostname ${secretHost.toLowerCase()}: Name or service not known`),
536
+ )
537
+ c.close()
538
+ },
539
+ }),
540
+ exited: Promise.resolve(255),
541
+ }
542
+ }
543
+
544
+ const result = await getGatewayComposeStatus(secretHost, mockSpawn)
545
+
546
+ expect(result.ok).toBe(false)
547
+ expect(result.error).not.toContain(secretHost)
548
+ expect(result.error).not.toContain(secretHost.toLowerCase())
549
+ })
550
+ })
@@ -1,11 +1,10 @@
1
1
  import type {goke} from 'goke'
2
2
  import type {ActionCtx} from '../../lib/action-ctx'
3
3
  import type {StatusSummary} from '../status'
4
-
5
4
  import {z} from 'zod'
6
5
 
6
+ import {redactHost} from '../../lib/redact'
7
7
  // ─── Minimal ctx interface (subset of GokeExecutionContext used by this action) ─
8
-
9
8
  import {validateGatewayHost} from './host'
10
9
 
11
10
  declare const process: {
@@ -128,10 +127,11 @@ export async function getGatewayComposeStatus(
128
127
  ])
129
128
 
130
129
  if (exitCode !== 0) {
130
+ const redacted = redactHost(stderrText.trim(), host) || 'unknown error'
131
131
  return {
132
132
  ok: false,
133
133
  services: [],
134
- error: `SSH command failed (exit ${exitCode}): ${stderrText.trim() || 'unknown error'}`,
134
+ error: `SSH command failed (exit ${exitCode}): ${redacted}`,
135
135
  }
136
136
  }
137
137
 
@@ -140,7 +140,11 @@ export async function getGatewayComposeStatus(
140
140
  try {
141
141
  entries = parseComposePsOutput(stdoutText)
142
142
  } catch {
143
- return {ok: false, services: [], error: `Failed to parse docker compose ps output: ${stdoutText.slice(0, 200)}`}
143
+ return {
144
+ ok: false,
145
+ services: [],
146
+ error: `Failed to parse docker compose ps output: ${redactHost(stdoutText.slice(0, 200), host)}`,
147
+ }
144
148
  }
145
149
 
146
150
  const services = parseComposePs(entries)
@@ -48,7 +48,7 @@ describe('validateUmamiHost', () => {
48
48
  expect(() => validateUmamiHost('')).toThrow('Invalid UMAMI_DOMAIN')
49
49
  })
50
50
 
51
- it('truncates the invalid value in the error message to ~30 chars', () => {
51
+ it('omits the rejected value entirely from the error message', () => {
52
52
  const longMalicious = `-oProxyCommand=${'A'.repeat(100)}`
53
53
  let message = ''
54
54
  try {
@@ -56,7 +56,24 @@ describe('validateUmamiHost', () => {
56
56
  } catch (error) {
57
57
  message = error instanceof Error ? error.message : String(error)
58
58
  }
59
+ // The value is never echoed (it may be a misdirected secret) — only the constraint is shown.
59
60
  expect(message).toContain('Invalid UMAMI_DOMAIN')
60
- expect(message.length).toBeLessThan(longMalicious.length + 50)
61
+ expect(message).not.toContain('ProxyCommand')
62
+ expect(message).not.toContain('AAAA')
63
+ })
64
+
65
+ // ── Secret value redaction ───────────────────────────────────────────────────
66
+
67
+ it('does not echo the rejected value in the error message', () => {
68
+ const secretValue = 'AWS_SECRET_KEY=AKIA1234567890EXAMPLE'
69
+ let message = ''
70
+ try {
71
+ validateUmamiHost(secretValue)
72
+ } catch (error) {
73
+ message = error instanceof Error ? error.message : String(error)
74
+ }
75
+ expect(message).toContain('Invalid UMAMI_DOMAIN')
76
+ expect(message).not.toContain('AKIA1234567890EXAMPLE')
77
+ expect(message).not.toContain('AWS_SECRET_KEY')
61
78
  })
62
79
  })
@@ -13,7 +13,8 @@ const VALID_HOST_RE = /^[a-z\d][a-z\d.\-]*$/i
13
13
  * Rejects: empty strings, values starting with `-`, and anything containing
14
14
  * characters outside `[A-Za-z0-9.-]`.
15
15
  *
16
- * @throws {Error} with a sanitized excerpt of the invalid value.
16
+ * @throws {Error} when empty or not a valid hostname. The rejected value is
17
+ * never included in the message (it may be a misdirected secret).
17
18
  * @returns The validated host string (unchanged).
18
19
  */
19
20
  export function validateUmamiHost(host: string): string {
@@ -22,9 +23,10 @@ export function validateUmamiHost(host: string): string {
22
23
  }
23
24
 
24
25
  if (!VALID_HOST_RE.test(host)) {
25
- // Truncate to 30 chars and strip non-printable bytes before echoing back
26
- const excerpt = host.slice(0, 30).replaceAll(/[^\u0020-\u007E]/g, '?')
27
- throw new Error(`Invalid UMAMI_DOMAIN: "${excerpt}" — must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`}`)
26
+ // Do NOT echo the value — it may be a secret that was redirected via --key
27
+ throw new Error(
28
+ `Invalid UMAMI_DOMAIN: value is not a valid hostname (must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`})`,
29
+ )
28
30
  }
29
31
 
30
32
  return host
@@ -385,3 +385,68 @@ describe('status command', () => {
385
385
  expect(captured.exit).toBeNull()
386
386
  })
387
387
  })
388
+
389
+ // ─── SSH error redaction — host value must not leak in returned error strings ──
390
+
391
+ describe('getUmamiComposeStatus — SSH error redaction', () => {
392
+ it('does not include the resolved host value in the error when SSH fails', async () => {
393
+ const {getUmamiComposeStatus} = await import('./status')
394
+ const secretLookingHost = 'TOPSECRETHOSTNAME'
395
+
396
+ const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
397
+ const encoder = new TextEncoder()
398
+ return {
399
+ stdout: new ReadableStream({
400
+ start(c) {
401
+ c.enqueue(encoder.encode(''))
402
+ c.close()
403
+ },
404
+ }),
405
+ stderr: new ReadableStream({
406
+ start(c) {
407
+ c.enqueue(encoder.encode(`ssh: Could not resolve hostname ${secretLookingHost}: Name or service not known`))
408
+ c.close()
409
+ },
410
+ }),
411
+ exited: Promise.resolve(255),
412
+ }
413
+ }
414
+
415
+ const result = await getUmamiComposeStatus(secretLookingHost, mockSpawn)
416
+
417
+ expect(result.ok).toBe(false)
418
+ expect(result.error).toBeDefined()
419
+ expect(result.error).not.toContain(secretLookingHost)
420
+ })
421
+
422
+ it('does not leak the host even when OpenSSH lowercases it in stderr', async () => {
423
+ const {getUmamiComposeStatus} = await import('./status')
424
+ const secretHost = 'AKIAIOSFODNN7EXAMPLE'
425
+
426
+ const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
427
+ const encoder = new TextEncoder()
428
+ return {
429
+ stdout: new ReadableStream({
430
+ start(c) {
431
+ c.close()
432
+ },
433
+ }),
434
+ stderr: new ReadableStream({
435
+ start(c) {
436
+ c.enqueue(
437
+ encoder.encode(`ssh: Could not resolve hostname ${secretHost.toLowerCase()}: Name or service not known`),
438
+ )
439
+ c.close()
440
+ },
441
+ }),
442
+ exited: Promise.resolve(255),
443
+ }
444
+ }
445
+
446
+ const result = await getUmamiComposeStatus(secretHost, mockSpawn)
447
+
448
+ expect(result.ok).toBe(false)
449
+ expect(result.error).not.toContain(secretHost)
450
+ expect(result.error).not.toContain(secretHost.toLowerCase())
451
+ })
452
+ })
@@ -1,9 +1,9 @@
1
1
  import type {goke} from 'goke'
2
2
  import type {ActionCtx} from '../../lib/action-ctx'
3
3
  import type {StatusSummary} from '../status'
4
-
5
4
  import {z} from 'zod'
6
5
 
6
+ import {redactHost} from '../../lib/redact'
7
7
  import {validateUmamiHost} from './host'
8
8
 
9
9
  declare const process: {
@@ -124,7 +124,7 @@ export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defau
124
124
  return {
125
125
  ok: false,
126
126
  services: [],
127
- error: `SSH command failed (exit ${exitCode}): ${stderrText.trim() || 'unknown error'}`,
127
+ error: `SSH command failed (exit ${exitCode}): ${redactHost(stderrText.trim(), host) || 'unknown error'}`,
128
128
  }
129
129
  }
130
130
 
@@ -133,7 +133,11 @@ export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defau
133
133
  try {
134
134
  entries = parseComposePsOutput(stdoutText)
135
135
  } catch {
136
- return {ok: false, services: [], error: `Failed to parse docker compose ps output: ${stdoutText.slice(0, 200)}`}
136
+ return {
137
+ ok: false,
138
+ services: [],
139
+ error: `Failed to parse docker compose ps output: ${redactHost(stdoutText.slice(0, 200), host)}`,
140
+ }
137
141
  }
138
142
 
139
143
  const services = parseComposePs(entries)
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Redact every occurrence of a host value from text, case-insensitively.
3
+ *
4
+ * Used to scrub a resolved SSH host out of error output before it surfaces to an
5
+ * MCP consumer. The host can be a misdirected secret (an agent may point the
6
+ * `--key` env-var selector at, e.g., `AWS_SECRET_ACCESS_KEY`), so its value must
7
+ * never appear in returned errors.
8
+ *
9
+ * Two subtleties this handles that a plain `replaceAll(host, ...)` does not:
10
+ * 1. OpenSSH lowercases the hostname in "Could not resolve hostname <name>"
11
+ * stderr, so the match must be case-insensitive.
12
+ * 2. Validated hosts contain `.` and `-`, which are regex-significant, so the
13
+ * host is escaped before building the matcher (no over-redaction).
14
+ *
15
+ * An empty/falsy host is a no-op (an empty matcher would match everywhere).
16
+ */
17
+ export function redactHost(text: string, host: string): string {
18
+ if (!host) return text
19
+ const escaped = host.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
20
+ return text.replaceAll(new RegExp(escaped, 'gi'), '<host>')
21
+ }