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
+[](https://github.com/cfengine/policy-builder/actions/workflows/ci.yml)
+[](SECURITY-CHECKLIST.md)
+[](https://app.codecov.io/gh/cfengine/policy-builder?flags%5B0%5D=typescript)
+[](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
+
+[](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:
- "