Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion src/commands/integration/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type ConnectableType =
| 'honeycomb'
| 'axiom'
| 'betterstack'
| 'grafana'
| 'devin'
| 'cursor'
| 'factory'
Expand All @@ -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' },
Expand Down Expand Up @@ -161,6 +163,52 @@ 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.
// 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;
if (isPrivateGrafanaHost(url.hostname)) return null;
return `https://${url.host}`;
Comment thread
claude[bot] marked this conversation as resolved.
}

const CODE_AGENTS = {
devin: {
name: 'Devin',
Expand Down Expand Up @@ -665,6 +713,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;
// 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 = '';
const ok = await runSteps([
Expand Down Expand Up @@ -774,7 +875,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: [
{
Expand All @@ -794,6 +895,8 @@ export const integrationConnectCommand: Command = {
{ flag: '--management-api-key-id <id>', description: 'Management API key ID (Honeycomb)', type: 'string' },
{ flag: '--management-api-key-secret <secret>', description: 'Management API key secret (Honeycomb)', type: 'string' },
{ flag: '--api-token <token>', description: 'API token (Axiom / Better Stack global token)', type: 'string' },
{ flag: '--stack-url <url>', description: 'Grafana Cloud stack URL, e.g. https://mystack.grafana.net', type: 'string' },
{ flag: '--service-account-token <token>', description: 'Service account token (Grafana only, glsa_...)', type: 'string' },
{ flag: '--uptime-api-token <token>', description: 'Uptime API token (Better Stack only)', type: 'string' },
{ flag: '--telemetry-api-token <token>', description: 'Telemetry API token (Better Stack only)', type: 'string' },
{ flag: '--url <url>', description: 'MCP server URL', type: 'string' },
Expand All @@ -816,6 +919,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"',
Expand Down
10 changes: 5 additions & 5 deletions test/integration-connect-category.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
84 changes: 84 additions & 0 deletions test/integration-connect-grafana.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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);
});

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 / octal / integer forms to dotted-quad.
assert.equal(normalizeGrafanaStackUrl('https://0x7f000001'), null);
assert.equal(normalizeGrafanaStackUrl('https://2130706433'), null);
Comment thread
claude[bot] marked this conversation as resolved.
assert.equal(normalizeGrafanaStackUrl('https://0177.0.0.1'), null);
assert.equal(normalizeGrafanaStackUrl('https://017700000001'), 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');
});
});
Loading