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
37 changes: 37 additions & 0 deletions .github/workflows/audit-bundled.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Audit the bundled packages

# What a host receives is the code webpack bundled, listed in dist/sbom.cdx.json; `dependencies` is empty, so
# `npm audit --omit=dev` audits nothing. scripts/audit-bundled.js runs `npm audit` and fails on a finding in a
# bundled package, at moderate or above. Weekly rather than on every PR: a new advisory must not turn an
# unrelated change red.
on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:

permissions:
contents: read

jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm

- name: Install dependencies
run: npm ci

- name: Build the library, which writes the SBOM
run: npm run build-lib

- name: Audit the bundled packages
run: npm run audit:bundled
43 changes: 23 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ permissions:

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# A PR's newer push supersedes the run before it. A push to master does not: each squash-merged commit gets a
# finished run, which is what a bisect needs. Until 2026-10-09 master's runs cancelled each other as well, and four
# PRs merged within a minute left three master commits with no completed run.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
verify:
Expand All @@ -22,10 +25,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm
Expand Down Expand Up @@ -152,10 +155,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm
Expand Down Expand Up @@ -189,10 +192,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm
Expand Down Expand Up @@ -241,10 +244,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm
Expand All @@ -266,7 +269,7 @@ jobs:

- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}
Expand Down Expand Up @@ -312,7 +315,7 @@ jobs:

- name: Upload the Playwright report
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-report
path: |
Expand All @@ -334,10 +337,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm
Expand All @@ -351,7 +354,7 @@ jobs:

- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-engines-${{ runner.os }}-${{ steps.playwright.outputs.version }}
Expand All @@ -369,7 +372,7 @@ jobs:

- name: Upload the Playwright report
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-report-engines
path: playwright-report/
Expand All @@ -389,10 +392,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm
Expand All @@ -413,10 +416,10 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm
Expand All @@ -428,7 +431,7 @@ jobs:
run: npm run build-lib

