From be8d569334787b5aee057b135e7aa6af63b64ca7 Mon Sep 17 00:00:00 2001 From: sec-check <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:31:01 -0400 Subject: [PATCH] fix: tally landscape category and maturity counts on null-prototype objects collect-metrics.mjs keys its two breakdown tallies on strings taken straight from the upstream cncf/landscape landscape.yml: category.name, and project.project lowercased. Both were accumulated on a plain {} object, so (obj[key] || 0) could read an inherited Object.prototype property instead of 0. Two upstream names break the published breakdown today: - 'constructor' (also toString, valueOf, hasOwnProperty) makes the || 0 guard truthy, so fn + 1 concatenates and the count publishes as the string 'function Object() { [native code] }1'. validate-metrics.mjs rejects that downstream via Number.isFinite, so the refresh PR and the Netlify build fail closed -- correct, but an opaque error one hop from its cause. - '__proto__' fails open instead: the setter ignores the numeric assignment, the key never appears in Object.entries, and the breakdown silently under-reports with no validator complaint. Object.prototype is never mutated, so this is not prototype pollution and there is no path to the browser; the damage is confined to the published data. Switch both tallies to Object.create(null), the same hardening generate-members.mjs and src/lib/profile-links.mjs already apply to their lookup tables. breakdown() reads them through Object.entries(), which is unchanged on a null-prototype object. monthlyCounts() and architectureTimelineData() share the tally shape but key on date.slice(0, 7) of a git/GitHub-formatted ISO date, which cannot collide with an inherited name, so they are left alone. The regression test drives the real script through the existing sandbox harness and fails on the unfixed code with exactly the two symptoms above. Signed-off-by: sec-check <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- scripts/collect-metrics.mjs | 10 ++++++++-- tests/collect-metrics.test.mjs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/scripts/collect-metrics.mjs b/scripts/collect-metrics.mjs index 0384e78d..8de7a875 100644 --- a/scripts/collect-metrics.mjs +++ b/scripts/collect-metrics.mjs @@ -32,8 +32,14 @@ try { stdio: 'ignore', }); const projects = []; - const categoryCounts = {}; - const maturityCounts = {}; + // Null-prototype tallies: the keys below are upstream landscape.yml strings + // (category.name, project.project), so on a plain object a category named + // "constructor" would read Object.prototype's function through `|| 0` and + // publish a string where a count belongs, and one named "__proto__" would be + // dropped by the setter without an error. Same reason the lookup tables in + // generate-members.mjs use Object.create(null). + const categoryCounts = Object.create(null); + const maturityCounts = Object.create(null); for (const category of source.landscape || []) { for (const subcategory of category.subcategories || []) { for (const item of subcategory.items || []) { diff --git a/tests/collect-metrics.test.mjs b/tests/collect-metrics.test.mjs index 737f40e2..41bf4a08 100644 --- a/tests/collect-metrics.test.mjs +++ b/tests/collect-metrics.test.mjs @@ -600,3 +600,33 @@ test('collect-metrics files projects from an unnamed category under "Other"', () { name: 'Other', value: 1 }, ]); }); + +test('collect-metrics counts a category or maturity named after an inherited Object property', () => { + const data = metricsFrom( + run({ + landscape: `landscape: + - name: __proto__ + subcategories: + - name: Scheduling + items: + - name: Kubernetes + project: constructor + - name: constructor + subcategories: + - name: Scheduling + items: + - name: Linkerd + project: toString +`, + }), + ); + + assert.deepEqual(data.breakdowns.projectCategories.values, [ + { name: '__proto__', value: 1 }, + { name: 'constructor', value: 1 }, + ]); + assert.deepEqual(data.breakdowns.projectMaturity.values, [ + { name: 'constructor', value: 1 }, + { name: 'tostring', value: 1 }, + ]); +});