From 76490bd1ade29424528064170f4ea203f4b9c8e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:13:37 +0000 Subject: [PATCH 1/3] feat: add Grafana Cloud to integration connect Adds grafana to the connect surface: type option in the observability category, --stack-url / --service-account-token flags, an interactive wizard with in-flow token-creation guidance (service account + token under Administration > Users and access > Service accounts, Editor role, glsa_ prefix), and client-side stack URL normalization mirroring the API's. The connect body is cast at the generated-client trust boundary until the prod spec ships the grafana variant. --- src/commands/integration/connect.ts | 81 ++++++++++++++++++++++- test/integration-connect-category.test.ts | 10 +-- test/integration-connect-grafana.test.ts | 48 ++++++++++++++ 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 test/integration-connect-grafana.test.ts diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 3f3cddb..fd2261c 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -46,6 +46,7 @@ type ConnectableType = | 'honeycomb' | 'axiom' | 'betterstack' + | 'grafana' | 'devin' | 'cursor' | 'factory' @@ -67,6 +68,7 @@ const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string; { value: 'honeycomb', label: 'Honeycomb', hint: 'configuration API key', category: 'observability' }, { value: 'axiom', label: 'Axiom', hint: 'API token', category: 'observability' }, { value: 'betterstack', label: 'Better Stack', hint: 'global, Uptime and Telemetry tokens', category: 'observability' }, + { value: 'grafana', label: 'Grafana Cloud', hint: 'stack URL + service account token', category: 'observability' }, { value: 'devin', label: 'Devin', hint: 'API key · coding agent', category: 'code-agent' }, { value: 'cursor', label: 'Cursor', hint: 'API key · coding agent', category: 'code-agent' }, { value: 'factory', label: 'Factory', hint: 'API key · coding agent', category: 'code-agent' }, @@ -161,6 +163,27 @@ export async function connectAxiom( } } +const GRAFANA_STACK_URL_HINT = 'Use the https URL of your Grafana Cloud stack, e.g. https://mystack.grafana.net'; + +// Accepts what people paste — a bare host, a trailing slash, a deep dashboard +// path — and normalizes to the bare https origin, mirroring the API's own +// normalization so the service-accounts link below points at the right host. +// Returns null when the value cannot be a Grafana stack URL. +export function normalizeGrafanaStackUrl(input: string): string | null { + const trimmed = input.trim(); + if (trimmed.length === 0) return null; + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + let url: URL; + try { + url = new URL(withScheme); + } catch { + return null; + } + if (url.protocol !== 'https:') return null; + if (!url.hostname.includes('.')) return null; + return `https://${url.host}`; +} + const CODE_AGENTS = { devin: { name: 'Devin', @@ -665,6 +688,59 @@ async function connectWithCredentials( ]); if (!ok) return BACK; body = { type: 'betterstack', workspaceId, apiToken, uptimeApiToken, telemetryApiToken }; + } else if (type === 'grafana') { + let stackUrl = ''; + let serviceAccountToken = ''; + const ok = await runSteps([ + async () => { + const fromFlag = getArgString(args, 'stackUrl'); + if (fromFlag !== undefined) { + const normalized = normalizeGrafanaStackUrl(fromFlag); + if (normalized === null) { + throw new CLIError(`Invalid value for --stack-url: "${fromFlag}"`, ExitCode.USAGE, GRAFANA_STACK_URL_HINT); + } + stackUrl = normalized; + return SKIPPED; + } + if (!isInteractive(config.nonInteractive)) { + throw new CLIError('Missing required flag: --stack-url', ExitCode.USAGE, GRAFANA_STACK_URL_HINT); + } + const value = await promptTextOrBack( + { nonInteractive: config.nonInteractive }, + 'Grafana stack URL', + { + placeholder: 'https://mystack.grafana.net', + validate: (v: string) => (normalizeGrafanaStackUrl(v) === null ? GRAFANA_STACK_URL_HINT : undefined), + } + ); + if (value === BACK) return BACK; + stackUrl = normalizeGrafanaStackUrl(value)!; + return; + }, + secretStep( + config, + args, + 'serviceAccountToken', + '--service-account-token', + () => ({ + message: 'Grafana service account token', + instructions: + 'In your Grafana stack, open Administration > Users and access > Service accounts. Create a service account with the Editor role (it needs to read dashboards, query datasources and check alerting), then add a token to it. The token starts with glsa_ and is shown only once.', + link: `${stackUrl}/org/serviceaccounts`, + linkLabel: 'Open Grafana service accounts', + }), + (v) => { + serviceAccountToken = v; + } + ), + ]); + if (!ok) return BACK; + // Cast instead of a plain literal: the client is generated from the live + // prod spec, which gains the grafana variant only when the matching API + // deploy lands. Same trust boundary as the honeycomb request-body spread; + // an API build that predates grafana rejects the type with a 400 instead + // of connecting silently, so nothing needs a post-connect assertion. + body = { type: 'grafana', workspaceId, stackUrl, serviceAccountToken } as unknown as ConnectBody; } else if (type === 'linear') { let apiKey = ''; const ok = await runSteps([ @@ -774,7 +850,7 @@ async function connectType( export const integrationConnectCommand: Command = { name: 'integration connect', - description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, Devin, Cursor, Factory, Conductor, Linear, MCP)', + description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, Grafana Cloud, Devin, Cursor, Factory, Conductor, Linear, MCP)', operationId: 'integrations.connect', options: [ { @@ -794,6 +870,8 @@ export const integrationConnectCommand: Command = { { flag: '--management-api-key-id ', description: 'Management API key ID (Honeycomb)', type: 'string' }, { flag: '--management-api-key-secret ', description: 'Management API key secret (Honeycomb)', type: 'string' }, { flag: '--api-token ', description: 'API token (Axiom / Better Stack global token)', type: 'string' }, + { flag: '--stack-url ', description: 'Grafana Cloud stack URL, e.g. https://mystack.grafana.net', type: 'string' }, + { flag: '--service-account-token ', description: 'Service account token (Grafana only, glsa_...)', type: 'string' }, { flag: '--uptime-api-token ', description: 'Uptime API token (Better Stack only)', type: 'string' }, { flag: '--telemetry-api-token ', description: 'Telemetry API token (Better Stack only)', type: 'string' }, { flag: '--url ', description: 'MCP server URL', type: 'string' }, @@ -816,6 +894,7 @@ export const integrationConnectCommand: Command = { 'polylane integration connect --type honeycomb --region us --api-key ... --management-api-key-id ... --management-api-key-secret ...', 'polylane integration connect --type axiom --api-token ...', 'polylane integration connect --type betterstack --api-token ... --uptime-api-token ... --telemetry-api-token ...', + 'polylane integration connect --type grafana --stack-url https://mystack.grafana.net --service-account-token glsa_...', 'polylane integration connect --type cursor --api-key crsr_...', 'polylane integration connect --type linear --api-key lin_api_...', 'polylane integration connect --type mcp --url https://mcp.example.com/sse --name "My MCP"', diff --git a/test/integration-connect-category.test.ts b/test/integration-connect-category.test.ts index eb68d6c..420e21d 100644 --- a/test/integration-connect-category.test.ts +++ b/test/integration-connect-category.test.ts @@ -10,12 +10,12 @@ import { isCLIError } from '../src/errors/base'; describe('typeOptionsForCategory', () => { it('returns every option when no category is given', () => { const all = typeOptionsForCategory(undefined); - assert.equal(all.length, 13); + assert.equal(all.length, 14); }); it('narrows to exactly the observability integrations', () => { const types = typeOptionsForCategory('observability').map((o) => o.value); - assert.deepEqual(types.sort(), ['axiom', 'betterstack', 'datadog', 'honeycomb', 'sentry']); + assert.deepEqual(types.sort(), ['axiom', 'betterstack', 'datadog', 'grafana', 'honeycomb', 'sentry']); }); it('narrows to exactly the code agents', () => { @@ -52,9 +52,9 @@ describe('typeOptionsForCategory', () => { describe('resolveTypeOptions', () => { it('lets --type win over the filter', () => { - assert.equal(resolveTypeOptions('observability', true).length, 13); - assert.equal(resolveTypeOptions('observability', false).length, 5); - assert.equal(resolveTypeOptions(undefined, false).length, 13); + assert.equal(resolveTypeOptions('observability', true).length, 14); + assert.equal(resolveTypeOptions('observability', false).length, 6); + assert.equal(resolveTypeOptions(undefined, false).length, 14); }); it('rejects an unknown category even when --type is present', () => { diff --git a/test/integration-connect-grafana.test.ts b/test/integration-connect-grafana.test.ts new file mode 100644 index 0000000..59cbd57 --- /dev/null +++ b/test/integration-connect-grafana.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeGrafanaStackUrl } from '../src/commands/integration/connect'; + +describe('normalizeGrafanaStackUrl', () => { + it('keeps a clean stack URL as-is', () => { + assert.equal(normalizeGrafanaStackUrl('https://acme.grafana.net'), 'https://acme.grafana.net'); + }); + + it('adds https:// to a bare host', () => { + assert.equal(normalizeGrafanaStackUrl('acme.grafana.net'), 'https://acme.grafana.net'); + }); + + it('strips trailing slashes and deep paths', () => { + assert.equal(normalizeGrafanaStackUrl('https://acme.grafana.net/'), 'https://acme.grafana.net'); + assert.equal( + normalizeGrafanaStackUrl('https://acme.grafana.net/d/abc123/my-dashboard?orgId=1'), + 'https://acme.grafana.net' + ); + }); + + it('trims surrounding whitespace', () => { + assert.equal(normalizeGrafanaStackUrl(' acme.grafana.net \n'), 'https://acme.grafana.net'); + }); + + it('keeps an explicit port for self-hosted Grafana', () => { + assert.equal(normalizeGrafanaStackUrl('https://grafana.example.com:3000/'), 'https://grafana.example.com:3000'); + }); + + it('rejects http URLs', () => { + assert.equal(normalizeGrafanaStackUrl('http://acme.grafana.net'), null); + }); + + it('rejects empty and whitespace-only values', () => { + assert.equal(normalizeGrafanaStackUrl(''), null); + assert.equal(normalizeGrafanaStackUrl(' '), null); + }); + + it('rejects hosts without a dot', () => { + assert.equal(normalizeGrafanaStackUrl('localhost'), null); + assert.equal(normalizeGrafanaStackUrl('https://grafana'), null); + }); + + it('rejects values that do not parse as a URL', () => { + assert.equal(normalizeGrafanaStackUrl('https://'), null); + assert.equal(normalizeGrafanaStackUrl('not a url'), null); + }); +}); From cae532edeca552a8e83ccd2505c04c971b964a99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:24:01 +0000 Subject: [PATCH 2/3] fix: reject private-network hosts in the Grafana stack URL Ports isPrivateGrafanaHost from the API's grafana-client so the CLI enforces the same SSRF invariant: loopback and 0/8 (including hex/integer IPv4 forms the URL parser canonicalizes), RFC1918 ranges, link-local incl. the 169.254.169.254 metadata endpoint, localhost names, and bracketed IPv6 literals are rejected at validation time. Adds rejection tests plus boundary-lookalike accept cases, and notes at the connect-body cast that grafana is not in the generated union yet. --- src/commands/integration/connect.ts | 35 ++++++++++++++++++++---- test/integration-connect-grafana.test.ts | 34 +++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index fd2261c..e4e8b27 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -165,6 +165,30 @@ export async function connectAxiom( const GRAFANA_STACK_URL_HINT = 'Use the https URL of your Grafana Cloud stack, e.g. https://mystack.grafana.net'; +// SSRF defense-in-depth, ported from the API's grafana-client: the stack URL +// drives outbound requests server-side, so hosts that can only point inside a +// private network (loopback, link-local incl. the 169.254.169.254 metadata +// endpoint, RFC1918) are rejected up front. The WHATWG URL parser +// canonicalizes hex/octal/integer IPv4 forms to dotted-quad before this check +// sees them. +function isPrivateGrafanaHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + if (host === 'localhost' || host.endsWith('.localhost')) return true; + // Bracketed IPv6 literals only survive the dotted-hostname check when they + // embed an IPv4 address (e.g. [::ffff:127.0.0.1]); no Grafana stack is + // addressed that way. + if (host.startsWith('[')) return true; + const octets = host.split('.'); + if (octets.length !== 4 || !octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)) return false; + const [a, b] = octets.map(Number); + if (a === 0 || a === 127) return true; + if (a === 10) return true; + if (a === 172 && b! >= 16 && b! <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + return false; +} + // Accepts what people paste — a bare host, a trailing slash, a deep dashboard // path — and normalizes to the bare https origin, mirroring the API's own // normalization so the service-accounts link below points at the right host. @@ -181,6 +205,7 @@ export function normalizeGrafanaStackUrl(input: string): string | null { } if (url.protocol !== 'https:') return null; if (!url.hostname.includes('.')) return null; + if (isPrivateGrafanaHost(url.hostname)) return null; return `https://${url.host}`; } @@ -735,11 +760,11 @@ async function connectWithCredentials( ), ]); if (!ok) return BACK; - // Cast instead of a plain literal: the client is generated from the live - // prod spec, which gains the grafana variant only when the matching API - // deploy lands. Same trust boundary as the honeycomb request-body spread; - // an API build that predates grafana rejects the type with a 400 instead - // of connecting silently, so nothing needs a post-connect assertion. + // grafana is not in the generated connect union yet: the client is built + // from the live prod spec, which gains the variant only when the matching + // API deploy lands. Same trust boundary as the honeycomb request-body + // spread; an API build that predates grafana rejects the type with a 400 + // instead of connecting silently, so nothing needs a post-connect assertion. body = { type: 'grafana', workspaceId, stackUrl, serviceAccountToken } as unknown as ConnectBody; } else if (type === 'linear') { let apiKey = ''; diff --git a/test/integration-connect-grafana.test.ts b/test/integration-connect-grafana.test.ts index 59cbd57..2ef7898 100644 --- a/test/integration-connect-grafana.test.ts +++ b/test/integration-connect-grafana.test.ts @@ -45,4 +45,38 @@ describe('normalizeGrafanaStackUrl', () => { assert.equal(normalizeGrafanaStackUrl('https://'), null); assert.equal(normalizeGrafanaStackUrl('not a url'), null); }); + + it('rejects loopback and 0/8 hosts, including canonicalized IPv4 forms', () => { + assert.equal(normalizeGrafanaStackUrl('https://127.0.0.1'), null); + assert.equal(normalizeGrafanaStackUrl('https://127.0.0.1:3000'), null); + assert.equal(normalizeGrafanaStackUrl('https://0.0.0.0'), null); + // The WHATWG URL parser canonicalizes hex / integer forms to dotted-quad. + assert.equal(normalizeGrafanaStackUrl('https://0x7f000001'), null); + assert.equal(normalizeGrafanaStackUrl('https://2130706433'), null); + }); + + it('rejects RFC1918 private ranges', () => { + assert.equal(normalizeGrafanaStackUrl('https://10.0.0.5'), null); + assert.equal(normalizeGrafanaStackUrl('https://172.16.0.1'), null); + assert.equal(normalizeGrafanaStackUrl('https://172.31.255.255'), null); + assert.equal(normalizeGrafanaStackUrl('https://192.168.1.1'), null); + }); + + it('rejects link-local hosts, including the cloud metadata endpoint', () => { + assert.equal(normalizeGrafanaStackUrl('https://169.254.169.254'), null); + assert.equal(normalizeGrafanaStackUrl('https://169.254.0.1'), null); + }); + + it('rejects localhost names and bracketed IPv6 literals', () => { + assert.equal(normalizeGrafanaStackUrl('https://grafana.localhost'), null); + assert.equal(normalizeGrafanaStackUrl('https://[::1]'), null); + assert.equal(normalizeGrafanaStackUrl('https://[::ffff:127.0.0.1]'), null); + }); + + it('accepts public IPs that only look like private-range boundaries', () => { + assert.equal(normalizeGrafanaStackUrl('https://172.15.0.1'), 'https://172.15.0.1'); + assert.equal(normalizeGrafanaStackUrl('https://172.32.0.1'), 'https://172.32.0.1'); + assert.equal(normalizeGrafanaStackUrl('https://192.169.0.1'), 'https://192.169.0.1'); + assert.equal(normalizeGrafanaStackUrl('https://169.253.0.1'), 'https://169.253.0.1'); + }); }); From dd378f95da3ddba8ceacdc2061da6b0e643aeef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:32:01 +0000 Subject: [PATCH 3/3] test: cover octal IPv4 forms in the private-host rejections --- test/integration-connect-grafana.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/integration-connect-grafana.test.ts b/test/integration-connect-grafana.test.ts index 2ef7898..49550c0 100644 --- a/test/integration-connect-grafana.test.ts +++ b/test/integration-connect-grafana.test.ts @@ -50,9 +50,11 @@ describe('normalizeGrafanaStackUrl', () => { assert.equal(normalizeGrafanaStackUrl('https://127.0.0.1'), null); assert.equal(normalizeGrafanaStackUrl('https://127.0.0.1:3000'), null); assert.equal(normalizeGrafanaStackUrl('https://0.0.0.0'), null); - // The WHATWG URL parser canonicalizes hex / integer forms to dotted-quad. + // The WHATWG URL parser canonicalizes hex / octal / integer forms to dotted-quad. assert.equal(normalizeGrafanaStackUrl('https://0x7f000001'), null); assert.equal(normalizeGrafanaStackUrl('https://2130706433'), null); + assert.equal(normalizeGrafanaStackUrl('https://0177.0.0.1'), null); + assert.equal(normalizeGrafanaStackUrl('https://017700000001'), null); }); it('rejects RFC1918 private ranges', () => {