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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 50 additions & 1 deletion scripts/lib/mdx-active-content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand All @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down
108 changes: 75 additions & 33 deletions scripts/validate-architecture-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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];
});
}

Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand All @@ -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) {
Expand Down
32 changes: 28 additions & 4 deletions scripts/validate-community-groups.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 href>. 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,
Expand Down Expand Up @@ -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
Expand Down
57 changes: 43 additions & 14 deletions scripts/validate-community-people.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <CommunityPeople section="..." />, 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 <img src> 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}`,
Expand Down
Loading