- name: Set up Node.js 22, the published floor
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22

Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ The modernization roadmap (React 17/18 upgrade, `semantic-ui-react` exit, projec
- `npm run test:e2e` — The browser leg (Playwright, `playwright.config.js`): builds the demo for production into `build-e2e/`, serves it on port 3199 and runs `e2e/*.pw.js` in Chromium, desktop and touch. `npm run test:e2e:install` installs Chromium first. `npm run test:e2e:engines` runs the same suite in Firefox and WebKit, desktop and touch (install them with `npm run test:e2e:engines:install`); CI runs it in the advisory `browser-engines` job. A fact one engine does not share goes through a named helper with the measurement behind it, as `buttonsTakeFocus` in `e2e/fixtures.js` does. `npm run test:e2e:react19` runs the Chromium projects on the demo built with the React 19 fixture (`playwright.react19.config.js`, which sets `REACT_FIXTURE` for webpack.demo.config.mjs), in the gating `browser` job; `e2e/react-version.pw.js` checks which React each run renders with `e2e/view-coverage.js` names, for every `view`, the browser tests that drive it or why none needs to, and `scripts/__tests__/view-browser-coverage.contract.test.js` keeps it complete
- `npm run test:types` — `build-lib`, then compiles consumer fixtures against `dist/*.d.ts` under `@types/react` 16, 17, 18 and 19, as an interop default import and as a CommonJS `import = require` (`scripts/test-public-types.js`)
- `npm run test:pack` — `build-lib`, then the packaging budgets (`test:pack:budget`, `scripts/check-package-budget.js`) and a server-render smoke of the packed tarball (`test:pack:consumer`). `npm run test:pack:peers` repeats the smoke on React 16.14, 17.0.2 and 19.3.0, from the same fixtures as the jest legs. `npm run test:pack:browser` (after `build-lib`) loads the packed tarball in Chromium: a throwaway host (`scripts/packed-workspace.js`, shared with the server smoke) bundles it with its own React beside the published `static/all.css`, and `e2e/packed/tarball.packed.js` (`playwright.packed.config.js`) checks that it renders, responds and styles only its own wrapper; CI runs it in the `browser` job. `npm run test:pack:browser:peers` runs the same page on React 16.14, 17 and 19 from the jest legs' fixtures, mounting through `ReactDOM.render` on 16 and 17 as 0.34.x hosts do; CI runs it there too
- `npm run audit:bundled` — `npm audit`, kept to the packages the bundle carries (`dist/sbom.cdx.json`; run `build-lib`
first). A host installs no dependency of ours, so `npm audit --omit=dev` audits nothing and a full `npm audit` reports
the development tree. Fails on a finding at moderate or above; `.github/workflows/audit-bundled.yml` runs it weekly
- `npm run build-css` — Standalone CSS build (LESS → PostCSS prefixwrap → CSS) to `public/static/ui-render.built.css`,
which is committed because the demo site carries it. Rerun it after a style change: `npm run build-css:check`, which
CI runs, fails while the committed copy differs from what it writes
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,29 @@ Note the unrelated legacy `version` attribute seen in older meta files: it is no
version (existing files use it both as a producer version at the root and as a node label deeper
in the tree), the engine discards it, and new files should use `metaVersion`.

## Security model (consumer)

**A meta is code, not content.** Load only metas you would trust as much as your own code: the
renderer does what a meta tells it to.

- **It sets attributes on the elements it renders, raw HTML included.** A `Text` node with
`dangerouslySetInnerHTML: {__html: "…"}` renders that HTML. The renderer checks no URL scheme
in an attribute a meta gives.
- **It calls functions.** A meta calls the actions the renderer has built in, the `methods` you
pass and the `apiCalls` you hand it, whenever it declares. One built-in action is `fetch`:
`{name: "fetch", mapArgs: ["https://…", options]}` calls the page's `fetch` with that URL and
those options.
- **Data is text, unless a meta makes it an attribute.** A value from `data` renders as text,
which React escapes. A meta that binds one to an attribute with `{name: "path"}` sets the value
there as it is.
- **Upload checks run in the browser only.** `formats` and `maxSize` decide what the drop zone
accepts, and your `uploadFile` receives the files as the user chose them. Check their type and
size again on your server.
- **Downloads come from your `downloadFile`.** The renderer requests no URL for one: it saves what
your function answers through a temporary `<a download>`.

To report a vulnerability, see [SECURITY.md](SECURITY.md).

## Renderer configuration (consumer)

Three props configure how values are formatted and how the shell is labelled. They are
Expand Down
24 changes: 24 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Security policy

## Reporting a vulnerability

Report it privately, through GitHub's private vulnerability reporting: the repository's **Security** tab, then
**Report a vulnerability**. Please do not open a public issue or pull request for it.

Say what an attacker controls (a meta, a data value, the page around the widget), what they can do with it, and
which version you found it in. A meta or a page that shows it helps most.

## What is in scope

The renderer does what a meta tells it to, and a meta is trusted configuration: the README's
[Security model](README.md#security-model-consumer) says what a meta can do. A meta that renders raw HTML or calls
`fetch` uses what a meta may use, and is not a vulnerability in itself.

In scope:

- what a **data** value, or a user's input, can do when the meta does not bind it to an attribute;
- the published package: its bundle, and the third-party code bundled into it, which `dist/sbom.cdx.json` lists;
- the stylesheet reaching outside `.ui-render`;
- this repository's build, release and CI.

The bundled packages are audited every week (`npm run audit:bundled`, `.github/workflows/audit-bundled.yml`).
5 changes: 3 additions & 2 deletions docs/UPGRADE-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2141,8 +2141,9 @@ npm run test:pack:consumer # pack, extract, resolve every CSS @import/url(), se
npm pack --dry-run # raw manifest, when a budget failure needs eyeballing

# Security baselines (§2.6-13)
npm audit --omit=dev # must stay at 0
npm audit # 0 on 2026-09-30 (§10), 35 on 2026-10-06 after the ESLint 9 move ("Automated updates" above), not re-measured since; a new finding is new debt to triage
npm audit --omit=dev # 0 while `dependencies` is empty, which leaves it nothing to audit: see audit:bundled below
npm audit # the development tree: 0 on 2026-09-30 (§10), 35 on 2026-10-06 after the ESLint 9 move ("Automated updates" above), 36 on 2026-10-09; a new finding is new debt to triage
npm run audit:bundled # what a host receives: the packages dist/sbom.cdx.json lists (build-lib first); 0 of 19 on 2026-10-09, weekly in CI

# Final-CSS scoping gate (§9.9-H8 — expect 0 unscoped globals after the fix)
# Reads the root payload: dist/static/all.css is only an @import re-export since §0.7.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"test:pack:consumer": "node scripts/test-packed-consumer.js",
"test:pack:browser": "node scripts/test-packed-browser.js",
"test:pack:browser:peers": "node scripts/test-packed-browser.js --peers",
"audit:bundled": "node scripts/audit-bundled.js",
"test:pack:peers": "node scripts/test-packed-react-matrix.js",
"test:env-flags": "node scripts/test-env-flags.js",
"test:react16": "jest --config jest.react16.config.js",
Expand Down
65 changes: 65 additions & 0 deletions scripts/__tests__/audit-bundled.contract.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* `npm run audit:bundled` (scripts/audit-bundled.js) keeps the `npm audit` findings on what the bundle carries.
* Its network half, `npm audit` itself, runs in CI once a week; this pins the half that decides what counts.
*/
const { atLeast, bundledFindings, bundledPackages } = require('../audit-bundled')

const sbom = {
components: [
{ group: '@rc-component', name: 'trigger', version: '2.3.1' },
{ name: 'rc-picker', version: '4.11.3' },
{ name: 'final-form', version: '4.20.10' },
],
}

const advisory = (title, severity) => ({ title, severity, url: `https://example.test/${title}`, range: '<9' })

const audit = {
vulnerabilities: {
// Bundled, at the bundled version: a finding.
'rc-picker': { severity: 'high', nodes: ['node_modules/rc-picker'], via: [advisory('picker', 'high'), 'dayjs'] },
// Bundled, but the vulnerable copy is another version, a development package's own: not ours.
'final-form': { severity: 'moderate', nodes: ['node_modules/some-tool/node_modules/final-form'], via: [advisory('form', 'moderate')] },
// Not bundled at all: the development tree.
eslint: { severity: 'high', nodes: ['node_modules/eslint'], via: [advisory('lint', 'high')] },
},
}

const installed = {
'node_modules/rc-picker': '4.11.3',
'node_modules/some-tool/node_modules/final-form': '4.19.0',
'node_modules/eslint': '9.39.5',
}

describe('audit:bundled', () => {
it('reads the bundled packages out of the SBOM, a scope joined to its name', () => {
expect(bundledPackages(sbom)).toEqual([
{ name: '@rc-component/trigger', version: '2.3.1' },
{ name: 'rc-picker', version: '4.11.3' },
{ name: 'final-form', version: '4.20.10' },
])
})

it('keeps a finding only on a bundled package at the version the bundle carries', () => {
const findings = bundledFindings(bundledPackages(sbom), audit, node => installed[node])
expect(findings).toEqual([{
name: 'rc-picker',
version: '4.11.3',
severity: 'high',
advisories: [{ title: 'picker', severity: 'high', url: 'https://example.test/picker', range: '<9' }],
through: ['dayjs'],
}])
})

it('finds nothing in a report with no vulnerabilities', () => {
expect(bundledFindings(bundledPackages(sbom), { vulnerabilities: {} }, () => undefined)).toEqual([])
expect(bundledFindings(bundledPackages({}), audit, node => installed[node])).toEqual([])
})

it('compares severities in npm audit order', () => {
expect(atLeast('high', 'moderate')).toBe(true)
expect(atLeast('moderate', 'moderate')).toBe(true)
expect(atLeast('low', 'moderate')).toBe(false)
expect(atLeast('critical', 'high')).toBe(true)
})
})
Loading
Loading