diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e282428..81d04f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,25 @@ jobs: - run: npm ci - run: npm run test + integration: + name: Integration tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + # The main process runs the real Python sidecar. + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv sync --frozen --directory python + - run: npm run test:integration + e2e: - name: E2E smoke test + name: E2E tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -63,6 +80,11 @@ jobs: node-version-file: .nvmrc cache: npm - run: npm ci + # Saving a project runs the Python sidecar's policy compiler. + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv sync --frozen --directory python # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces Chromium's sandbox needs. - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - run: xvfb-run -a npm run test:e2e diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml new file mode 100644 index 0000000..9f094c3 --- /dev/null +++ b/.github/workflows/commits.yml @@ -0,0 +1,23 @@ +name: Commits + +# Every commit a pull request adds follows the commit rules: +# signed off by its author (git commit -s), a title of at most 80 characters with no ending +# punctuation, and a blank line before any body. +on: + pull_request: + +permissions: + contents: read + +jobs: + rules: + name: Sign-off and titles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: node scripts/commit-checks.mjs ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..4a152d3 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,86 @@ +name: Coverage + +# Measures test coverage and uploads it to Codecov, one flag per language (badges in README.md). +# Codecov's GitHub app authenticates the upload through OIDC: no token, so it runs in the +# org's own repository only, for pushes and same-repository pull requests (forks get no OIDC token). +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: coverage-${{ github.ref }} + cancel-in-progress: true + +jobs: + typescript: + name: TypeScript + if: github.repository_owner == 'cfengine' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run test:coverage + - uses: actions/upload-artifact@v7 + with: + name: coverage-typescript + path: coverage/lcov.info + retention-days: 1 + + python: + name: Python + if: github.repository_owner == 'cfengine' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + - run: uv sync --frozen --directory python + - run: uv run --no-sync --directory python pytest --cov --cov-report=xml + - uses: actions/upload-artifact@v7 + with: + name: coverage-python + path: python/coverage.xml + retention-days: 1 + + # Only this job gets an OIDC token: it runs none of the project's code or dependencies. + upload: + name: Upload + needs: [typescript, python] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + # Codecov maps the reports' paths onto the repository's files. + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v7 + with: + name: coverage-typescript + path: coverage + - uses: actions/download-artifact@v7 + with: + name: coverage-python + path: python + - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 + with: + use_oidc: true + files: coverage/lcov.info + flags: typescript + fail_ci_if_error: true + - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 + with: + use_oidc: true + files: python/coverage.xml + flags: python + fail_ci_if_error: true diff --git a/.github/workflows/electron-security.yml b/.github/workflows/electron-security.yml new file mode 100644 index 0000000..e9392fa --- /dev/null +++ b/.github/workflows/electron-security.yml @@ -0,0 +1,71 @@ +name: Electron security + +# Checks the app follows Electron's security checklist (SECURITY-CHECKLIST.md): in the source, +# in the running window, and in the packaged binary. Weekly too: Electron support can lapse +# and advisories appear without any change here. +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: electron-security-${{ github.ref }} + cancel-in-progress: true + +jobs: + source: + name: Source checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: node scripts/security-checks.mjs + + electron: + name: Supported Electron, no advisories + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: node scripts/electron-support-check.mjs + + runtime: + name: Running window + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces Chromium's sandbox needs. + - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - run: npx electron-vite build + - run: xvfb-run -a npx playwright test -c e2e/playwright.config.ts e2e/security.spec.ts + + packaged: + name: Fuses in the packaged app + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npx electron-vite build + # The sidecar isn't what's checked here: an empty bundle folder satisfies extraResources. + - run: mkdir -p python/dist/cfpb-backend + - run: npx electron-builder --linux --dir + - run: node scripts/fuses-check.mjs release/linux-unpacked/cfengine-policy-builder diff --git a/.gitignore b/.gitignore index e2ae8b9..58ed95d 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,7 @@ python/build/ # Playwright (e2e/) test-results/ playwright-report/ + +# Python coverage (npm run backend:coverage) +.coverage +coverage.xml diff --git a/README.md b/README.md index 2ce79c7..b602a2f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # CFEngine Policy Builder +[![CI](https://github.com/cfengine/policy-builder/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/cfengine/policy-builder/actions/workflows/ci.yml) +[![Electron security](https://github.com/cfengine/policy-builder/actions/workflows/electron-security.yml/badge.svg?branch=main)](SECURITY-CHECKLIST.md) +[![TypeScript coverage](https://img.shields.io/codecov/c/github/cfengine/policy-builder/main?flag=typescript&label=TypeScript%20coverage)](https://app.codecov.io/gh/cfengine/policy-builder?flags%5B0%5D=typescript) +[![Python coverage](https://img.shields.io/codecov/c/github/cfengine/policy-builder/main?flag=python&label=Python%20coverage)](https://app.codecov.io/gh/cfengine/policy-builder?flags%5B0%5D=python) + Desktop application for building CFEngine policy. **Stack:** @@ -27,6 +32,11 @@ npm run dev # launches Electron app prerequisite. Without it, the app runs but anything touching the backend reports that it cannot be found. +## Security + +How the app follows Electron's security recommendations, item by item, and +how that is checked: [SECURITY-CHECKLIST.md](SECURITY-CHECKLIST.md). + ## Project structure electron-vite builds three targets from `src/`: diff --git a/SECURITY-CHECKLIST.md b/SECURITY-CHECKLIST.md new file mode 100644 index 0000000..c4d8796 --- /dev/null +++ b/SECURITY-CHECKLIST.md @@ -0,0 +1,46 @@ +# Electron security checklist + +[![Electron security](https://github.com/cfengine/policy-builder/actions/workflows/electron-security.yml/badge.svg?branch=main)](https://github.com/cfengine/policy-builder/actions/workflows/electron-security.yml) + +How CFEngine Policy Builder follows Electron's +[security recommendations](https://www.electronjs.org/docs/latest/tutorial/security#checklist-security-recommendations), +item by item, with the code that shows it. The +[Electron security](.github/workflows/electron-security.yml) workflow checks every item marked ✅ +on each push and pull request, and weekly: + +- **Source checks:** [`scripts/security-checks.mjs`](scripts/security-checks.mjs) reads the code and config each item depends on. +- **Running window:** [`e2e/security.spec.ts`](e2e/security.spec.ts) inspects the real app's window: its web preferences, CSP, permissions, navigation and popups. +- **Packaged app:** [`scripts/fuses-check.mjs`](scripts/fuses-check.mjs) reads the fuses from a packaged binary. +- **Electron version:** [`scripts/electron-support-check.mjs`](scripts/electron-support-check.mjs) checks the Electron major is still supported and `npm audit` has no advisory for it. + +| # | Recommendation | Status | Evidence | +| --- | --- | --- | --- | +| 1 | [Only load secure content](https://www.electronjs.org/docs/latest/tutorial/security#1-only-load-secure-content) | ✅ | The packaged app loads its own files only, from `app://bundle/` (item 18). Everything the main process fetches itself is https: the Enterprise hub (`checkedUrl` in [`src/main/hub.ts`](src/main/hub.ts) refuses anything else), the masterfiles versions index (`VERSIONS_URL` in [`src/main/project.ts`](src/main/project.ts)). `http://` appears only for the development server on localhost. The exception is a project's git remote: the user's choice of URL (`REMOTE_URL` in [`src/main/deploy.ts`](src/main/deploy.ts) also takes `http://`, `git://`, `file://` and local paths), fetched and pushed by the system's git. | +| 2 | [Do not enable Node.js integration for remote content](https://www.electronjs.org/docs/latest/tutorial/security#2-do-not-enable-nodejs-integration-for-remote-content) | ✅ | `nodeIntegration` is never enabled (`createWindow` in [`src/main/index.ts`](src/main/index.ts)); the running page has no `require` or `process`. | +| 3 | [Enable context isolation](https://www.electronjs.org/docs/latest/tutorial/security#3-enable-context-isolation) | ✅ | `contextIsolation: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)). | +| 4 | [Enable process sandboxing](https://www.electronjs.org/docs/latest/tutorial/security#4-enable-process-sandboxing) | ✅ | `sandbox: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)); no `--no-sandbox`. | +| 5 | [Handle session permission requests from remote content](https://www.electronjs.org/docs/latest/tutorial/security#5-handle-session-permission-requests-from-remote-content) | ✅ | `setPermissionRequestHandler` and `setPermissionCheckHandler` in [`src/main/index.ts`](src/main/index.ts) grant only `clipboard-sanitized-write` (Copy in Generated Policy) and deny the rest, whether requested or only checked. | +| 6 | [Do not disable `webSecurity`](https://www.electronjs.org/docs/latest/tutorial/security#6-do-not-disable-websecurity) | ✅ | Never set; the running window reports `webSecurity: true`. | +| 7 | [Define a Content Security Policy](https://www.electronjs.org/docs/latest/tutorial/security#7-define-a-content-security-policy) | ✅ | The `` in [`src/renderer/index.html`](src/renderer/index.html): `default-src 'self'`, `script-src 'self'` (no inline scripts, no `eval`). `style-src` allows `'unsafe-inline'`, which Emotion/MUI need for their injected styles. | +| 8 | [Do not enable `allowRunningInsecureContent`](https://www.electronjs.org/docs/latest/tutorial/security#8-do-not-enable-allowrunninginsecurecontent) | ✅ | Never set; the running window reports `false`. | +| 9 | [Do not enable experimental features](https://www.electronjs.org/docs/latest/tutorial/security#9-do-not-enable-experimental-features) | ✅ | Never set; the running window reports `false`. | +| 10 | [Do not use `enableBlinkFeatures`](https://www.electronjs.org/docs/latest/tutorial/security#10-do-not-use-enableblinkfeatures) | ✅ | Never set. | +| 11 | [Do not use `allowpopups` for WebViews](https://www.electronjs.org/docs/latest/tutorial/security#11-do-not-use-allowpopups-for-webviews) | ✅ | The app has no ``. | +| 12 | [Verify WebView options before creation](https://www.electronjs.org/docs/latest/tutorial/security#12-verify-webview-options-before-creation) | ✅ | `webviewTag` stays off (the running window reports `false`), so no webview can be attached. | +| 13 | [Disable or limit navigation](https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation) | ✅ | The `will-navigate` handler in [`src/main/index.ts`](src/main/index.ts) prevents any navigation away from the loaded page; http(s) links go to the system browser instead. | +| 14 | [Disable or limit creation of new windows](https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows) | ✅ | `setWindowOpenHandler` in [`src/main/index.ts`](src/main/index.ts) always returns `{ action: 'deny' }`; http(s) links open in the system browser. | +| 15 | [Do not use `shell.openExternal` with untrusted content](https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-shellopenexternal-with-untrusted-content) | ✅ | The only `shell.openExternal` call is in `openExternalIfSafe` ([`src/main/index.ts`](src/main/index.ts)), which passes only `http:` and `https:` URLs. | +| 16 | [Use a current version of Electron](https://www.electronjs.org/docs/latest/tutorial/security#16-use-a-current-version-of-electron) | ✅ | Electron 44 ([`package.json`](package.json)), checked weekly against Electron's supported majors and `npm audit`. | +| 17 | [Validate the sender of all IPC messages](https://www.electronjs.org/docs/latest/tutorial/security#17-validate-the-sender-of-all-ipc-messages) | ✅ | Every `ipcMain.handle` checks `isTrustedFrame` ([`src/main/index.ts`](src/main/index.ts)): the top frame of our own page only. Feature modules wrap their handlers in `trusted(…)` ([`project.ts`](src/main/project.ts), [`deploy.ts`](src/main/deploy.ts), [`hub.ts`](src/main/hub.ts), [`testenv.ts`](src/main/testenv.ts)). Unit tests check untrusted senders are refused ([`project.test.ts`](src/main/project.test.ts), [`deploy.test.ts`](src/main/deploy.test.ts)). | +| 18 | [Avoid usage of the `file` protocol and prefer usage of custom protocols](https://www.electronjs.org/docs/latest/tutorial/security#18-avoid-usage-of-the-file-protocol-and-prefer-usage-of-custom-protocols) | ✅ | The packaged app's page is served from `app://bundle/` (`serveRenderer` in [`src/main/index.ts`](src/main/index.ts)), which maps to the built renderer folder and returns 404 for anything outside it, encoded `..` included. Nothing calls `loadFile`, and the `GrantFileProtocolExtraPrivileges` fuse is off ([`electron-builder.yml`](electron-builder.yml)). | +| 19 | [Check which fuses you can change](https://www.electronjs.org/docs/latest/tutorial/security#19-check-which-fuses-you-can-change) | ✅ | `electronFuses` in [`electron-builder.yml`](electron-builder.yml): `RunAsNode`, `EnableNodeOptionsEnvironmentVariable`, `EnableNodeCliInspectArguments` and `GrantFileProtocolExtraPrivileges` off; `EnableCookieEncryption`, `OnlyLoadAppFromAsar` and `EnableEmbeddedAsarIntegrityValidation` on. Read back from the packaged binary. | +| 20 | [Do not expose Electron APIs to untrusted web content](https://www.electronjs.org/docs/latest/tutorial/security#20-do-not-expose-electron-apis-to-untrusted-web-content) | ✅ | The preload ([`src/preload/index.ts`](src/preload/index.ts)) exposes one object, `window.api`, of functions over fixed channels; never `ipcRenderer` itself, and event listeners get only the payload, not the `IpcRendererEvent`. | + +## Running the checks locally + +```sh +node scripts/security-checks.mjs # source checks +node scripts/electron-support-check.mjs # supported Electron, npm audit +npx electron-vite build && npx playwright test -c e2e/playwright.config.ts e2e/security.spec.ts +npx electron-builder --dir && node scripts/fuses-check.mjs "release/mac/CFEngine Policy Builder.app" +``` diff --git a/blocks/README.md b/blocks/README.md index 66222a0..1db940a 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -19,9 +19,9 @@ Schema can't: every `{{placeholder}}` is a parameter and every parameter is used `outcome_step`/`name_param` point at something real, defaults match their type and options. It runs in CI with the other backend tests (`npm run backend:test`). -Compiled policy targets **CFEngine 3.24 and later** (verified on 3.28). Nothing in the contract is -newer than 3.17 — `content` (3.16), `execresult()`'s output selector (3.17), `edit_template_string` -and `inline_mustache` (3.12) — so anything added should keep to 3.24's language. +Compiled policy targets **CFEngine 3.27 and later** (verified on 3.27.1 and 3.28): every file +evaluates top-down (`evaluation_order => "top_down"`, new in 3.27), so promises run in canvas order. +Projects are created on masterfiles 3.27 or master. Nothing is released yet, so the schemas stay at v1 and change in place. Once released, a change that affects stored instances or compiled output bumps the block's `version` (migrations key on @@ -29,12 +29,11 @@ affects stored instances or compiled output bumps the block's `version` (migrati ## Descriptors -- **`compile_target`** — `own_bundle`: a sequenced block, compiled to its own `bundle agent`, with - outcomes and arrows. `file_vars`: its entries compile into the file's shared `bundle common vars` - (Define Variable, Define Class) — not sequenced, no arrows. -- **`steps`** — ordered promises; each compiles to its own chained bundle, called in order. That's - how a block controls ordering across promise types (CFEngine evaluates promise types within one - bundle in a fixed order). Every built-in block has one step today. +- **`compile_target`** — `own_bundle`: a sequenced block, compiled to a promise of the file's entry + bundle, with outcomes and arrows (the name predates single-bundle files). `file_vars`: its + entries compile into the file's shared `bundle common vars` (Define Variable, Define Class) — + not sequenced, no arrows. +- **`steps`** — ordered promises. Every built-in block has one step; the compiler refuses more. - **`value_sources`** — instead of `steps`/`parameters`, for blocks whose promise depends on how the value is produced (Define Variable: literal, command output, file…; Define Class: check, combine, custom…). Each source has one step and its own parameters, on top of the shared ones. `value_type` @@ -43,33 +42,95 @@ affects stored instances or compiled output bumps the block's `version` (migrati - **`entries`** — one instance holds a list of definitions (several variables or classes), each with its own name (`name_param`), value source, parameters and optional condition. - **`outcome_step`**, **`arrow_default_outcome`** — see "Outcomes and arrows" below. +- **`summary`** (value sources and decorators) — a one-line pattern with `{{param}}` placeholders, + e.g. `split into a list on "{{delimiter}}"`. The compiler fills them in and writes a chain's steps + as a comment above its promise (`# The stdout of "/usr/bin/x"` / `# → sort (lex)`); without one, + the label is used. ## Compiled shape (per policy file) +Each file is a **namespace** of its own (``, from its name, unique project-wide — see +`filesSlice/deriveNamespace.ts`), so its names can't clash with another file's, masterfiles' or any +other module's: + ```cfengine3 -body file control { namespace => ""; } +# Generated by CFEngine Policy Builder from "". … +body file control { namespace => ""; evaluation_order => "top_down"; } bundle common vars # every file_vars entry of the file { - vars: "" => , if => ; - classes: "" => ; + vars: + :: # only with a file condition + "" => , if => ""; + classes: + "" => ; } -bundle agent # called via cfbs; the file condition guards this call +bundle agent main # what the cfbs `bundles :main` step runs; absent when the file only defines variables and classes { + # # every ungrouped block, in canvas order + vars: # the block's locals, if any, named _ + :: + "_" => ; + : + :: + "" , + if => ".", + classes => default:results("bundle", ""); # only if an arrow starts here + + # Group: # a group, as one step, where it falls in the order methods: - "