diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 761c427f..790a9051 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -57,6 +57,8 @@ jobs:
run: npm run validate:case-studies
- name: Validate radar reports data
run: npm run validate:radar-reports
+ - name: Validate projects-born data
+ run: npm run validate:projects-born
- name: Validate button contrast
run: npm run validate:button-contrast
- name: Build production site
diff --git a/package.json b/package.json
index 8a8d3008..b2029dbb 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"validate:radar-reports": "node scripts/validate-radar-reports.mjs",
"validate:community-people": "node scripts/validate-community-people.mjs",
"validate:community-groups": "node scripts/validate-community-groups.mjs",
+ "validate:projects-born": "node scripts/validate-projects-born.mjs",
"pr-queue-hygiene": "node scripts/pr-queue-hygiene.mjs",
"test:unit": "TZ=UTC node --test",
"test:unit:coverage": "TZ=UTC node tests/tools/coverage-report.mjs",
diff --git a/scripts/lib/mdx-active-content.mjs b/scripts/lib/mdx-active-content.mjs
index 9b615f3a..7ed0ea44 100644
--- a/scripts/lib/mdx-active-content.mjs
+++ b/scripts/lib/mdx-active-content.mjs
@@ -8,6 +8,12 @@
* helper reports the constructs that can execute or load remote code so a
* validator can fail the build before such a body ships.
*
+ * MDX evaluates a braced expression as JavaScript, so `{fetch(...)}` in an
+ * imported body is live code and not prose; every `{` is therefore a finding
+ * unless it is one of the inert string-literal attributes the importer emits
+ * itself. The check is deliberately fail-closed: literal braces in upstream
+ * prose are reported rather than assumed harmless.
+ *
* Scheme detection normalizes each line before testing it, because CommonMark
* decodes character references in a link destination: `javascript:` is a
* live `javascript:` href by the time the page renders.
@@ -36,6 +42,24 @@ const ALLOWED_COMPONENT = 'CNCFProjectCard';
const ALLOWED_IMPORT =
"import CNCFProjectCard from '@site/src/components/CNCFProjectCard';";
+/**
+ * The one expression form the importer itself emits: an attribute whose value
+ * is a single JSON string literal, as produced by `jsxAttribute`
+ * (`scripts/lib/jsx-attributes.mjs`). The pattern requires the closing brace
+ * to follow the closing quote immediately, so the braces can enclose nothing
+ * but the literal -- `{"a" + fetch(x)}` does not match. An expression whose
+ * entire body is a string literal evaluates to that string and has no call,
+ * member access or identifier reference available to it, so it is inert
+ * wherever it appears.
+ */
+const ALLOWED_ATTRIBUTE_EXPRESSION =
+ /(?<=\s)[A-Za-z_$][A-Za-z0-9_$-]*=\{"(?:[^"\\]|\\.)*"\}/g;
+
+/**
+ * Any remaining `{` opens an MDX expression, which is evaluated JavaScript.
+ */
+const EXPRESSION_PATTERN = /\{/;
+
const ELEMENT_PATTERN = /<\/?([A-Za-z][A-Za-z0-9._-]*)/g;
const EVENT_HANDLER_PATTERN = /\bon[a-z]{3,}\s*=/gi;
const DANGEROUS_URL_PATTERN = /(?:javascript|vbscript):|data:text\/html/gi;
@@ -227,6 +251,22 @@ function blankInlineSpans(text) {
return result;
}
+/**
+ * Neutralize the braces of the importer's own attribute expressions so the
+ * expression scan does not flag generated markup. Only the `{` and `}`
+ * characters are replaced, by a space each: the quoted value between them is
+ * left in place so the scheme, handler and element scans still read it, and
+ * the line keeps its length so findings keep accurate line numbers.
+ *
+ * @param {string} text
+ * @returns {string}
+ */
+function blankAllowedExpressions(text) {
+ return text.replace(ALLOWED_ATTRIBUTE_EXPRESSION, (match) =>
+ match.replace(/[{}]/g, ' '),
+ );
+}
+
/**
* Scans a Markdown/MDX body for content that executes or loads remote code.
*
@@ -236,7 +276,9 @@ function blankInlineSpans(text) {
*/
export function findActiveContent(markdown) {
const findings = [];
- const scannable = blankCodeSpans(String(markdown ?? ''));
+ const scannable = blankAllowedExpressions(
+ blankCodeSpans(String(markdown ?? '')),
+ );
const lines = scannable.split('\n');
lines.forEach((line, index) => {
@@ -281,6 +323,13 @@ export function findActiveContent(markdown) {
reason: 'unexpected ESM statement',
snippet,
});
+
+ if (EXPRESSION_PATTERN.test(line))
+ findings.push({
+ line: number,
+ reason: 'MDX expression',
+ snippet,
+ });
});
const seen = new Set();
diff --git a/scripts/validate-architecture-assets.mjs b/scripts/validate-architecture-assets.mjs
index 4dbf4377..cebb872e 100644
--- a/scripts/validate-architecture-assets.mjs
+++ b/scripts/validate-architecture-assets.mjs
@@ -12,6 +12,26 @@ import { collectError, reportAndExit } from './lib/validate-utils.mjs';
import { findActiveContent } from './lib/svg-active-content.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
+
+// Mirrors MIRRORABLE_ASSET_EXTENSIONS in scripts/import-architectures.mjs.
+// static/ is published verbatim at the site origin, so a file the browser
+// executes as markup or script must never be present here.
+const ALLOWED_ASSET_EXTENSIONS = new Set([
+ '.avif',
+ '.gif',
+ '.jpeg',
+ '.jpg',
+ '.png',
+ '.svg',
+ '.webp',
+]);
+
+// static/img additionally serves the legacy favicon.ico. ICO is a raster
+// container that no browser parses as markup or script, so it is safe at the
+// origin. It stays out of ALLOWED_ASSET_EXTENSIONS because that set mirrors
+// what the importer will mirror, and the importer never writes an .ico.
+const SITE_CHROME_EXTENSIONS = new Set([...ALLOWED_ASSET_EXTENSIONS, '.ico']);
+
// Every directory here is published verbatim at the site origin, so every one
// gets the security gate (extension allow-list, symlink rejection, SVG active
// content). The gate is scoped by where the bytes are *served from*, not by
@@ -22,40 +42,60 @@ const root = fileURLToPath(new URL('..', import.meta.url));
// Diagram-quality checks (viewBox, raster bloat, editor metadata) apply only
// to architecture diagrams; mirrored cncf/artwork icons and award logos are
// kept byte-faithful apart from the security gate.
+//
+// static/img and static/favicons hold site chrome - the footer logo, the
+// favicons - rather than imported assets, but they are served from the same
+// origin as everything else, so they carry the same security gate. static/img
+// is walked shallowly because its image subdirectories are listed above, each
+// with its own quality setting.
+//
+// static/fonts and the static/ root (robots.txt, manifest.json, .nojekyll) are
+// deliberately outside the gate: they hold no SVG, and their extensions are
+// legitimately outside the image allow-list.
const assetDirs = [
{ dir: join(root, 'static/img/architectures'), quality: true },
{ dir: join(root, 'static/img/cncf-projects'), quality: false },
{ dir: join(root, 'static/img/awards'), quality: false },
+ {
+ dir: join(root, 'static/img'),
+ quality: false,
+ recurse: false,
+ extensions: SITE_CHROME_EXTENSIONS,
+ },
+ {
+ dir: join(root, 'static/favicons'),
+ quality: false,
+ extensions: SITE_CHROME_EXTENSIONS,
+ },
];
const shouldFix = process.argv.includes('--fix');
-// Mirrors MIRRORABLE_ASSET_EXTENSIONS in scripts/import-architectures.mjs.
-// static/ is published verbatim at the site origin, so a file the browser
-// executes as markup or script must never be present here.
-const ALLOWED_ASSET_EXTENSIONS = new Set([
- '.avif',
- '.gif',
- '.jpeg',
- '.jpg',
- '.png',
- '.svg',
- '.webp',
-]);
+// Paths that are asset roots in their own right. The shallow static/img walk
+// must not report on them whatever they turn out to be on disk: each one is
+// already handled by its own entry above, which decides for itself whether a
+// symlink is an error and whether a non-directory is skipped. Without this the
+// shallow walk would duplicate the symlink error and would additionally reject
+// a regular file sitting where a root is expected.
+const assetRootPaths = new Set(assetDirs.map(({ dir }) => dir));
const issues = [];
const fixed = [];
-function walk(dir) {
+function walk(dir, recurse = true) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
+ if (assetRootPaths.has(path)) return [];
// A symlink in published assets can point anywhere in the repository (or
// outside it) and would be followed by readers and --fix writers, so its
// presence is itself an error rather than something to validate through.
+ // Checked before the directory branch, so a symlinked directory is still
+ // reported in a shallow walk.
if (entry.isSymbolicLink()) {
record(path, 'error', 'is a symbolic link; symlinks are not allowed');
return [];
}
- return entry.isDirectory() ? walk(path) : [path];
+ if (entry.isDirectory()) return recurse ? walk(path) : [];
+ return [path];
});
}
@@ -161,7 +201,7 @@ function validateSvg(path, quality) {
}
}
-function validateAsset(path, quality) {
+function validateAsset(path, quality, extensions) {
const rel = relative(root, path);
const stats = statSync(path);
const maxSize = 2 * 1024 * 1024; // 2 MB
@@ -174,7 +214,7 @@ function validateAsset(path, quality) {
}
const extension = extname(path).toLowerCase();
- if (!ALLOWED_ASSET_EXTENSIONS.has(extension)) {
+ if (!extensions.has(extension)) {
record(
path,
'error',
@@ -188,23 +228,25 @@ function validateAsset(path, quality) {
}
}
-const assets = assetDirs.flatMap(({ dir, quality }) => {
- const kind = assetRootKind(dir);
- // Fail loudly rather than skipping: a silently unvalidated asset root ships
- // unchecked SVGs from the site origin.
- if (kind === 'symlink') {
- record(
- dir,
- 'error',
- 'asset directory is a symbolic link; symlinks are not allowed',
- );
- return [];
- }
- if (kind !== 'directory') return [];
- return walk(dir).map((path) => ({ path, quality }));
-});
-for (const { path, quality } of assets) {
- validateAsset(path, quality);
+const assets = assetDirs.flatMap(
+ ({ dir, quality, recurse = true, extensions = ALLOWED_ASSET_EXTENSIONS }) => {
+ const kind = assetRootKind(dir);
+ // Fail loudly rather than skipping: a silently unvalidated asset root ships
+ // unchecked SVGs from the site origin.
+ if (kind === 'symlink') {
+ record(
+ dir,
+ 'error',
+ 'asset directory is a symbolic link; symlinks are not allowed',
+ );
+ return [];
+ }
+ if (kind !== 'directory') return [];
+ return walk(dir, recurse).map((path) => ({ path, quality, extensions }));
+ },
+);
+for (const { path, quality, extensions } of assets) {
+ validateAsset(path, quality, extensions);
}
if (fixed.length) {
diff --git a/scripts/validate-community-groups.mjs b/scripts/validate-community-groups.mjs
index c9355f81..f3eed84a 100644
--- a/scripts/validate-community-groups.mjs
+++ b/scripts/validate-community-groups.mjs
@@ -7,6 +7,27 @@ const data = JSON.parse(
);
const errors = [];
+// The repository value is the upstream link for an End User Group and is one
+// component change away from an . A bare `new URL()` parse accepts
+// "javascript:alert(1)", "data:text/html,...", cleartext http, and a
+// userinfo-spoofed authority such as "https://github.com@evil.example/x",
+// whose visible prefix and real host disagree. Mirrors publishableUrl() in
+// validate-case-studies.mjs and isCncfProjectHref() in
+// lib/project-card-links.mjs. github.com is the only host
+// check-community-group-links.mjs ever writes.
+function publishableRepositoryUrl(value) {
+ if (typeof value !== 'string' || !value.trim()) return false;
+ let url;
+ try {
+ url = new URL(value.trim());
+ } catch {
+ return false;
+ }
+ if (url.protocol !== 'https:') return false;
+ if (url.username || url.password) return false;
+ return url.hostname.toLowerCase() === 'github.com';
+}
+
if (Number.isNaN(Date.parse(data.checkedAt))) {
collectError(
errors,
@@ -48,10 +69,13 @@ for (const group of Array.isArray(data.groups) ? data.groups : []) {
collectError(errors, label, 'error', 'duplicate slug');
slugs.add(group.slug);
}
- try {
- if (group.repository) new URL(group.repository);
- } catch {
- collectError(errors, label, 'error', 'repository must be an absolute URL');
+ if (group.repository && !publishableRepositoryUrl(group.repository)) {
+ collectError(
+ errors,
+ label,
+ 'error',
+ `repository must be an https github.com URL, with no userinfo: ${JSON.stringify(group.repository)}`,
+ );
}
// A group whose upstream repo is archived or unreachable still renders on
// the docs page today; surface it loudly but do not fail the build over an
diff --git a/scripts/validate-community-people.mjs b/scripts/validate-community-people.mjs
index 78b4d55c..a7d5c3c2 100644
--- a/scripts/validate-community-people.mjs
+++ b/scripts/validate-community-people.mjs
@@ -37,23 +37,52 @@ if (!ISO_8601.test(data.fetchedAt ?? '')) {
// back to name for the one member without one), so a stale or
// partially-generated file with the right shape but a missing/extra person
// fails loudly instead of only checking that the arrays are non-empty.
-for (const [section, rosterEntries] of Object.entries(roster.sections || {})) {
- const generated = data.people?.[section] || [];
- const key = (person) => person.github || person.name;
- const rosterKeys = new Set(rosterEntries.map(key));
- const generatedKeys = new Set(generated.map(key));
- for (const entry of rosterEntries) {
- if (!generatedKeys.has(key(entry))) {
- collectError(
- errors,
- `people.${section}`,
- 'error',
- `missing roster member ${entry.name}`,
- );
+//
+// The loop runs over the union of both files' section keys, not the roster's
+// alone. Every section in the generated file is rendered by
+// , so driving the loop from the roster let a
+// section present only in community-people.json skip every check below --
+// including the profileImageUrl() host gate, which is the last thing standing
+// between an unattended upstream refresh and an arbitrary third-party host in
+// an
served to every visitor.
+const rosterSections = roster.sections || {};
+const generatedSections = data.people || {};
+const key = (person) => person.github || person.name;
+
+for (const section of new Set([
+ ...Object.keys(rosterSections),
+ ...Object.keys(generatedSections),
+])) {
+ const onRoster = Object.hasOwn(rosterSections, section);
+ const rosterEntries = onRoster ? rosterSections[section] || [] : [];
+ const generated = generatedSections[section] || [];
+
+ if (!onRoster) {
+ collectError(
+ errors,
+ `people.${section}`,
+ 'error',
+ 'section is not declared in community-roster.json; every section the site renders must be on the roster',
+ );
+ } else {
+ const generatedKeys = new Set(generated.map(key));
+ for (const entry of rosterEntries) {
+ if (!generatedKeys.has(key(entry))) {
+ collectError(
+ errors,
+ `people.${section}`,
+ 'error',
+ `missing roster member ${entry.name}`,
+ );
+ }
}
}
+
+ const rosterKeys = new Set(rosterEntries.map(key));
for (const person of generated) {
- if (!rosterKeys.has(key(person))) {
+ // Skipped when the whole section is unknown: the section error above
+ // already says so, once, instead of once per member.
+ if (onRoster && !rosterKeys.has(key(person))) {
collectError(
errors,
`people.${section}`,
diff --git a/scripts/validate-metrics.mjs b/scripts/validate-metrics.mjs
index e6c197cd..d86d1361 100644
--- a/scripts/validate-metrics.mjs
+++ b/scripts/validate-metrics.mjs
@@ -15,6 +15,17 @@ const errors = [];
// parses with protocol "https:" while resolving to evil.example, so it reads
// as CNCF to a human reviewing a generated diff and resolves elsewhere in a
// browser. Mirrors checkHttpsUrl() in validate-awards.mjs.
+//
+// The scheme and userinfo tests are still not a gate on their own: every one of
+// these URLs is rendered under hard-coded anchor text ("cncf/architecture",
+// "Source ↗"), so a plain https URL on a host that is not CNCF's publishes as a
+// CNCF-labelled link to an unrelated origin. Hold the host to an allow-list,
+// the same standard ALLOWED_HOST_SUFFIXES applies in
+// scripts/validate-case-studies.mjs and scripts/validate-radar-reports.mjs.
+// github.com is allowed because sources.*.repository, sources.*.sourceUrl and
+// several metric sourceUrl values legitimately point at github.com/cncf/...
+const ALLOWED_HOST_SUFFIXES = ['cncf.io', 'github.com'];
+
function checkUrl(path, field, value) {
if (value === undefined || value === null || value === '') return;
let parsed;
@@ -36,12 +47,24 @@ function checkUrl(path, field, value) {
});
return;
}
- if (parsed.username || parsed.password)
+ if (parsed.username || parsed.password) {
errors.push({
path,
severity: 'error',
message: `${field} must not carry a userinfo component, which only disguises the real host (${parsed.hostname})`,
});
+ return;
+ }
+ const host = parsed.hostname.toLowerCase();
+ const allowed = ALLOWED_HOST_SUFFIXES.some(
+ (domain) => host === domain || host.endsWith(`.${domain}`),
+ );
+ if (!allowed)
+ errors.push({
+ path,
+ severity: 'error',
+ message: `${field} must be on ${ALLOWED_HOST_SUFFIXES.join(' or ')}, got ${parsed.hostname}`,
+ });
}
if (!data.generated)
diff --git a/scripts/validate-projects-born.mjs b/scripts/validate-projects-born.mjs
new file mode 100644
index 00000000..8b9bab5a
--- /dev/null
+++ b/scripts/validate-projects-born.mjs
@@ -0,0 +1,105 @@
+#!/usr/bin/env node
+import { readFileSync } from 'node:fs';
+import { reportAndExit } from './lib/validate-utils.mjs';
+
+// data/projects-born.json is hand-maintained, and src/components/ProjectsBorn
+// renders every entry's `url` as an . That component is mounted in
+// src/theme/Footer, so these links ship on every page of the site rather than
+// on one section of the homepage.
+//
+// It was the only data file feeding an with no validator behind it,
+// which left a maintainer skimming a JSON diff as the sole gate. A scheme
+// prefix test would not be one either: /^https:\/\// accepts
+// "https://www.cncf.io@evil.example/", whose visible prefix and real host
+// disagree, and accepts unparseable values such as "https://". Parse the URL
+// instead, the same standard checkHttpsUrl() in validate-awards.mjs and
+// checkUrl() in validate-metrics.mjs already apply.
+//
+// No host allow-list: these are legitimately third-party project sites
+// (envoyproxy.io, jaegertracing.io, backstage.io), unlike the cncf.io feeds
+// guarded by validate-radar-reports.mjs and validate-case-studies.mjs.
+
+const REQUIRED_TEXT_FIELDS = ['name', 'origin', 'description'];
+
+function checkUrl(errors, path, value) {
+ if (typeof value !== 'string' || !value.trim()) {
+ errors.push({
+ path,
+ severity: 'error',
+ message: 'url must be a non-empty string',
+ });
+ return;
+ }
+
+ let parsed;
+ try {
+ parsed = new URL(value.trim());
+ } catch {
+ errors.push({
+ path,
+ severity: 'error',
+ message: `url must be an absolute https URL: ${JSON.stringify(value)}`,
+ });
+ return;
+ }
+
+ if (parsed.protocol !== 'https:') {
+ errors.push({
+ path,
+ severity: 'error',
+ message: `url must use https, got ${parsed.protocol}`,
+ });
+ return;
+ }
+
+ if (parsed.username || parsed.password) {
+ errors.push({
+ path,
+ severity: 'error',
+ message: `url must not carry a userinfo component, which only disguises the real host (${parsed.hostname})`,
+ });
+ }
+}
+
+const data = JSON.parse(
+ readFileSync(new URL('../data/projects-born.json', import.meta.url)),
+);
+const errors = [];
+
+if (!Array.isArray(data) || !data.length) {
+ errors.push({
+ path: 'projects-born.json',
+ severity: 'error',
+ message: 'projects-born.json must be a non-empty array',
+ });
+}
+
+const names = new Set();
+for (const entry of Array.isArray(data) ? data : []) {
+ const path = typeof entry?.name === 'string' ? entry.name : 'unknown';
+
+ for (const field of REQUIRED_TEXT_FIELDS) {
+ const value = entry?.[field];
+ if (typeof value !== 'string' || !value.trim()) {
+ errors.push({
+ path,
+ severity: 'error',
+ message: `${field} must be a non-empty string`,
+ });
+ }
+ }
+
+ if (typeof entry?.name === 'string' && names.has(entry.name)) {
+ errors.push({
+ path,
+ severity: 'error',
+ message: 'duplicate name',
+ });
+ }
+ names.add(entry?.name);
+
+ checkUrl(errors, path, entry?.url);
+}
+
+reportAndExit(errors, 'projects born');
+console.log(`Validated ${data.length} born projects`);
diff --git a/tests/mdx-active-content.test.mjs b/tests/mdx-active-content.test.mjs
index 6d8c76b4..89d03d1c 100644
--- a/tests/mdx-active-content.test.mjs
+++ b/tests/mdx-active-content.test.mjs
@@ -125,9 +125,11 @@ test('reports a live element hidden by a mismatched backtick run, not a code spa
'disallowed element `'), []);
@@ -233,3 +235,63 @@ test('leaves an unrecognized named entity literal rather than dropping it', () =
'script-capable URL scheme',
]);
});
+
+test('flags an MDX expression, which Docusaurus compiles to executable JavaScript', () => {
+ assert.deepEqual(
+ reasons('{(() => { document.location = "https://evil.example"; })()}'),
+ ['MDX expression'],
+ );
+ assert.deepEqual(
+ reasons('Body {globalThis.fetch("https://evil.example")}.'),
+ ['MDX expression'],
+ );
+ assert.deepEqual(reasons('{[].constructor.constructor("return 1")()}'), [
+ 'MDX expression',
+ ]);
+});
+
+test('flags the opening line of an expression that spans several lines', () => {
+ const body = ['{(() => {', ' fetch("https://evil.example");', '})()}'].join(
+ '\n',
+ );
+ assert.deepEqual(findActiveContent(body), [
+ {
+ line: 1,
+ reason: 'MDX expression',
+ snippet: '{(() => {',
+ },
+ ]);
+});
+
+test('accepts the string-literal attribute expressions the importer emits', () => {
+ const card =
+ ' ';
+ assert.deepEqual(findActiveContent(card), []);
+});
+
+test('flags an attribute expression that is more than a string literal', () => {
+ assert.deepEqual(
+ reasons(''),
+ ['MDX expression'],
+ );
+ assert.deepEqual(reasons(''), [
+ 'MDX expression',
+ ]);
+});
+
+test('still scans the value inside an allowed attribute expression', () => {
+ // Only the braces are neutralized, so a script URI smuggled into a prop
+ // value is still reported rather than hidden by the allowance.
+ assert.deepEqual(
+ reasons(''),
+ ['script-capable URL scheme'],
+ );
+});
+
+test('does not flag braces inside code, which MDX does not evaluate', () => {
+ assert.deepEqual(reasons('Run `kubectl get pods -o {.items}` to list.'), []);
+ assert.deepEqual(
+ reasons(['```json', '{ "replicas": 3 }', '```'].join('\n')),
+ [],
+ );
+});
diff --git a/tests/members-data.test.mjs b/tests/members-data.test.mjs
index 0f099c10..db56cdba 100644
--- a/tests/members-data.test.mjs
+++ b/tests/members-data.test.mjs
@@ -26,6 +26,33 @@ const REQUIRED_ARRAY_FIELDS = [
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+// members.json is generated, but nothing reconciles its URLs against the
+// validated sources it came from: the awards cross-check below compares only
+// the slug/year/award key. This is the only gate on the hrefs the member
+// directory renders, so it parses rather than prefix-matches. A `/^https:\/\//`
+// test accepts "https://www.cncf.io@evil.example/", whose real host is
+// evil.example, and accepts the bare string "https://", which is not a URL at
+// all. Mirrors isHttpsUrl() in scripts/validate-architectures.mjs.
+function httpsUrlProblem(value) {
+ if (typeof value !== 'string') return 'must be a string';
+ if (value !== value.trim()) return 'must not have surrounding whitespace';
+ let url;
+ try {
+ url = new URL(value);
+ } catch {
+ return 'must be a parseable URL';
+ }
+ if (url.protocol !== 'https:') return `must use https, got ${url.protocol}`;
+ if (url.username || url.password)
+ return `must not carry userinfo; its real host is ${url.host}`;
+ return null;
+}
+
+function assertHttpsUrl(value, label) {
+ const problem = httpsUrlProblem(value);
+ assert.equal(problem, null, `${label} ${problem} (${String(value)})`);
+}
+
test('members.json exposes the generated envelope', () => {
assert.equal(typeof membersData.description, 'string');
assert.ok(membersData.description.length > 0);
@@ -127,10 +154,9 @@ test('member architecture entries carry their provenance', () => {
`${member.id} architecture is missing ${field}`,
);
}
- assert.match(
+ assertHttpsUrl(
architecture.sourceUrl,
- /^https:\/\//,
- `${member.id} architecture sourceUrl must be https`,
+ `${member.id} architecture sourceUrl`,
);
assert.match(
architecture.sourceCommit,
@@ -159,11 +185,7 @@ test('member award entries carry the fields the profile renders', () => {
);
for (const field of ['announcementUrl', 'caseStudyUrl', 'talkUrl']) {
if (!award[field]) continue;
- assert.match(
- award[field],
- /^https:\/\//,
- `${member.id} award ${field} must be https`,
- );
+ assertHttpsUrl(award[field], `${member.id} award ${field}`);
}
}
}
@@ -172,11 +194,7 @@ test('member award entries carry the fields the profile renders', () => {
test('sourceAttribution entries are https URLs', () => {
for (const member of members) {
for (const url of member.sourceAttribution) {
- assert.match(
- url,
- /^https:\/\//,
- `${member.id} sourceAttribution entry must be an https URL`,
- );
+ assertHttpsUrl(url, `${member.id} sourceAttribution entry`);
}
}
});
@@ -222,3 +240,33 @@ test('members with no public detail still carry attribution', () => {
);
}
});
+
+test('the URL gate rejects what a bare https prefix test would accept', () => {
+ for (const value of [
+ 'https://www.cncf.io@evil.example/phish',
+ 'https://cncf.io@127.0.0.1/',
+ 'https://user:pass@evil.example/',
+ 'https://',
+ 'http://cncf.io/',
+ 'javascript:alert(1)',
+ ' https://cncf.io/',
+ 42,
+ null,
+ ]) {
+ assert.notEqual(
+ httpsUrlProblem(value),
+ null,
+ `${String(value)} must be rejected`,
+ );
+ }
+});
+
+test('the URL gate accepts ordinary https source links', () => {
+ for (const value of [
+ 'https://cncf.io/',
+ 'https://github.com/cncf/endusers',
+ 'https://www.cncf.io/case-studies/example/?utm=1#section',
+ ]) {
+ assert.equal(httpsUrlProblem(value), null, `${value} must be accepted`);
+ }
+});
diff --git a/tests/validate-architecture-assets.test.mjs b/tests/validate-architecture-assets.test.mjs
index 51cf2e7d..2804313f 100644
--- a/tests/validate-architecture-assets.test.mjs
+++ b/tests/validate-architecture-assets.test.mjs
@@ -356,3 +356,72 @@ test('warns on an asset larger than 2 MB but still passes', () => {
assert.match(result.stderr, /large\.svg: asset is \d+\.\d\d MB/);
assert.match(result.stdout, /Validated 1 architecture asset/);
});
+
+// static/img and static/favicons hold site chrome — the footer logo, the
+// favicon set — rather than imported assets. They are served from the same
+// origin as the diagrams, so a browser that opens one of their SVGs directly
+// executes any script it carries; they were outside the gate until #690.
+
+test('rejects active content in a site-chrome SVG under static/img', () => {
+ const logo = 'static/img/cncf_logo_white.svg';
+ const svg = VALID_SVG.replace('alert(1) {
+ const icon = 'static/favicons/favicon.svg';
+ const svg = VALID_SVG.replace(
+ ' {
+ const result = runScriptWithFixtures(SCRIPT, {
+ 'static/img/page.html': 'x',
+ });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /\.html is not an allowed asset type/);
+});
+
+test('accepts the legacy favicon.ico that static/img serves', () => {
+ // ICO is a raster container no browser parses as markup, so it is safe at
+ // the origin even though the importer never mirrors one.
+ const result = runScriptWithFixtures(SCRIPT, {
+ 'static/img/favicon.ico': 'not-really-an-icon',
+ });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Validated 1 architecture asset/);
+});
+
+test('walks static/img shallowly so diagram-quality checks stay scoped', () => {
+ // static/img is walked without recursion because its image subdirectories
+ // are gated as their own roots. A viewBox-less logo sitting directly in
+ // static/img must therefore pass, while the same file under
+ // static/img/architectures fails the diagram-quality gate.
+ const svg = '';
+ const chrome = runScriptWithFixtures(SCRIPT, { 'static/img/logo.svg': svg });
+ assert.equal(chrome.status, 0, chrome.stderr);
+
+ const diagram = runScriptWithFixtures(SCRIPT, svgFixture(svg));
+ assert.equal(diagram.status, 1);
+ assert.match(diagram.stderr, /missing viewBox/);
+});
+
+test('reports a symlinked directory sitting directly in static/img', () => {
+ // The shallow walk must still reject symlinks: the symlink check runs
+ // before the directory branch, so a link that would otherwise be skipped
+ // for not being recursed into is still a finding.
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ { 'static/img/architectures/example/diagram.svg': VALID_SVG },
+ { symlinks: { 'static/img/elsewhere': 'architectures/example' } },
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /elsewhere: is a symbolic link/);
+});
diff --git a/tests/validate-community-groups.test.mjs b/tests/validate-community-groups.test.mjs
index 8be2154a..2a05e626 100644
--- a/tests/validate-community-groups.test.mjs
+++ b/tests/validate-community-groups.test.mjs
@@ -125,7 +125,7 @@ test('labels a group with neither slug nor name as "unknown group"', () => {
});
// A group missing `repository` must not also be reported as a bad URL: the
-// `if (group.repository)` guard inside the try is what keeps the two errors
+// `group.repository &&` guard on the URL check is what keeps the two errors
// from stacking on one record.
test('reports a repository-less group once, not also as a bad URL', () => {
const group = validGroup();
@@ -136,7 +136,7 @@ test('reports a repository-less group once, not also as a bad URL', () => {
);
assert.equal(result.status, 1);
assert.match(result.stderr, /group requires slug, name, and repository/);
- assert.doesNotMatch(result.stderr, /absolute URL/);
+ assert.doesNotMatch(result.stderr, /no userinfo/);
});
test('rejects a truthy non-array groups without crashing', () => {
@@ -167,7 +167,67 @@ test('rejects a non-URL repository', () => {
}),
);
assert.equal(result.status, 1);
- assert.match(result.stderr, /absolute URL/);
+ assert.match(result.stderr, /https github\.com URL/);
+});
+
+// A bare `new URL()` parse accepts every value below. The repository link is
+// published data one component change away from an , so the gate has
+// to decide the destination by parsing rather than by parseability alone.
+for (const [description, repository] of [
+ ['a javascript: scheme', 'javascript:alert(1)'],
+ ['a data: URL', 'data:text/html,'],
+ ['a cleartext http URL', 'http://github.com/cncf/research-user-group'],
+ [
+ 'a userinfo-spoofed authority',
+ 'https://github.com@evil.example/cncf/research-user-group',
+ ],
+ ['a host that merely ends in the allowed name', 'https://notgithub.com/x'],
+ ['a subdomain of the allowed host', 'https://raw.github.com/cncf/x'],
+ ['a whitespace-only repository', ' '],
+]) {
+ test(`rejects ${description}`, () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture({ ...validData, groups: [validGroup({ repository })] }),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /https github\.com URL/);
+ });
+}
+
+// Trailing whitespace is a copy-paste artefact, not a different destination:
+// the gate trims before parsing so a valid link is not rejected over it.
+test('accepts a repository with surrounding whitespace', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture({
+ ...validData,
+ groups: [
+ validGroup({
+ repository: ' https://github.com/cncf/research-user-group ',
+ }),
+ ],
+ }),
+ );
+ assert.equal(result.status, 0, result.stderr);
+});
+
+// `hostname` is already lowercased by the URL parser, but the explicit
+// toLowerCase() in the gate is what keeps that true if the comparison ever
+// moves to a raw host string.
+test('accepts an uppercase host', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture({
+ ...validData,
+ groups: [
+ validGroup({
+ repository: 'https://GitHub.com/cncf/research-user-group',
+ }),
+ ],
+ }),
+ );
+ assert.equal(result.status, 0, result.stderr);
});
test('warns, without failing, on an archived upstream repo', () => {
diff --git a/tests/validate-community-people.test.mjs b/tests/validate-community-people.test.mjs
index b5f503f5..fafa118b 100644
--- a/tests/validate-community-people.test.mjs
+++ b/tests/validate-community-people.test.mjs
@@ -258,10 +258,92 @@ test('reports a missing fetchedAt instead of crashing on an absent field', () =>
assert.doesNotMatch(result.stderr, /TypeError/);
});
-test('treats a roster with no sections as having nothing to cross-check', () => {
+test('rejects a generated section the roster does not declare', () => {
const result = runScriptWithFixtures(SCRIPT, fixture(freshData, {}));
+ assert.equal(result.status, 1);
+ assert.match(
+ result.stderr,
+ /people\.tab.*section is not declared in community-roster\.json/,
+ );
+ assert.match(
+ result.stderr,
+ /people\.staff.*section is not declared in community-roster\.json/,
+ );
+});
+
+// The hole this guards: a section present only in the generated file used to
+// skip the loop body entirely, so its members reached
with no host
+// check at all.
+test('gates images in a generated section the roster does not declare', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture({
+ ...freshData,
+ people: {
+ ...freshData.people,
+ ambassadors: [
+ validPerson({
+ name: 'Probe',
+ github: 'probe',
+ image: 'https://evil.example/beacon.png',
+ }),
+ ],
+ },
+ }),
+ );
+ assert.equal(result.status, 1);
+ assert.match(
+ result.stderr,
+ /people\.ambassadors.*section is not declared in community-roster\.json/,
+ );
+ assert.match(
+ result.stderr,
+ /Probe image must be an https URL on an allowed host/,
+ );
+});
+
+// A section the roster does not declare is reported once, as a section error,
+// rather than once per member as "is not on the roster".
+test('does not repeat the roster-membership error for an undeclared section', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture({
+ ...freshData,
+ people: { ...freshData.people, ambassadors: [validPerson()] },
+ }),
+ );
+ assert.equal(result.status, 1);
+ assert.doesNotMatch(
+ result.stderr,
+ /people\.ambassadors.*is not on the roster/,
+ );
+});
+
+test('still cross-checks roster membership within a declared section', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture({
+ ...freshData,
+ people: {
+ tab: [validPerson(), validPerson({ name: 'Mallory', github: 'mal' })],
+ staff: [staffPerson()],
+ },
+ }),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /Mallory is not on the roster/);
+});
+
+test('treats a declared roster section with no entries as empty', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture(
+ { ...freshData, people: { ...freshData.people, alumni: [] } },
+ { sections: { ...roster.sections, alumni: null } },
+ ),
+ );
assert.equal(result.status, 0, result.stderr);
- assert.match(result.stdout, /Validated 2 community profiles/);
+ assert.doesNotMatch(result.stderr, /TypeError/);
});
test('treats a section absent from the generated file as empty', () => {
diff --git a/tests/validate-metrics-host-allowlist.test.mjs b/tests/validate-metrics-host-allowlist.test.mjs
new file mode 100644
index 00000000..a55af1e4
--- /dev/null
+++ b/tests/validate-metrics-host-allowlist.test.mjs
@@ -0,0 +1,192 @@
+// Host allow-list coverage for scripts/validate-metrics.mjs.
+//
+// Every URL in data/metrics.json is rendered under hard-coded anchor text
+// ("cncf/architecture" in src/components/ReferenceArchitectures, "Source ↗" in
+// src/components/MetricsDashboard), so an https URL on a host that is not
+// CNCF's publishes as a CNCF-labelled link to an unrelated origin. These cases
+// pin the allow-list at every checkUrl() call site.
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+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: { revision: 'abc123' },
+ architectures: { revision: 'def456' },
+ },
+ metrics: [validMetric],
+};
+
+function run(data) {
+ return runScriptWithFixtures(SCRIPT, {
+ 'data/metrics.json': JSON.stringify(data),
+ });
+}
+
+const OFF_ALLOWLIST = 'https://evil.example/x';
+
+test('accepts github.com and cncf.io subdomains across every URL field', () => {
+ const result = run({
+ ...validData,
+ sources: {
+ landscape: {
+ revision: 'abc123',
+ repository: 'https://github.com/cncf/landscape',
+ sourceUrl: 'https://github.com/cncf/landscape/blob/main/landscape.yml',
+ },
+ architectures: {
+ revision: 'def456',
+ repository: 'https://github.com/cncf/architecture',
+ sourceUrl: 'https://github.com/cncf/architecture',
+ },
+ },
+ referenceArchitectureLifecycle: { sourceUrl: 'https://www.cncf.io/' },
+ series: {
+ projects: {
+ label: 'Projects',
+ sourceUrl: 'https://landscape.cncf.io/',
+ values: [{ date: '2026-01-01', value: 1 }],
+ },
+ },
+ breakdowns: {
+ industries: {
+ label: 'Industries',
+ sourceUrl: 'https://github.com/cncf/landscape',
+ values: [{ name: 'Telecom', value: 1 }],
+ },
+ },
+ });
+ assert.equal(result.status, 0, result.stderr);
+});
+
+test('rejects an off-allow-list host in sources.*.repository', () => {
+ const result = run({
+ ...validData,
+ sources: {
+ landscape: { revision: 'abc123', repository: OFF_ALLOWLIST },
+ architectures: { revision: 'def456' },
+ },
+ });
+ assert.equal(result.status, 1);
+ assert.match(
+ result.stderr,
+ /repository must be on cncf\.io or github\.com, got evil\.example/,
+ );
+});
+
+test('rejects an off-allow-list host in sources.*.sourceUrl', () => {
+ const result = run({
+ ...validData,
+ sources: {
+ landscape: { revision: 'abc123' },
+ architectures: { revision: 'def456', sourceUrl: OFF_ALLOWLIST },
+ },
+ });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /sources\.architectures/);
+ assert.match(result.stderr, /must be on cncf\.io or github\.com/);
+});
+
+test('rejects an off-allow-list host in referenceArchitectureLifecycle.sourceUrl', () => {
+ const result = run({
+ ...validData,
+ referenceArchitectureLifecycle: { sourceUrl: OFF_ALLOWLIST },
+ });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /referenceArchitectureLifecycle/);
+ assert.match(result.stderr, /must be on cncf\.io or github\.com/);
+});
+
+test('rejects an off-allow-list host in metric.sourceUrl', () => {
+ const result = run({
+ ...validData,
+ metrics: [{ ...validMetric, sourceUrl: OFF_ALLOWLIST }],
+ });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must be on cncf\.io or github\.com/);
+});
+
+test('rejects an off-allow-list host in series.*.sourceUrl', () => {
+ const result = run({
+ ...validData,
+ series: {
+ projects: {
+ label: 'Projects',
+ sourceUrl: OFF_ALLOWLIST,
+ values: [{ date: '2026-01-01', value: 1 }],
+ },
+ },
+ });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /series\.projects/);
+ assert.match(result.stderr, /must be on cncf\.io or github\.com/);
+});
+
+test('rejects an off-allow-list host in breakdowns.*.sourceUrl', () => {
+ const result = run({
+ ...validData,
+ breakdowns: {
+ industries: {
+ label: 'Industries',
+ sourceUrl: OFF_ALLOWLIST,
+ values: [{ name: 'Telecom', value: 1 }],
+ },
+ },
+ });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /breakdowns\.industries/);
+ assert.match(result.stderr, /must be on cncf\.io or github\.com/);
+});
+
+test('rejects a host that merely ends with an allowed label, not an allowed domain', () => {
+ const result = run({
+ ...validData,
+ metrics: [
+ { ...validMetric, sourceUrl: 'https://notcncf.io/x' },
+ {
+ ...validMetric,
+ id: 'suffix-trap',
+ sourceUrl: 'https://evil-github.com/x',
+ },
+ ],
+ });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /got notcncf\.io/);
+ assert.match(result.stderr, /got evil-github\.com/);
+});
+
+test('reports userinfo rather than the allow-list when both would fail', () => {
+ const result = run({
+ ...validData,
+ metrics: [
+ { ...validMetric, sourceUrl: 'https://www.cncf.io@evil.example/x' },
+ ],
+ });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must not carry a userinfo component/);
+ assert.doesNotMatch(result.stderr, /must be on cncf\.io or github\.com/);
+});
+
+test('the committed data/metrics.json satisfies the allow-list', () => {
+ const result = runScriptWithFixtures(SCRIPT, {
+ 'data/metrics.json': readFileSync(
+ new URL('../data/metrics.json', import.meta.url),
+ 'utf8',
+ ),
+ });
+ assert.equal(result.status, 0, result.stderr);
+});
diff --git a/tests/validate-projects-born.test.mjs b/tests/validate-projects-born.test.mjs
new file mode 100644
index 00000000..a839f403
--- /dev/null
+++ b/tests/validate-projects-born.test.mjs
@@ -0,0 +1,166 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { runScriptWithFixtures } from './helpers.mjs';
+
+const SCRIPT = 'validate-projects-born.mjs';
+
+const validEntry = {
+ name: 'Envoy',
+ origin: 'Lyft',
+ description: 'Originally built at Lyft before becoming a CNCF project.',
+ url: 'https://www.envoyproxy.io/',
+};
+
+const validData = [
+ validEntry,
+ {
+ name: 'Jaeger',
+ origin: 'Uber',
+ description: 'Open sourced by Uber to make distributed tracing practical.',
+ url: 'https://www.jaegertracing.io/',
+ },
+];
+
+function fixture(data) {
+ return { 'data/projects-born.json': JSON.stringify(data) };
+}
+
+test('accepts a valid projects-born file', () => {
+ const result = runScriptWithFixtures(SCRIPT, fixture(validData));
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Validated 2 born projects/);
+});
+
+test('rejects a file that is not an array', () => {
+ const result = runScriptWithFixtures(SCRIPT, fixture({ projects: [] }));
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must be a non-empty array/);
+});
+
+test('rejects an empty array', () => {
+ const result = runScriptWithFixtures(SCRIPT, fixture([]));
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must be a non-empty array/);
+});
+
+// The control this validator exists for: the visible prefix reads as the
+// project's own site while the request resolves to the userinfo-suffixed host.
+test('rejects a url carrying a userinfo component', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([
+ { ...validEntry, url: 'https://www.envoyproxy.io@evil.example/' },
+ ]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must not carry a userinfo component/);
+ assert.match(result.stderr, /evil\.example/);
+});
+
+test('rejects a non-https url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: 'http://www.envoyproxy.io/' }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must use https, got http:/);
+});
+
+test('rejects a javascript: url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: 'javascript:alert(1)' }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must use https, got javascript:/);
+});
+
+test('rejects an unparseable url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: 'www.envoyproxy.io' }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must be an absolute https URL/);
+});
+
+test('rejects a missing url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: undefined }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /url must be a non-empty string/);
+});
+
+test('rejects a blank url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: ' ' }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /url must be a non-empty string/);
+});
+
+test('rejects a non-string url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: 42 }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /url must be a non-empty string/);
+});
+
+for (const field of ['name', 'origin', 'description']) {
+ test(`rejects a missing ${field}`, () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, [field]: undefined }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(
+ result.stderr,
+ new RegExp(`${field} must be a non-empty string`),
+ );
+ });
+
+ test(`rejects a blank ${field}`, () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, [field]: ' ' }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(
+ result.stderr,
+ new RegExp(`${field} must be a non-empty string`),
+ );
+ });
+}
+
+// A non-string name still has to produce a readable error path rather than
+// crashing the report, so the entry is labelled "unknown".
+test('labels an entry with a non-string name as unknown', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, name: 7 }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /unknown: name must be a non-empty string/);
+});
+
+test('rejects duplicate names', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([validEntry, { ...validEntry }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /duplicate name/);
+});
+
+// A null entry must be reported rather than crash the walk on property access.
+test('reports a null entry instead of throwing', () => {
+ const result = runScriptWithFixtures(SCRIPT, fixture([null]));
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /unknown: name must be a non-empty string/);
+ assert.match(result.stderr, /unknown: url must be a non-empty string/);
+});
diff --git a/tests/validators-smoke.test.mjs b/tests/validators-smoke.test.mjs
index fe9c2752..b64a04b0 100644
--- a/tests/validators-smoke.test.mjs
+++ b/tests/validators-smoke.test.mjs
@@ -21,6 +21,7 @@ const READ_ONLY_VALIDATORS = [
'validate-radar-reports.mjs',
'validate-community-people.mjs',
'validate-community-groups.mjs',
+ 'validate-projects-born.mjs',
];
for (const script of READ_ONLY_VALIDATORS) {