From f7a1a6d31adfac6f07f5075d5a886601ed321e79 Mon Sep 17 00:00:00 2001 From: "kubestellar-hive[bot]" Date: Fri, 18 Sep 2026 19:59:51 -0400 Subject: [PATCH] fix: reject non-https URLs in data/metrics.json validate-metrics.mjs checked every URL field in data/metrics.json for presence but never for scheme, while every one of those values is rendered directly into an by MetricsDashboard and by the SyncStatus block in ReferenceArchitectures. A javascript: value in metrics[].sourceUrl, series.*.sourceUrl, breakdowns.*.sourceUrl, referenceArchitectureLifecycle.sourceUrl or sources.*.repository passed npm run validate:metrics and was emitted verbatim into the built HTML. sources.*.repository is also string-concatenated into a commit URL, so a javascript: value there survives the concatenation as a javascript: URL. Adds a checkUrl() helper that parses with new URL and requires the https scheme, wired into all six sinks. Absent values are skipped so the existing presence checks remain the single source of 'missing', and every pre-existing error message and path is unchanged. Closes #303 Signed-off-by: kubestellar-hive[bot] --- scripts/validate-metrics.mjs | 31 +++++- tests/validate-metrics-urls.test.mjs | 144 +++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 tests/validate-metrics-urls.test.mjs diff --git a/scripts/validate-metrics.mjs b/scripts/validate-metrics.mjs index bb06eca0..bb248fe5 100644 --- a/scripts/validate-metrics.mjs +++ b/scripts/validate-metrics.mjs @@ -3,25 +3,52 @@ import { readFileSync } from 'node:fs'; import { reportAndExit } from './lib/validate-utils.mjs'; const data = JSON.parse(readFileSync(new URL('../data/metrics.json', import.meta.url))); const errors = []; + +// Every URL in metrics.json is rendered straight into an by +// src/components/MetricsDashboard and src/components/ReferenceArchitectures, +// so an href-bearing scheme other than https reaching the published site is an +// active-content sink. Absent values are left to the existing presence checks. +function checkUrl(path, field, value) { + if (value === undefined || value === null || value === '') return; + let parsed; + try { + parsed = new URL(value); + } catch { + errors.push({ path, severity: 'error', message: `${field} must be an absolute URL` }); + return; + } + if (parsed.protocol !== 'https:') errors.push({ path, severity: 'error', message: `${field} must use https, got ${parsed.protocol}` }); +} + if (!data.generated) errors.push({ path: 'metrics.json', severity: 'error', message: 'generated must be true' }); if (Number.isNaN(Date.parse(data.generatedAt))) errors.push({ path: 'metrics.json', severity: 'error', message: 'generatedAt must be ISO 8601' }); if (!data.sources?.landscape?.revision || !data.sources?.architectures?.revision) errors.push({ path: 'metrics.json', severity: 'error', message: 'source revisions are required' }); +for (const name of ['landscape', 'architectures']) { + const source = data.sources?.[name]; + if (!source) continue; + checkUrl(`sources.${name}`, 'repository', source.repository); + checkUrl(`sources.${name}`, 'sourceUrl', source.sourceUrl); +} +checkUrl('referenceArchitectureLifecycle', 'sourceUrl', data.referenceArchitectureLifecycle?.sourceUrl); const ids = new Set(); for (const metric of data.metrics || []) { if (!metric.id || ids.has(metric.id)) errors.push({ path: metric.id, severity: 'error', message: 'duplicate or missing metric id' }); ids.add(metric.id); if (metric.value === undefined || metric.value === null || metric.value === '') errors.push({ path: metric.id, severity: 'error', message: 'missing value' }); if (!metric.source || !metric.sourceUrl || !metric.collectedAt) errors.push({ path: metric.id, severity: 'error', message: 'missing provenance' }); + checkUrl(metric.id, 'sourceUrl', metric.sourceUrl); } for (const item of data.omitted || []) if (!item.id || !item.reason) errors.push({ path: 'metrics.json', severity: 'error', message: 'omitted metrics require id and reason' }); for (const card of data.referenceArchitectureLifecycle?.cards || []) if (!card.id || !card.label || !Number.isFinite(card.value)) errors.push({ path: 'metrics.json', severity: 'error', message: 'invalid lifecycle card' }); for (const item of data.referenceArchitectureLifecycle?.omitted || []) if (!item.id || !item.reason) errors.push({ path: 'metrics.json', severity: 'error', message: 'invalid lifecycle omission' }); -for (const series of Object.values(data.series || {})) { +for (const [id, series] of Object.entries(data.series || {})) { if (!series.label || !series.sourceUrl || !Array.isArray(series.values) || !series.values.length) errors.push({ path: 'metrics.json', severity: 'error', message: 'invalid time series' }); + checkUrl(`series.${id}`, 'sourceUrl', series.sourceUrl); for (const point of series.values || []) if (!point.date || !Number.isFinite(point.value)) errors.push({ path: 'metrics.json', severity: 'error', message: 'invalid time-series point' }); } -for (const chart of Object.values(data.breakdowns || {})) { +for (const [id, chart] of Object.entries(data.breakdowns || {})) { if (!chart.label || !chart.sourceUrl || !Array.isArray(chart.values)) errors.push({ path: 'metrics.json', severity: 'error', message: 'invalid breakdown' }); + checkUrl(`breakdowns.${id}`, 'sourceUrl', chart.sourceUrl); for (const item of chart.values || []) if (!item.name || !Number.isFinite(item.value)) errors.push({ path: 'metrics.json', severity: 'error', message: 'invalid breakdown value' }); } reportAndExit(errors, 'metrics'); diff --git a/tests/validate-metrics-urls.test.mjs b/tests/validate-metrics-urls.test.mjs new file mode 100644 index 00000000..8ee20a8d --- /dev/null +++ b/tests/validate-metrics-urls.test.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runScriptWithFixtures } from './helpers.mjs'; + +const SCRIPT = 'validate-metrics.mjs'; + +const validMetric = { + id: 'cncf-projects', + label: 'CNCF projects', + value: 100, + source: 'landscape', + sourceUrl: 'https://landscape.cncf.io/', + collectedAt: '2026-08-07T00:00:00.000Z', +}; + +const validData = { + generated: true, + generatedAt: '2026-08-07T00:00:00.000Z', + sources: { + landscape: { + repository: 'https://github.com/cncf/landscape', + revision: 'abc123', + sourceUrl: 'https://github.com/cncf/landscape/blob/main/landscape.yml', + }, + architectures: { + repository: 'https://github.com/cncf/architecture', + revision: 'def456', + sourceUrl: + 'https://github.com/cncf/architecture/tree/main/content/en/architectures', + }, + }, + metrics: [validMetric], + referenceArchitectureLifecycle: { + sourceUrl: 'https://github.com/cncf/tab/issues', + cards: [], + omitted: [], + }, + series: { + endUserMembers: { + label: 'CNCF member companies', + source: 'landscape', + sourceUrl: 'https://landscape.cncf.io/', + values: [{ date: '2026-01-01', value: 1 }], + }, + }, + breakdowns: { + projectMaturity: { + label: 'Project maturity', + source: 'landscape', + sourceUrl: 'https://landscape.cncf.io/', + values: [{ name: 'graduated', value: 1 }], + }, + }, +}; + +function fixture(data) { + return { 'data/metrics.json': JSON.stringify(data) }; +} + +function clone(data) { + return JSON.parse(JSON.stringify(data)); +} + +test('accepts a metrics file whose URLs are all https', () => { + const result = runScriptWithFixtures(SCRIPT, fixture(validData)); + assert.equal(result.status, 0, result.stderr); +}); + +test('rejects a javascript: metric sourceUrl', () => { + const data = clone(validData); + data.metrics[0].sourceUrl = 'javascript:alert(document.domain)'; + const result = runScriptWithFixtures(SCRIPT, fixture(data)); + assert.equal(result.status, 1); + assert.match(result.stderr, /sourceUrl must use https, got javascript:/); +}); + +test('rejects a data: series sourceUrl', () => { + const data = clone(validData); + data.series.endUserMembers.sourceUrl = 'data:text/html,'; + const result = runScriptWithFixtures(SCRIPT, fixture(data)); + assert.equal(result.status, 1); + assert.match( + result.stderr, + /series\.endUserMembers: sourceUrl must use https/, + ); +}); + +test('rejects a javascript: breakdown sourceUrl', () => { + const data = clone(validData); + data.breakdowns.projectMaturity.sourceUrl = 'javascript:alert(1)'; + const result = runScriptWithFixtures(SCRIPT, fixture(data)); + assert.equal(result.status, 1); + assert.match( + result.stderr, + /breakdowns\.projectMaturity: sourceUrl must use https/, + ); +}); + +test('rejects a javascript: lifecycle sourceUrl', () => { + const data = clone(validData); + data.referenceArchitectureLifecycle.sourceUrl = 'javascript:alert(1)'; + const result = runScriptWithFixtures(SCRIPT, fixture(data)); + assert.equal(result.status, 1); + assert.match( + result.stderr, + /referenceArchitectureLifecycle: sourceUrl must use https/, + ); +}); + +test('rejects a javascript: source repository, which is concatenated into a commit href', () => { + const data = clone(validData); + data.sources.architectures.repository = 'javascript:alert(1)'; + const result = runScriptWithFixtures(SCRIPT, fixture(data)); + assert.equal(result.status, 1); + assert.match( + result.stderr, + /sources\.architectures: repository must use https/, + ); +}); + +test('rejects an http: source sourceUrl', () => { + const data = clone(validData); + data.sources.landscape.sourceUrl = 'http://landscape.cncf.io/'; + const result = runScriptWithFixtures(SCRIPT, fixture(data)); + assert.equal(result.status, 1); + assert.match(result.stderr, /sources\.landscape: sourceUrl must use https/); +}); + +test('rejects a relative metric sourceUrl', () => { + const data = clone(validData); + data.metrics[0].sourceUrl = '/img/architectures/x.svg'; + const result = runScriptWithFixtures(SCRIPT, fixture(data)); + assert.equal(result.status, 1); + assert.match(result.stderr, /sourceUrl must be an absolute URL/); +}); + +test('leaves absent optional URLs to the existing presence checks', () => { + const data = clone(validData); + delete data.sources.landscape.repository; + delete data.sources.landscape.sourceUrl; + delete data.referenceArchitectureLifecycle.sourceUrl; + const result = runScriptWithFixtures(SCRIPT, fixture(data)); + assert.equal(result.status, 0, result.stderr); +});