diff --git a/src/lib/__tests__/loki.test.ts b/src/lib/__tests__/loki.test.ts index 0ac323f3..8fb335d7 100644 --- a/src/lib/__tests__/loki.test.ts +++ b/src/lib/__tests__/loki.test.ts @@ -134,6 +134,16 @@ describe('validateNamespaceLockdown', () => { expect(validateNamespaceLockdown('{namespace=`prod`} |= "error"', 'prod')).toBe(true); }); + it('accepts a single-quoted namespace matcher value', () => { + expect(validateNamespaceLockdown("{namespace='prod'} |= \"error\"", 'prod')).toBe(true); + }); + + it('rejects a mismatched selector even when a single-quoted line filter contains the locked namespace text', () => { + expect( + validateNamespaceLockdown("{namespace=\"evil\"} |= 'namespace=\"prod\"'", 'prod'), + ).toBe(false); + }); + it('rejects a negated namespace matcher even when the value matches', () => { expect(validateNamespaceLockdown('{namespace!="prod"} |= "error"', 'prod')).toBe(false); expect(validateNamespaceLockdown('{namespace!~"prod"} |= "error"', 'prod')).toBe(false); diff --git a/src/lib/__tests__/prometheus.test.ts b/src/lib/__tests__/prometheus.test.ts index 6145b5a7..314736b0 100644 --- a/src/lib/__tests__/prometheus.test.ts +++ b/src/lib/__tests__/prometheus.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { runPrometheusQuery } from '../prometheus.ts'; +import { runPrometheusQuery, validateNamespaceLockdown } from '../prometheus.ts'; import type { PrometheusConfig } from '../prometheus.ts'; import { mockFetch, makeAbortError, mockFetchHangsUntilAbort, restoreGlobalsAfterEach } from './test-helpers.ts'; @@ -120,6 +120,109 @@ describe('runPrometheusQuery — range', () => { }); }); +// --------------------------------------------------------------------------- +// Namespace lockdown +// --------------------------------------------------------------------------- +// +// validateNamespaceLockdown layers a PromQL-specific bare-metric-reference +// scan on top of the shared selector parser in selector-lockdown.ts (see +// loki.test.ts for exhaustive selector/quote-parsing edge cases); these cover +// PromQL-shaped selectors, the bare-reference gap, and runPrometheusQuery +// integration. + +describe('validateNamespaceLockdown — PromQL selectors', () => { + it('accepts a vector selector with an exact namespace matcher', () => { + expect(validateNamespaceLockdown('up{namespace="prod"}', 'prod')).toBe(true); + }); + + it('accepts a single-quoted namespace matcher (PromQL supports single-quoted strings)', () => { + expect(validateNamespaceLockdown("up{namespace='prod'}", 'prod')).toBe(true); + }); + + it('rejects a bare metric name with no selector', () => { + expect(validateNamespaceLockdown('up', 'prod')).toBe(false); + }); + + it('rejects a selector targeting a different namespace', () => { + expect(validateNamespaceLockdown('up{namespace="staging"}', 'prod')).toBe(false); + }); + + it('requires every selector in a multi-selector expression to match', () => { + const query = 'sum(rate(http_requests_total{namespace="prod"}[5m])) / sum(rate(http_requests_total{namespace="evil"}[5m]))'; + expect(validateNamespaceLockdown(query, 'prod')).toBe(false); + }); + + it('accepts a histogram_quantile query using a "by (le)" aggregation modifier', () => { + // Regression: `le` here is a label name in the `by (...)` grouping clause, + // not a bare metric reference, and must not be flagged. + const query = 'histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{namespace="prod"}[5m])) by (le))'; + expect(validateNamespaceLockdown(query, 'prod')).toBe(true); + }); + + it('rejects a bare metric name combined with a locked selector via a binary operator', () => { + // Regression for a real bypass: the extracted selector matches, but the + // bare `up` term has no selector at all and would read every namespace. + expect(validateNamespaceLockdown('up{namespace="prod"} + up', 'prod')).toBe(false); + }); + + it('rejects a bare metric name combined with a locked selector via a set operator', () => { + expect( + validateNamespaceLockdown('container_memory_usage_bytes or kube_pod_info{namespace="prod"}', 'prod'), + ).toBe(false); + }); + + it('rejects a bare range-vector reference alongside a properly scoped one', () => { + expect(validateNamespaceLockdown('rate(up{namespace="prod"}[5m]) + rate(up[5m])', 'prod')).toBe(false); + }); + + it('accepts a bare-looking metric name that is entirely covered by its own selector', () => { + expect(validateNamespaceLockdown('sum(rate(a{namespace="prod"}[5m])) / sum(rate(b{namespace="prod"}[5m]))', 'prod')).toBe(true); + }); +}); + +describe('runPrometheusQuery — namespace lockdown', () => { + const LOCKED_CONFIG: PrometheusConfig = { ...BASE_CONFIG, lockedNamespace: 'prod' }; + + it('allows queries whose selector includes the locked namespace', async () => { + const fetchMock = mockFetch('{}'); + + const result = await runPrometheusQuery('instant', { query: 'up{namespace="prod"}' }, LOCKED_CONFIG); + + expect(result).not.toMatch(/BLOCKED/); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('blocks queries with no selector', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await runPrometheusQuery('instant', { query: 'up' }, LOCKED_CONFIG); + + expect(result).toMatch(/BLOCKED/); + expect(result).toContain('prod'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('blocks queries targeting a different namespace', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await runPrometheusQuery('instant', { query: 'up{namespace="staging"}' }, LOCKED_CONFIG); + + expect(result).toMatch(/BLOCKED/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not enforce lockdown when lockedNamespace is unset', async () => { + const fetchMock = mockFetch('{}'); + + const result = await runPrometheusQuery('instant', { query: 'up' }, BASE_CONFIG); + + expect(result).not.toMatch(/BLOCKED/); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +}); + // --------------------------------------------------------------------------- // HTTP errors // --------------------------------------------------------------------------- diff --git a/src/lib/loki.ts b/src/lib/loki.ts index 9f3b1184..b1232967 100644 --- a/src/lib/loki.ts +++ b/src/lib/loki.ts @@ -11,6 +11,7 @@ import { resolveTimePassthrough } from './time-resolution.ts'; import { runJsonQuery } from './http.ts'; import { clampLimit } from './tool-config.ts'; import { BLOCKED_PREFIX } from './harness.ts'; +import { validateNamespaceSelectorLockdown } from './selector-lockdown.ts'; export interface LokiConfig { url: string; @@ -31,233 +32,14 @@ const truncate = makeTruncate(MAX_RESULT_CHARS, 'use a narrower time range, smal */ export const resolveTime = resolveTimePassthrough; -/** - * Strip LogQL `#`-to-end-of-line comments (outside quoted/backtick strings). - * Loki ignores commented text when executing a query, so a decoy stream - * selector hidden in a comment must not be mistaken for the real one. - */ -function stripLogQLComments(query: string): string { - let out = ''; - let i = 0; - const n = query.length; - while (i < n) { - const ch = query[i]; - if (ch === '"') { - out += ch; - i++; - while (i < n && query[i] !== '"') { - if (query[i] === '\\' && i + 1 < n) { - out += query[i] + query[i + 1]; - i += 2; - } else { - out += query[i]; - i++; - } - } - if (i < n) { - out += query[i]; - i++; - } - continue; - } - if (ch === '`') { - out += ch; - i++; - while (i < n && query[i] !== '`') { - out += query[i]; - i++; - } - if (i < n) { - out += query[i]; - i++; - } - continue; - } - if (ch === '#') { - while (i < n && query[i] !== '\n') i++; - continue; - } - out += ch; - i++; - } - return out; -} - -/** - * Find the index of the `}` that closes the brace opened at `start` in - * `query[start]`. LogQL string literals (double-quoted, with backslash - * escapes, or backtick-delimited raw strings) are skipped as opaque spans so - * that a brace or quote inside a label value can't be mistaken for the real - * boundary. Returns -1 if unterminated. - */ -function findMatchingBrace(query: string, start: number): number { - let depth = 0; - let i = start; - while (i < query.length) { - const ch = query[i]; - if (ch === '"') { - i++; - while (i < query.length && query[i] !== '"') { - i += query[i] === '\\' ? 2 : 1; - } - i++; - continue; - } - if (ch === '`') { - i++; - while (i < query.length && query[i] !== '`') i++; - i++; - continue; - } - if (ch === '{') depth++; - else if (ch === '}') { - depth--; - if (depth === 0) return i; - } - i++; - } - return -1; -} - -/** - * Extract every LogQL stream selector — each top-level `{...}` brace group — - * from a query. LogQL metric queries can combine more than one selector via - * binary operators (e.g. `sum(rate({a}[5m])) / sum(rate({b}[5m]))`), so a - * namespace lockdown must check all of them, not just the first. Returns null - * if any selector is unterminated. - */ -function extractAllStreamSelectors(query: string): string[] | null { - const selectors: string[] = []; - let i = 0; - while (i < query.length) { - const ch = query[i]; - if (ch === '"') { - i++; - while (i < query.length && query[i] !== '"') { - i += query[i] === '\\' ? 2 : 1; - } - i++; - continue; - } - if (ch === '`') { - i++; - while (i < query.length && query[i] !== '`') i++; - i++; - continue; - } - if (ch === '{') { - const end = findMatchingBrace(query, i); - if (end === -1) return null; - selectors.push(query.slice(i, end + 1)); - i = end + 1; - continue; - } - i++; - } - return selectors; -} - -interface LabelMatcher { - label: string; - op: string; - value: string; -} - -const MATCHER_OPS = ['=~', '!~', '!=', '=']; - -/** - * Parse a `{...}` LogQL stream selector into its label matchers. Returns null - * on anything that doesn't parse as a well-formed matcher list — callers - * should fail closed (reject) rather than guess at a malformed selector. - */ -function parseSelectorMatchers(selector: string): LabelMatcher[] | null { - const inner = selector.slice(1, -1); - const matchers: LabelMatcher[] = []; - let i = 0; - const n = inner.length; - while (i < n) { - while (i < n && /[\s,]/.test(inner[i])) i++; - if (i >= n) break; - - const labelStart = i; - while (i < n && /[A-Za-z0-9_]/.test(inner[i])) i++; - const label = inner.slice(labelStart, i); - if (!label) return null; - - while (i < n && /\s/.test(inner[i])) i++; - const op = MATCHER_OPS.find((candidate) => inner.startsWith(candidate, i)); - if (!op) return null; - i += op.length; - while (i < n && /\s/.test(inner[i])) i++; - - const quote = inner[i]; - if (quote !== '"' && quote !== '`') return null; - i++; - let value = ''; - if (quote === '"') { - while (i < n && inner[i] !== '"') { - if (inner[i] === '\\' && i + 1 < n) { - // Only `\"` and `\\` decode to themselves under this naive scan. - // Any other escape (`\n`, `p`, octal, ...) needs real LogQL - // string-literal decoding to get the right value — since we don't - // implement that, fail closed rather than silently mis-decoding - // (e.g. treating `prod` as literal "u0070rod" instead of the - // "prod" Loki would actually parse it as). - if (inner[i + 1] !== '"' && inner[i + 1] !== '\\') return null; - value += inner[i + 1]; - i += 2; - } else { - value += inner[i]; - i++; - } - } - } else { - while (i < n && inner[i] !== '`') { - value += inner[i]; - i++; - } - } - if (i >= n) return null; // unterminated value - i++; // consume closing quote/backtick - - matchers.push({ label, op, value }); - } - return matchers; -} - /** * Check that every LogQL stream selector in a query contains an exact - * namespace matcher for the locked namespace. Accepts namespace="" or - * namespace=~"" (treated as an exact-string match, not a real regex - * evaluation), rejecting anything else — a different value, a wildcard - * regex, negated operators (!=, !~), or no namespace matcher at all. - * - * Metric queries can combine multiple selectors via binary operators (e.g. - * `sum(rate({a}[5m])) / sum(rate({b}[5m]))`), so every selector found must - * pass — checking only the first would let a later selector read an - * unlocked namespace while the whole query is forwarded to Loki unchanged. - * A query with no selector at all is rejected (fail closed). - * - * Each selector is fully parsed into label/operator/value matchers rather - * than regex-matched as raw text: a naive substring/regex check over the - * selector text can be spoofed by decoy text inside another matcher's - * backtick-quoted (unescaped) value — e.g. `app=~\`namespace="prod"\`` — while - * the real namespace matcher targets something else entirely. Parsing each - * matcher's value independently closes that off. `#`-comments are stripped - * first so a decoy selector hidden in a comment (which Loki itself ignores) - * can't be validated in place of the real, executed selector. + * namespace matcher for the locked namespace. See + * {@link validateNamespaceSelectorLockdown} in `selector-lockdown.ts` for the + * full semantics (shared with the `prometheus_query` tool, since PromQL and + * LogQL selectors share the same `{label="value"}` grammar). */ -export function validateNamespaceLockdown(query: string, lockedNamespace: string): boolean { - const selectors = extractAllStreamSelectors(stripLogQLComments(query)); - if (selectors === null || selectors.length === 0) return false; - return selectors.every((selector) => { - const matchers = parseSelectorMatchers(selector); - if (matchers === null) return false; - return matchers.some( - (m) => m.label === 'namespace' && (m.op === '=' || m.op === '=~') && m.value === lockedNamespace, - ); - }); -} +export const validateNamespaceLockdown = validateNamespaceSelectorLockdown; export interface LokiQueryParams { query: string; diff --git a/src/lib/prometheus.ts b/src/lib/prometheus.ts index 65cbda6d..c10c2758 100644 --- a/src/lib/prometheus.ts +++ b/src/lib/prometheus.ts @@ -8,12 +8,143 @@ import type { CompiledRedactionRule } from './regex-redact.ts'; import { makeTruncate } from './output-truncation.ts'; import { runJsonQuery } from './http.ts'; +import { BLOCKED_PREFIX } from './harness.ts'; +import { findMatchingDelimiter, validateNamespaceSelectorLockdown } from './selector-lockdown.ts'; export interface PrometheusConfig { url: string; timeoutMs: number; /** User-configured regex redaction rules compiled at startup. */ regexRedactionRules?: CompiledRedactionRule[]; + /** When set, all queries are rejected unless every vector selector includes namespace="". */ + lockedNamespace?: string; +} + +/** + * PromQL aggregation operators, functions, and keywords that are never + * themselves a metric-name reference — an identifier matching one of these + * never fetches unscoped data on its own, regardless of what follows it. + * https://prometheus.io/docs/prometheus/latest/querying/operators/ + * https://prometheus.io/docs/prometheus/latest/querying/functions/ + */ +const PROMQL_SAFE_IDENTIFIERS = new Set([ + // Aggregation operators + 'sum', 'min', 'max', 'avg', 'group', 'stddev', 'stdvar', 'count', + 'count_values', 'bottomk', 'topk', 'quantile', 'limitk', 'limit_ratio', + // Vector-matching / aggregation modifiers and binary-operator keywords + 'by', 'without', 'on', 'ignoring', 'group_left', 'group_right', 'bool', 'offset', + 'and', 'or', 'unless', 'atan2', + // Functions + 'abs', 'absent', 'absent_over_time', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atanh', + 'avg_over_time', 'ceil', 'changes', 'clamp', 'clamp_max', 'clamp_min', 'cos', 'cosh', + 'count_over_time', 'days_in_month', 'day_of_month', 'day_of_week', 'day_of_year', + 'delta', 'deriv', 'end', 'exp', 'floor', 'histogram_avg', 'histogram_count', + 'histogram_fraction', 'histogram_quantile', 'histogram_stddev', 'histogram_stdvar', + 'histogram_sum', 'holt_winters', 'hour', 'idelta', 'increase', 'info', 'irate', + 'label_join', 'label_replace', 'last_over_time', 'ln', 'log2', 'log10', 'mad_over_time', + 'max_over_time', 'min_over_time', 'minute', 'month', 'predict_linear', 'present_over_time', + 'quantile_over_time', 'rate', 'resets', 'round', 'scalar', 'sgn', 'sin', 'sinh', 'sort', + 'sort_desc', 'sort_by_label', 'sort_by_label_desc', 'sqrt', 'start', 'stddev_over_time', + 'stdvar_over_time', 'sum_over_time', 'tan', 'tanh', 'time', 'timestamp', 'vector', 'year', +]); + +/** Modifiers whose parenthesized argument is a label list, never a metric reference. */ +const LABEL_LIST_KEYWORDS = new Set(['by', 'without', 'on', 'ignoring', 'group_left', 'group_right']); + +/** + * Detect a bare PromQL metric-name reference: an identifier that is not a + * recognized aggregation/function/keyword and is not immediately (optionally + * across whitespace) followed by a `{...}` selector. + * + * `validateNamespaceSelectorLockdown` only inspects the `{...}` selectors it + * can find — it has no notion of PromQL grammar. A query can smuggle + * unscoped data past it by combining one namespace-scoped selector with a + * second, bare metric reference via a binary operator or set operator (e.g. + * `up{namespace="prod"} + up`, `container_memory_usage_bytes or + * kube_pod_info{namespace="prod"}`): the bare term has no selector to check, + * so it's invisible to that validator even though Prometheus executes it + * unscoped. This scan closes that gap by requiring every metric reference in + * the query to carry a selector, fail-closed on anything it can't place + * (unterminated brackets, an identifier it can't classify). + * + * Numbers and durations (`5m`, `1h30m`, `3.14`) can never start a PromQL + * identifier, so any digit-led alphanumeric run is skipped as one opaque + * token rather than misread as a trailing unit-suffix identifier (e.g. the + * `m` in `5m`). `[...]` range-vector/subquery brackets are skipped entirely + * for the same reason `{...}` selectors are — their contents are never a + * bare metric reference. + */ +function hasBareMetricReference(query: string): boolean { + let i = 0; + const n = query.length; + while (i < n) { + const ch = query[i]; + if (ch === '"' || ch === "'") { + const quote = ch; + i++; + while (i < n && query[i] !== quote) { + i += query[i] === '\\' ? 2 : 1; + } + i++; + continue; + } + if (ch === '`') { + i++; + while (i < n && query[i] !== '`') i++; + i++; + continue; + } + if (ch === '{') { + const end = findMatchingDelimiter(query, i, '{', '}'); + if (end === -1) return true; // unterminated selector — fail closed + i = end + 1; + continue; + } + if (ch === '[') { + const end = findMatchingDelimiter(query, i, '[', ']'); + if (end === -1) return true; + i = end + 1; + continue; + } + if (/[0-9]/.test(ch)) { + while (i < n && /[0-9a-zA-Z.]/.test(query[i])) i++; + continue; + } + if (/[A-Za-z_:]/.test(ch)) { + const start = i; + while (i < n && /[A-Za-z0-9_:]/.test(query[i])) i++; + const identifier = query.slice(start, i); + + let j = i; + while (j < n && /\s/.test(query[j])) j++; + + if (LABEL_LIST_KEYWORDS.has(identifier) && query[j] === '(') { + const end = findMatchingDelimiter(query, j, '(', ')'); + if (end === -1) return true; + i = end + 1; + continue; + } + if (PROMQL_SAFE_IDENTIFIERS.has(identifier)) continue; + if (query[j] === '{') continue; // has its own selector, validated separately + + return true; // bare metric-name reference with no selector + } + i++; + } + return false; +} + +/** + * Check that every metric reference in a PromQL query carries a selector + * with an exact namespace matcher for the locked namespace. Combines + * {@link validateNamespaceSelectorLockdown} (every `{...}` selector found + * must match) with {@link hasBareMetricReference} (no metric reference may + * lack a selector entirely) — the first alone is insufficient for PromQL + * because, unlike LogQL, a bare metric name with no braces is itself a valid, + * unscoped vector selector. + */ +export function validateNamespaceLockdown(query: string, lockedNamespace: string): boolean { + return validateNamespaceSelectorLockdown(query, lockedNamespace) && !hasBareMetricReference(query); } const MAX_RESULT_CHARS = 20_000; @@ -44,6 +175,18 @@ export async function runPrometheusQuery( if (!params.step) return 'Error: range queries require a step parameter (e.g. "15s", "1m").'; } + // Namespace lockdown: code-enforced when config.lockedNamespace is set. + // The PromQL query must contain an exact namespace="" selector on + // every vector selector — any selector that could match other namespaces + // (or the absence of a selector) is rejected. + if (config.lockedNamespace && !validateNamespaceLockdown(params.query, config.lockedNamespace)) { + return ( + `${BLOCKED_PREFIX}namespace lockdown is active — queries must include ` + + `namespace="${config.lockedNamespace}" in every vector selector. ` + + `Example: 'up{namespace="${config.lockedNamespace}"}'` + ); + } + const endpoint = queryType === 'instant' ? '/api/v1/query' : '/api/v1/query_range'; return runJsonQuery(config, endpoint, 'Prometheus', truncate, (searchParams) => { diff --git a/src/lib/selector-lockdown.ts b/src/lib/selector-lockdown.ts new file mode 100644 index 00000000..ee67ad21 --- /dev/null +++ b/src/lib/selector-lockdown.ts @@ -0,0 +1,234 @@ +/** + * Namespace-lockdown enforcement for query languages built on Prometheus-style + * label selectors — `{label="value", label2=~"value2"}` — which both PromQL + * and LogQL use for their stream/vector selectors. Shared by `loki.ts` and + * `prometheus.ts` so the same hardened parser backs both tools' lockdown checks. + */ + +/** + * Strip `#`-to-end-of-line comments (outside quoted/backtick strings). Some + * query languages built on this selector syntax (e.g. LogQL) ignore commented + * text when executing a query, so a decoy selector hidden in a comment must + * not be mistaken for the real one. + */ +function stripHashComments(query: string): string { + let out = ''; + let i = 0; + const n = query.length; + while (i < n) { + const ch = query[i]; + if (ch === '"' || ch === "'") { + const quote = ch; + out += ch; + i++; + while (i < n && query[i] !== quote) { + if (query[i] === '\\' && i + 1 < n) { + out += query[i] + query[i + 1]; + i += 2; + } else { + out += query[i]; + i++; + } + } + if (i < n) { + out += query[i]; + i++; + } + continue; + } + if (ch === '`') { + out += ch; + i++; + while (i < n && query[i] !== '`') { + out += query[i]; + i++; + } + if (i < n) { + out += query[i]; + i++; + } + continue; + } + if (ch === '#') { + while (i < n && query[i] !== '\n') i++; + continue; + } + out += ch; + i++; + } + return out; +} + +/** + * Find the index of the `close` delimiter that matches the `open` delimiter + * at `query[start]` (e.g. `{`/`}`, `[`/`]`, `(`/`)`). String literals + * (double-quoted, with backslash escapes, or backtick-delimited raw strings) + * are skipped as opaque spans so that a delimiter or quote inside a label + * value can't be mistaken for the real boundary. Returns -1 if unterminated. + */ +export function findMatchingDelimiter(query: string, start: number, open: string, close: string): number { + let depth = 0; + let i = start; + while (i < query.length) { + const ch = query[i]; + if (ch === '"' || ch === "'") { + const quote = ch; + i++; + while (i < query.length && query[i] !== quote) { + i += query[i] === '\\' ? 2 : 1; + } + i++; + continue; + } + if (ch === '`') { + i++; + while (i < query.length && query[i] !== '`') i++; + i++; + continue; + } + if (ch === open) depth++; + else if (ch === close) { + depth--; + if (depth === 0) return i; + } + i++; + } + return -1; +} + +/** + * Extract every selector — each top-level `{...}` brace group — from a + * query. Queries can combine more than one selector via binary/aggregation + * operators (e.g. `sum(rate({a}[5m])) / sum(rate({b}[5m]))`), so a namespace + * lockdown must check all of them, not just the first. Returns null if any + * selector is unterminated. + */ +function extractAllSelectors(query: string): string[] | null { + const selectors: string[] = []; + let i = 0; + while (i < query.length) { + const ch = query[i]; + if (ch === '"' || ch === "'") { + const quote = ch; + i++; + while (i < query.length && query[i] !== quote) { + i += query[i] === '\\' ? 2 : 1; + } + i++; + continue; + } + if (ch === '`') { + i++; + while (i < query.length && query[i] !== '`') i++; + i++; + continue; + } + if (ch === '{') { + const end = findMatchingDelimiter(query, i, '{', '}'); + if (end === -1) return null; + selectors.push(query.slice(i, end + 1)); + i = end + 1; + continue; + } + i++; + } + return selectors; +} + +interface LabelMatcher { + label: string; + op: string; + value: string; +} + +const MATCHER_OPS = ['=~', '!~', '!=', '=']; + +/** + * Parse a `{...}` selector into its label matchers. Returns null on anything + * that doesn't parse as a well-formed matcher list — callers should fail + * closed (reject) rather than guess at a malformed selector. + */ +function parseSelectorMatchers(selector: string): LabelMatcher[] | null { + const inner = selector.slice(1, -1); + const matchers: LabelMatcher[] = []; + let i = 0; + const n = inner.length; + while (i < n) { + while (i < n && /[\s,]/.test(inner[i])) i++; + if (i >= n) break; + + const labelStart = i; + while (i < n && /[A-Za-z0-9_]/.test(inner[i])) i++; + const label = inner.slice(labelStart, i); + if (!label) return null; + + while (i < n && /\s/.test(inner[i])) i++; + const op = MATCHER_OPS.find((candidate) => inner.startsWith(candidate, i)); + if (!op) return null; + i += op.length; + while (i < n && /\s/.test(inner[i])) i++; + + const quote = inner[i]; + if (quote !== '"' && quote !== "'" && quote !== '`') return null; + i++; + let value = ''; + if (quote === '"' || quote === "'") { + while (i < n && inner[i] !== quote) { + if (inner[i] === '\\' && i + 1 < n) { + // Only `\` and `\\` decode to themselves under this naive + // scan. Any other escape needs real string-literal decoding to get + // the right value — since we don't implement that, fail closed + // rather than silently mis-decoding in either direction. + if (inner[i + 1] !== quote && inner[i + 1] !== '\\') return null; + value += inner[i + 1]; + i += 2; + } else { + value += inner[i]; + i++; + } + } + } else { + while (i < n && inner[i] !== '`') { + value += inner[i]; + i++; + } + } + if (i >= n) return null; // unterminated value + i++; // consume closing quote/backtick + + matchers.push({ label, op, value }); + } + return matchers; +} + +/** + * Check that every selector in a query contains an exact namespace matcher + * for the locked namespace. Accepts `namespace=""` or `namespace=~""` + * (treated as an exact-string match, not a real regex evaluation), rejecting + * anything else — a different value, a wildcard regex, negated operators + * (!=, !~), or no namespace matcher at all. + * + * Queries can combine multiple selectors via binary/aggregation operators + * (e.g. `sum(rate({a}[5m])) / sum(rate({b}[5m]))`), so every selector found + * must pass — checking only the first would let a later selector read an + * unlocked namespace while the whole query is forwarded unchanged. A query + * with no selector at all is rejected (fail closed). + * + * Each selector is fully parsed into label/operator/value matchers rather + * than regex-matched as raw text: a naive substring/regex check over the + * selector text can be spoofed by decoy text inside another matcher's + * backtick-quoted (unescaped) value. Parsing each matcher's value + * independently closes that off. `#`-comments are stripped first so a decoy + * selector hidden in a comment can't be validated in place of the real one. + */ +export function validateNamespaceSelectorLockdown(query: string, lockedNamespace: string): boolean { + const selectors = extractAllSelectors(stripHashComments(query)); + if (selectors === null || selectors.length === 0) return false; + return selectors.every((selector) => { + const matchers = parseSelectorMatchers(selector); + if (matchers === null) return false; + return matchers.some( + (m) => m.label === 'namespace' && (m.op === '=' || m.op === '=~') && m.value === lockedNamespace, + ); + }); +} diff --git a/src/tools/__tests__/prometheus.test.ts b/src/tools/__tests__/prometheus.test.ts index 24e414ff..f013ec2c 100644 --- a/src/tools/__tests__/prometheus.test.ts +++ b/src/tools/__tests__/prometheus.test.ts @@ -84,6 +84,30 @@ describe('makePrometheusQuery — timeout precedence', () => { }); }); +describe('makePrometheusQuery — namespace lockdown', () => { + it('bakes lockedNamespace into the config passed to runPrometheusQuery', async () => { + runPrometheusQuery.mockResolvedValue('ok'); + const tool = makePrometheusQuery({}, undefined, 'prod-payments'); + await tool.run({ input: { queryType: 'instant', query: 'up{namespace="prod-payments"}' } }); + expect(runPrometheusQuery).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ lockedNamespace: 'prod-payments' }), + ); + }); + + it('description mentions lockdown when active', () => { + const tool = makePrometheusQuery({}, undefined, 'prod-payments'); + expect(tool.description).toContain('NAMESPACE LOCKDOWN ACTIVE'); + expect(tool.description).toContain('prod-payments'); + }); + + it('description has no lockdown note when no lock is set', () => { + const tool = makePrometheusQuery(); + expect(tool.description).not.toContain('NAMESPACE LOCKDOWN'); + }); +}); + describe('makePrometheusQuery — tool metadata and params forwarding', () => { it('has the expected model-facing name', () => { expect(makePrometheusQuery().name).toBe('prometheus_query'); @@ -155,4 +179,29 @@ describe('prometheusPlugin', () => { expect.objectContaining({ url: 'http://prom-test:9090', timeoutMs: 5000, regexRedactionRules: rules }), ); }); + + it('factory passes namespace lock through to makePrometheusQuery', async () => { + runPrometheusQuery.mockResolvedValue('ok'); + const config = { + prometheus: { url: 'http://prom-test:9090' }, + namespace: { locked: 'prod-ns' }, + } as unknown as HeimdallConfig; + const tool = prometheusPlugin.factory(config, []); + expect(tool.description).toContain('NAMESPACE LOCKDOWN ACTIVE'); + await tool.run({ input: { queryType: 'instant', query: 'up{namespace="prod-ns"}' } }); + expect(runPrometheusQuery).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ lockedNamespace: 'prod-ns' }), + ); + }); + + it('factory works when namespace.locked is undefined', async () => { + runPrometheusQuery.mockResolvedValue('ok'); + const config = { + prometheus: { url: 'http://prom-test:9090' }, + } as unknown as HeimdallConfig; + const tool = prometheusPlugin.factory(config, []); + expect(tool.description).not.toContain('NAMESPACE LOCKDOWN'); + }); }); diff --git a/src/tools/prometheus.ts b/src/tools/prometheus.ts index 6a5520aa..41e7f162 100644 --- a/src/tools/prometheus.ts +++ b/src/tools/prometheus.ts @@ -7,32 +7,41 @@ import { runPrometheusQuery } from '../lib/prometheus.ts'; import type { PrometheusConfig } from '../lib/prometheus.ts'; import type { CompiledRedactionRule } from '../lib/regex-redact.ts'; import type { ToolPlugin } from '../lib/plugin.ts'; -import { resolveConfigString, resolveTimeoutMs } from '../lib/tool-config.ts'; +import { buildLockdownNote, resolveConfigString, resolveTimeoutMs } from '../lib/tool-config.ts'; const DEFAULT_PROMETHEUS_URL = 'http://prometheus-operated.monitoring:9090'; const DEFAULT_TIMEOUT_MS = 10_000; /** - * Factory that bakes the Prometheus base URL and timeout into the tool closure. - * The URL is resolved from config → env → in-cluster default, never from the model. + * Factory that bakes the Prometheus base URL, timeout, and optional namespace + * lockdown into the tool closure. The URL is resolved from config → env → + * in-cluster default, never from the model. */ export function makePrometheusQuery( prometheusConfig?: { url?: string | null; timeoutMs?: number | null } | null, regexRedactionRules?: CompiledRedactionRule[], + lockedNamespace?: string | null, ) { const config: PrometheusConfig = { url: resolveConfigString(prometheusConfig?.url, 'PROMETHEUS_URL', DEFAULT_PROMETHEUS_URL), timeoutMs: resolveTimeoutMs(prometheusConfig?.timeoutMs, DEFAULT_TIMEOUT_MS), regexRedactionRules, + lockedNamespace: lockedNamespace ?? undefined, }; + const lockdownNote = buildLockdownNote( + lockedNamespace, + (ns) => `every vector selector must include namespace="${ns}"; queries without it are blocked.`, + ); + return defineTool({ name: 'prometheus_query', description: 'Query Prometheus for time-series metrics using PromQL. Two query types:\n' + '- instant: evaluate a PromQL expression at a single point in time (defaults to now).\n' + '- range: evaluate a PromQL expression over a time window with a resolution step.\n' + - 'Use this to inspect golden signals (request rate, error rate, latency, saturation) and resource trends that kubectl cannot show.', + 'Use this to inspect golden signals (request rate, error rate, latency, saturation) and resource trends that kubectl cannot show.' + + lockdownNote, input: v.object({ queryType: v.pipe( v.picklist(['instant', 'range']), @@ -75,5 +84,5 @@ export function makePrometheusQuery( export const prometheusPlugin: ToolPlugin = { key: 'prometheusQuery', - factory: (config, rules) => makePrometheusQuery(config.prometheus, rules), + factory: (config, rules) => makePrometheusQuery(config.prometheus, rules, config.namespace?.locked), };