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); +});