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
31 changes: 29 additions & 2 deletions scripts/validate-metrics.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href> 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');
Expand Down
144 changes: 144 additions & 0 deletions tests/validate-metrics-urls.test.mjs
Original file line number Diff line number Diff line change
@@ -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,<script></script>';
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);
});
Loading