@marcusrbrown/infra 0.9.8 → 0.9.10
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 +1 -1
- package/src/commands/cliproxy/status.test.ts +122 -0
- package/src/commands/cliproxy/status.ts +10 -1
- package/src/commands/gateway/deploy.test.ts +92 -0
- package/src/commands/gateway/deploy.ts +6 -0
- package/src/commands/gateway/host.test.ts +19 -3
- package/src/commands/gateway/host.ts +6 -4
- package/src/commands/gateway/status.test.ts +67 -0
- package/src/commands/gateway/status.ts +8 -4
- package/src/commands/umami/host.test.ts +19 -2
- package/src/commands/umami/host.ts +6 -4
- package/src/commands/umami/status.test.ts +65 -0
- package/src/commands/umami/status.ts +7 -3
- package/src/lib/redact.ts +21 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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('')
|
|
@@ -13,6 +13,7 @@ const envKeys = [
|
|
|
13
13
|
'DISCORD_GUILD_ID',
|
|
14
14
|
'DISCORD_TOKEN',
|
|
15
15
|
'GATEWAY_HOST',
|
|
16
|
+
'GATEWAY_TRIGGER_ROLE_ID',
|
|
16
17
|
'HOME',
|
|
17
18
|
'OBJECT_STORE_HOSTS',
|
|
18
19
|
'PATH',
|
|
@@ -20,6 +21,11 @@ const envKeys = [
|
|
|
20
21
|
'S3_ENDPOINT',
|
|
21
22
|
'S3_REGION',
|
|
22
23
|
'SSH_AUTH_SOCK',
|
|
24
|
+
'WORKSPACE_OPENCODE_AUTH',
|
|
25
|
+
'WORKSPACE_OPENCODE_CONFIG',
|
|
26
|
+
'WORKSPACE_OPENCODE_MODEL',
|
|
27
|
+
'WORKSPACE_OPENCODE_TOKEN',
|
|
28
|
+
'WORKSPACE_OPENCODE_URL',
|
|
23
29
|
] as const
|
|
24
30
|
|
|
25
31
|
type ManagedEnvKey = (typeof envKeys)[number]
|
|
@@ -269,6 +275,92 @@ describe('gateway deploy', () => {
|
|
|
269
275
|
const env = getGatewayDeployEnv()
|
|
270
276
|
expect(env.DISCORD_PRIVILEGED_INTENTS).toBe('')
|
|
271
277
|
})
|
|
278
|
+
|
|
279
|
+
// ── workspace mention-loop var parity ─────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
it('forwards WORKSPACE_OPENCODE_TOKEN when set', () => {
|
|
282
|
+
setManagedEnv({
|
|
283
|
+
GATEWAY_HOST: 'gateway.example.com',
|
|
284
|
+
HOME: '/tmp/test-home',
|
|
285
|
+
PATH: '/usr/bin:/bin',
|
|
286
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
287
|
+
WORKSPACE_OPENCODE_TOKEN: 'ws-tok-123',
|
|
288
|
+
})
|
|
289
|
+
const env = getGatewayDeployEnv()
|
|
290
|
+
expect(env.WORKSPACE_OPENCODE_TOKEN).toBe('ws-tok-123')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('forwards WORKSPACE_OPENCODE_AUTH when set', () => {
|
|
294
|
+
setManagedEnv({
|
|
295
|
+
GATEWAY_HOST: 'gateway.example.com',
|
|
296
|
+
HOME: '/tmp/test-home',
|
|
297
|
+
PATH: '/usr/bin:/bin',
|
|
298
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
299
|
+
WORKSPACE_OPENCODE_AUTH: '{"provider":{}}',
|
|
300
|
+
})
|
|
301
|
+
const env = getGatewayDeployEnv()
|
|
302
|
+
expect(env.WORKSPACE_OPENCODE_AUTH).toBe('{"provider":{}}')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('forwards WORKSPACE_OPENCODE_URL when set', () => {
|
|
306
|
+
setManagedEnv({
|
|
307
|
+
GATEWAY_HOST: 'gateway.example.com',
|
|
308
|
+
HOME: '/tmp/test-home',
|
|
309
|
+
PATH: '/usr/bin:/bin',
|
|
310
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
311
|
+
WORKSPACE_OPENCODE_URL: 'http://workspace:9200',
|
|
312
|
+
})
|
|
313
|
+
const env = getGatewayDeployEnv()
|
|
314
|
+
expect(env.WORKSPACE_OPENCODE_URL).toBe('http://workspace:9200')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('WORKSPACE_OPENCODE_URL unset → forwarded as empty string (optional)', () => {
|
|
318
|
+
setManagedEnv({
|
|
319
|
+
GATEWAY_HOST: 'gateway.example.com',
|
|
320
|
+
HOME: '/tmp/test-home',
|
|
321
|
+
PATH: '/usr/bin:/bin',
|
|
322
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
323
|
+
WORKSPACE_OPENCODE_URL: undefined,
|
|
324
|
+
})
|
|
325
|
+
const env = getGatewayDeployEnv()
|
|
326
|
+
expect(env.WORKSPACE_OPENCODE_URL).toBe('')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('forwards WORKSPACE_OPENCODE_MODEL when set', () => {
|
|
330
|
+
setManagedEnv({
|
|
331
|
+
GATEWAY_HOST: 'gateway.example.com',
|
|
332
|
+
HOME: '/tmp/test-home',
|
|
333
|
+
PATH: '/usr/bin:/bin',
|
|
334
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
335
|
+
WORKSPACE_OPENCODE_MODEL: 'anthropic/claude-sonnet-4-6',
|
|
336
|
+
})
|
|
337
|
+
const env = getGatewayDeployEnv()
|
|
338
|
+
expect(env.WORKSPACE_OPENCODE_MODEL).toBe('anthropic/claude-sonnet-4-6')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('forwards WORKSPACE_OPENCODE_CONFIG when set', () => {
|
|
342
|
+
setManagedEnv({
|
|
343
|
+
GATEWAY_HOST: 'gateway.example.com',
|
|
344
|
+
HOME: '/tmp/test-home',
|
|
345
|
+
PATH: '/usr/bin:/bin',
|
|
346
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
347
|
+
WORKSPACE_OPENCODE_CONFIG: '{"provider":{}}',
|
|
348
|
+
})
|
|
349
|
+
const env = getGatewayDeployEnv()
|
|
350
|
+
expect(env.WORKSPACE_OPENCODE_CONFIG).toBe('{"provider":{}}')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('forwards GATEWAY_TRIGGER_ROLE_ID when set', () => {
|
|
354
|
+
setManagedEnv({
|
|
355
|
+
GATEWAY_HOST: 'gateway.example.com',
|
|
356
|
+
HOME: '/tmp/test-home',
|
|
357
|
+
PATH: '/usr/bin:/bin',
|
|
358
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
359
|
+
GATEWAY_TRIGGER_ROLE_ID: '987654321098765432',
|
|
360
|
+
})
|
|
361
|
+
const env = getGatewayDeployEnv()
|
|
362
|
+
expect(env.GATEWAY_TRIGGER_ROLE_ID).toBe('987654321098765432')
|
|
363
|
+
})
|
|
272
364
|
})
|
|
273
365
|
|
|
274
366
|
describe('validateGatewayRemotePreconditions', () => {
|
|
@@ -50,6 +50,12 @@ export function getGatewayDeployEnv(): Record<string, string> {
|
|
|
50
50
|
GH_APP_ID: process.env.GH_APP_ID ?? '',
|
|
51
51
|
GH_APP_PRIVATE_KEY: process.env.GH_APP_PRIVATE_KEY ?? '',
|
|
52
52
|
DISCORD_PRIVILEGED_INTENTS: process.env.DISCORD_PRIVILEGED_INTENTS ?? '',
|
|
53
|
+
WORKSPACE_OPENCODE_TOKEN: process.env.WORKSPACE_OPENCODE_TOKEN ?? '',
|
|
54
|
+
WORKSPACE_OPENCODE_AUTH: process.env.WORKSPACE_OPENCODE_AUTH ?? '',
|
|
55
|
+
WORKSPACE_OPENCODE_URL: process.env.WORKSPACE_OPENCODE_URL ?? '',
|
|
56
|
+
WORKSPACE_OPENCODE_MODEL: process.env.WORKSPACE_OPENCODE_MODEL ?? '',
|
|
57
|
+
WORKSPACE_OPENCODE_CONFIG: process.env.WORKSPACE_OPENCODE_CONFIG ?? '',
|
|
58
|
+
GATEWAY_TRIGGER_ROLE_ID: process.env.GATEWAY_TRIGGER_ROLE_ID ?? '',
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
|
|
@@ -58,7 +58,7 @@ describe('validateGatewayHost', () => {
|
|
|
58
58
|
|
|
59
59
|
// ── Error message sanitization ──────────────────────────────────────────────
|
|
60
60
|
|
|
61
|
-
it('
|
|
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
|
|
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
|
|
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}
|
|
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
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
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}): ${
|
|
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 {
|
|
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('
|
|
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
|
|
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}
|
|
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
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
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 {
|
|
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
|
+
}
|