Skip to content

fix: tally landscape category and maturity counts on null-prototype objects - #1826

Merged
mrbobbytables merged 1 commit into
mainfrom
sec/metrics-proto-keys-1825
Oct 23, 2026
Merged

mrbobbytables merged 1 commit into
mainfrom
sec/metrics-proto-keys-1825

Conversation

@hivecommons-hive

Copy link
Copy Markdown
Contributor

Security Fix

scripts/collect-metrics.mjs accumulates its two published breakdowns
(projectCategories, projectMaturity) on plain {} objects whose keys are
upstream cncf/landscape strings — category.name, and project.project
lowercased. (obj[key] || 0) + 1 therefore reads inherited
Object.prototype members for some upstream names.

Switches both tallies to Object.create(null), matching the hardening this
repo already applies to its own lookup tables
(scripts/generate-members.mjs:13,18,29, src/lib/profile-links.mjs:25).

What was actually broken

Verified by driving the real script through the existing sandbox harness —
the new test fails on the unfixed code with:

actual:   [ { name: 'constructor', value: 'function Object() { [native code] }1' } ]
expected: [ { name: '__proto__', value: 1 }, { name: 'constructor', value: 1 } ]
  • constructor / toString / valueOf / hasOwnProperty: the || 0 guard
    is truthy, fn + 1 concatenates, and a string publishes where a count
    belongs. validate-metrics.mjs catches it downstream
    (Number.isFinite), so the refresh-metrics PR and the Netlify build fail
    closed — correct, but as an opaque validator error one hop from its cause.
  • __proto__: fails open. The setter ignores the numeric assignment, the
    key never reaches 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 or to XSS. Severity is low; the impact is
confined to the published data and to the automation lane's availability.

Scope

monthlyCounts() and architectureTimelineData() use the same tally shape
but key on date.slice(0, 7) of a git/GitHub-formatted ISO date, which cannot
collide with an inherited name. Deliberately left unchanged so the diff stays
on the reachable sites.

Verification

  • New regression test fails before, passes after.
  • npm run test:unit: 3533 passing, 0 failing.
  • npm run check:lint and npm run check:format: clean.

Ground claimed

scripts/collect-metrics.mjs (the two tally initialisers) and
tests/collect-metrics.test.mjs (one appended test). Disjoint from every open
hold-gated PR (#1810, #1812, #1815, #1816, #1818, #1819, #1821 — all
tests/e2e-coverage-*, uri-safety.mjs annotations, shared CSS) and from
sec-check's own #1824, which touches package-lock.json only.

Closes #1825


Filed by sec-check agent (ACMM L4/L5 — hold-gated mode). Hold-gated: human review required.

— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88

@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

Important

Held for human review by the hive's ACMM level gate.

This PR was opened by the "sec-check" agent while Hive policy required a human checkpoint for that agent. Agent PRs are held at ACMM L3–L5; an outreach PR that targets a repository this hive does not manage is held at every level because it publishes project-facing communication into someone else's project.

Hive will keep the hold label until a human removes it. Operators can make a deliberate one-off release during an ACMM level change with release_level_holds=true, but level changes never release this hold automatically.

@netlify

netlify Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for endusers ready!

Name Link
🔨 Latest commit be8d569
🔍 Latest deploy log https://app.netlify.com/projects/endusers/deploys/6adba6cc9db2070bc5746e7f
😎 Deploy Preview https://deploy-preview-1826--endusers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified against the tree at head 4f4a8c1 — this looks correct to me.

  • The defect is real on main: scripts/collect-metrics.mjs:36-37 initialise categoryCounts/maturityCounts as plain {}, and the tally at lines 45-48 does (obj[key] || 0) + 1 on keys taken straight from upstream landscape.yml (category.name, lowercased project.project). A category named constructor reads Object.prototype.constructor through the truthy || 0 and publishes a string; __proto__ is swallowed by the setter and silently dropped — exactly as the body describes.
  • Fix is safe for the consumer: breakdown() (collect-metrics.mjs:167-176) consumes the tallies via Object.entries(), which works identically on null-prototype objects. No other reader of these two objects exists in the script.
  • Precedent claim checks out: scripts/generate-members.mjs:13,18,29,89,95 and src/lib/profile-links.mjs:25 already use Object.create(null) for the same reason, so this matches repository idiom.
  • Scope call is right: monthlyCounts/architectureTimelineData key on date.slice(0, 7) of ISO dates, which cannot collide with inherited names; leaving them unchanged keeps the diff on the reachable sites.
  • Test is sound: it exercises both failure modes (__proto__ drop, constructor/toString string-concat) through the real script, and the lowercased tostring expectation matches the String(project.project).toLowerCase() at line 47.

The failing End-to-end coverage check is failing across effectively every open PR in the queue and this diff touches only a build-time script and a unit test — unrelated.

No findings. Severity framing in the body (low, data-integrity/availability only, not prototype pollution) is accurate.

— hive: agent=reviewer backend=copilot model=claude-fable-5 copilot=1.0.88

@hivecommons-hive

Copy link
Copy Markdown
Contributor Author

The red End-to-end coverage check on this PR is not caused by this diff: it fails with the same "6 accepted region(s) ... are stale" error tracked in the shared incident #1809. Deferring this PR until the incident closes (fix is held in #1810). No change needed here.


🐝 Hive Agent: ci-maintainer | Instance: hosted-available-lke648397-260827-5n31 | SHA: unknown

— hive: agent=ci-maintainer backend=copilot model=kimi-k3 copilot=1.0.88

…bjects

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>
@mrbobbytables
mrbobbytables force-pushed the sec/metrics-proto-keys-1825 branch from 4f4a8c1 to be8d569 Compare October 23, 2026 18:26

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified against the tree, looks correct to me (not an approval — hold is a human gate).

  • The bug is real on main: at scripts/collect-metrics.mjs:44-47 the tally keys are upstream landscape.yml strings on a plain object, so a category named constructor reads Object.prototype.constructor through || 0 (publishing a non-number), and __proto__ is silently swallowed by the setter. Object.create(null) fixes both.
  • The cited precedent checks out: scripts/generate-members.mjs already uses Object.create(null) for its lookup tables (lines 13, 18, 29, 89, 95).
  • The new test's expected ordering is consistent with breakdown() (collect-metrics.mjs:167-176): stable value-desc sort preserves insertion order for equal counts, and tostring matches the toLowerCase() at line 46.

Tight scope, grounded comment, regression test included.

— hive: agent=reviewer backend=copilot model=claude-fable-5 copilot=1.0.88

@mrbobbytables
mrbobbytables added this pull request to the merge queue Oct 23, 2026
Merged via the queue into main with commit 6459bde Oct 23, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant