From cb16539853bbe4795a7cce5144f3e592a5571b13 Mon Sep 17 00:00:00 2001 From: Ronen Slavin Date: Sat, 16 May 2026 18:29:01 +0300 Subject: [PATCH] feat(attest): post-#118 hardening + Windows CI verification + docs #118 already shipped the cross-platform install path (release-zip from cycodelabs/cimon-releases, ncc 0.38 upgrade), so this branch's earlier install.ps1-based implementation is dropped. The remaining pieces from this PR that #118 didn't cover are kept here: 1. Per-job install dir + wipe-before-install ($RUNNER_TEMP/cimon---/) Closes the silent-reuse hole where a malicious earlier step on a self-hosted runner with persistent $RUNNER_TEMP could plant cimon[.exe] or install.sh at the action's expected path and the action would execute it without re-downloading. Applied uniformly to the Linux install.sh path and the Windows release-zip path. 2. Hardened build-attest-dist.yaml - npm ci --ignore-scripts so a PR adding a malicious dependency can't run a postinstall hook under contents:write before the auto-commit lands. - Single-file commit guard: the step refuses to act unless the only changed file is exactly attest/dist/index.js. Anything else fails the workflow loudly instead of silently landing under github-actions[bot]. - Fork-safe checkout: pin to pull_request.head.sha and set repository to pull_request.head.repo.full_name. Without these, actions/checkout would try to resolve github.head_ref in the base repo, which does not contain the forks branch, and the rebuild job would fail before it could run at all. - Auto-commit gated to same-repo PRs. On fork PRs the base-repo GITHUB_TOKEN is read-only and cannot push to the fork anyway, so the step fails with a clear rebuild-locally instruction instead of attempting a 403ing push. 3. verify-attest-windows CI job in verify-pr.yaml Exercises ./attest end-to-end on windows-latest with keyed signing. No longer gated by continue-on-error: the comment about install.ps1 missing from S3 is obsolete now that #118 fetches cimon_windows_x86_64.zip directly from the GitHub release. 4. attest/README.md Cross-platform quickstart, GHES support notes, signing-path guidance for air-gapped / data-residency customers. 5. attest/action.yml Name + description disambiguated from the top-level hardening action ("Cimon by Cycode (Attest)" + cross-platform mention). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-attest-dist.yaml | 91 ++++++++++++++++++++++++ .github/workflows/verify-pr.yaml | 42 +++++++++++ attest/README.md | 90 ++++++++++++++++++++++- attest/action.yml | 6 +- attest/dist/index.js | 49 ++++++++++--- attest/index.js | 49 ++++++++++--- 6 files changed, 301 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/build-attest-dist.yaml diff --git a/.github/workflows/build-attest-dist.yaml b/.github/workflows/build-attest-dist.yaml new file mode 100644 index 0000000..da9b179 --- /dev/null +++ b/.github/workflows/build-attest-dist.yaml @@ -0,0 +1,91 @@ +name: Rebuild attest dist + +# Rebuilds attest/dist/index.js when attest/index.js changes. The bundled +# dist file is what the action runtime executes; both the source and the +# bundle must be committed. +on: + pull_request: + paths: + - 'attest/index.js' + - 'attest/package.json' + - 'attest/package-lock.json' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + rebuild-dist: + runs-on: ubuntu-22.04 + steps: + # Resolve the PR head explicitly. github.head_ref is just the + # branch name; without setting `repository`, actions/checkout + # would look for that branch in the base repo, which doesn't + # have it when the PR comes from a fork — the job would fail + # before the rebuild can run. We pin to the head SHA so a force + # push between trigger and checkout doesn't race us, and point + # `repository` at the head's repo so forks resolve correctly. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install deps + rebuild dist + working-directory: attest + run: | + # SECURITY: --ignore-scripts disables npm lifecycle hooks + # (preinstall / postinstall / prepare). Without this, a PR + # that adds a malicious dependency could execute arbitrary + # code in CI under contents:write permission. The ncc bundler + # itself doesn't need lifecycle scripts to run. + npm ci --ignore-scripts + npm run dist/index.js + + - name: Verify or auto-commit refreshed dist + if: github.event_name == 'pull_request' + env: + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + BASE_REPO: ${{ github.repository }} + run: | + # SECURITY: only act if the rebuild produced exactly one + # changed file — attest/dist/index.js. Anything else (a new + # node_modules entry leaking in, a sneakily-modified workflow + # file, etc.) means the PR did something the rebuild step + # shouldn't be vouching for, so fail loudly instead of + # auto-committing it under github-actions[bot] identity. + changed=$(git status --porcelain | awk '{print $2}' | sort) + expected="attest/dist/index.js" + + if [ -z "$changed" ]; then + echo "dist already up to date" + exit 0 + fi + + if [ "$changed" != "$expected" ]; then + echo "::error::Rebuild produced unexpected changes; refusing to act:" + echo "$changed" + exit 1 + fi + + # Fork PRs: the base-repo GITHUB_TOKEN is read-only here and + # can't push to the fork's branch anyway. Fail with clear + # instructions instead of attempting a push that will 403. + if [ "$PR_HEAD_REPO" != "$BASE_REPO" ]; then + echo "::error::attest/dist/index.js is out of sync with attest/index.js." + echo "::error::This PR is from a fork, so the rebuild can't auto-commit." + echo "::error::Please run locally and push:" + echo "::error:: cd attest && npm ci --ignore-scripts && npm run dist/index.js" + echo "::error:: git add attest/dist/index.js && git commit -m 'rebuild dist'" + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add attest/dist/index.js + git commit -m "chore(attest): rebuild dist/index.js" + git push diff --git a/.github/workflows/verify-pr.yaml b/.github/workflows/verify-pr.yaml index d9b2663..692e61e 100644 --- a/.github/workflows/verify-pr.yaml +++ b/.github/workflows/verify-pr.yaml @@ -16,6 +16,48 @@ concurrency: cancel-in-progress: true jobs: + verify-attest-windows: + # Validates the cross-platform attestation path on Windows. Uses the + # same `./attest` action the customer would use on a GHES self-hosted + # Windows runner. Keyed signing path (offline, data-residency safe). + # + # As of #118, the Windows install path resolves the latest tag from + # cycodelabs/cimon-releases and downloads cimon_windows_x86_64.zip + # directly from the GitHub release — no S3 install.ps1 dependency, + # so this job is end-to-end runnable. + runs-on: windows-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + + - name: Build sample Windows artifact + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + Set-Content -Path dist/sample.exe -Value "fake-windows-binary-$(Get-Date -Format o)" + + - name: Generate ephemeral signing key + shell: pwsh + run: | + & "C:\Program Files\Git\usr\bin\openssl.exe" genrsa -out private-key.pem 3072 + + - name: Cimon Attest (Windows, keyed) + uses: ./attest + with: + client-id: ${{ secrets.CIMON_CLIENT_ID }} + secret: ${{ secrets.CIMON_SECRET }} + subjects: dist\sample.exe + sign-key: private-key.pem + report-job-summary: true + report-artifact: true + fail-on-error: true + + - name: Show provenance + shell: pwsh + run: Get-Content provenance.intoto.jsonl + verify: runs-on: ubuntu-22.04 steps: diff --git a/attest/README.md b/attest/README.md index fd26195..09de488 100644 --- a/attest/README.md +++ b/attest/README.md @@ -1 +1,89 @@ -# cimon-attest \ No newline at end of file +# cimon-attest + +Generate, sign, and verify SLSA build provenance for artifacts produced +by your CI pipeline. + +This action is **cross-platform**: it works on Linux, Windows, and macOS +GitHub Actions runners (including self-hosted runners on **GitHub +Enterprise Server**). Hardening (`prevent: true`, network policy, etc.) +is implemented on top of eBPF and remains Linux-only — for that, use the +top-level `cycodelabs/cimon-action@v1` action instead. + +## Quick start + +### Linux / macOS + +```yaml +- uses: cycodelabs/cimon-action/attest@v1 + with: + subjects: dist/my-binary + keyless: true + allow-submit-data-to-public-sigstore: true +``` + +### Windows (e.g. GHES self-hosted runner) + +```yaml +- uses: cycodelabs/cimon-action/attest@v1 + with: + subjects: dist\my-app.msi + sign-key: private-key.pem # keyed signing recommended for air-gapped/data-residency +``` + +The action automatically detects the runner platform and downloads the +matching `cimon` binary (`cimon.exe` on Windows). It uses +`$RUNNER_TEMP` so it cooperates with on-prem GHES self-hosted runners +that may not have `/tmp` writable. + +## GHES support + +The action honors `GITHUB_API_URL` and `GITHUB_SERVER_URL` (set +automatically by GHES runners) so build metadata enrichment works +against your enterprise instance, not public github.com. + +## Air-gapped / data-residency signing + +If your environment cannot submit to the public Sigstore transparency +log (e.g. customers in the USA / EU with data-residency requirements), +use one of: + +- **KMS signing**: `--kms vault://` (HashiCorp Vault transit) or + `--kms awskms://` (AWS KMS) — signature stays in your control. +- **Keyed signing with checked-in private key**: `sign-key: private-key.pem`. +- **Private Sigstore**: deploy your own Fulcio + Rekor and pass + `fulcio-server-url` + `rekor-server-url`. + +See the inputs section below for the full list. + +## Inputs + +| Name | Description | Default | +|---|---|---| +| `subjects` | Whitespace-separated list of artifact paths or base64 subjects | — | +| `sign-key` | Path to a private ECDSA/RSA/ED25519 PEM key | — | +| `keyless` | Use keyless (Sigstore) signing | `false` | +| `tlog-upload` | Upload signature to Rekor transparency log | `true` | +| `fulcio-server-url` | Fulcio server URL | `https://fulcio.sigstore.dev` | +| `rekor-server-url` | Rekor server URL | `https://rekor.sigstore.dev` | +| `timestamp-server-url` | RFC3161 timestamp server URL | — | +| `allow-submit-data-to-public-sigstore` | Required when using public Sigstore | `false` | +| `provenance-output` | Path for unsigned provenance | `provenance.intoto.jsonl` | +| `signed-provenance-output` | Path for signed provenance | `provenance.intoto.jsonl.sig` | +| `report-job-summary` | Render provenance in the workflow job summary | `true` | +| `report-artifact` | Upload provenance as a workflow artifact | `true` | +| `github-token` | Token used for build metadata enrichment | `${{ github.token }}` | +| `log-level` | `trace` / `debug` / `info` | `info` | +| `fail-on-error` | Fail the step on attestation errors | `false` | + +## Development + +After editing `index.js`, rebuild the bundled distribution: + +```bash +cd attest +npm install +npm run dist/index.js # invokes ncc build +``` + +The bundled `dist/index.js` is what the action runtime executes; both +`index.js` and `dist/index.js` must be committed. diff --git a/attest/action.yml b/attest/action.yml index c99ebd8..5f4fcb1 100644 --- a/attest/action.yml +++ b/attest/action.yml @@ -1,5 +1,5 @@ -name: Cimon by Cycode -description: Runtime Security Solution for your CI/CD Pipeline +name: Cimon by Cycode (Attest) +description: Generate, sign, and verify SLSA build provenance attestations. Supports Linux, Windows, and macOS runners (including GitHub Enterprise Server). branding: icon: shield color: green @@ -87,5 +87,5 @@ inputs: default: 'false' runs: - using: node24 + using: node20 main: 'dist/index.js' diff --git a/attest/dist/index.js b/attest/dist/index.js index 0383754..5c0db1c 100644 --- a/attest/dist/index.js +++ b/attest/dist/index.js @@ -127569,12 +127569,28 @@ __nccwpck_require__.a(__webpack_module__, async (__webpack_handle_async_dependen const IS_WINDOWS = process.platform === 'win32'; -// Linux: bootstrap from the cimon-releases install.sh, same as before. +// SECURITY: scope every install under a unique per-job subdirectory so a +// malicious previous step / job on a self-hosted runner cannot plant a +// pre-existing cimon binary or install script that we'd silently reuse. +// GHES self-hosted runners commonly persist $RUNNER_TEMP between jobs; +// public hosted runners give us a fresh VM, but we apply the same +// hardening unconditionally. +const CIMON_TMP_BASE = process.env.RUNNER_TEMP || os__WEBPACK_IMPORTED_MODULE_7__.tmpdir(); +const CIMON_RUN_KEY = [ + process.env.GITHUB_RUN_ID || 'local', + process.env.GITHUB_RUN_ATTEMPT || '0', + process.env.GITHUB_JOB || 'job', +] + .map((s) => String(s).replace(/[^A-Za-z0-9._-]/g, '_')) + .join('-'); +const CIMON_TMP_DIR = path__WEBPACK_IMPORTED_MODULE_5__.join(CIMON_TMP_BASE, `cimon-${CIMON_RUN_KEY}`); + +// Linux: bootstrap from the cimon-releases install.sh. const CIMON_SCRIPT_DOWNLOAD_URL = 'https://cimon-releases.s3.amazonaws.com/install.sh'; -const CIMON_SCRIPT_PATH = '/tmp/install.sh'; -const CIMON_EXECUTABLE_DIR = '/tmp/cimon'; -const CIMON_EXECUTABLE_PATH = '/tmp/cimon/cimon'; +const CIMON_SCRIPT_PATH = path__WEBPACK_IMPORTED_MODULE_5__.join(CIMON_TMP_DIR, 'install.sh'); +const CIMON_EXECUTABLE_DIR = path__WEBPACK_IMPORTED_MODULE_5__.join(CIMON_TMP_DIR, 'cimon'); +const CIMON_EXECUTABLE_PATH = path__WEBPACK_IMPORTED_MODULE_5__.join(CIMON_EXECUTABLE_DIR, 'cimon'); // Windows: install.ps1 is not yet published to S3, so we fetch the // platform-specific zip directly from the cimon-releases GitHub release. @@ -127604,13 +127620,17 @@ async function downloadToFile(url, filePath) { // which ignores Authorization, but using it on the api.github.com side // is what matters. async function installCimonWindows(githubToken) { - const tmpDir = process.env.RUNNER_TEMP || os__WEBPACK_IMPORTED_MODULE_7__.tmpdir(); - const installDir = path__WEBPACK_IMPORTED_MODULE_5__.join(tmpDir, 'cimon'); + const installDir = CIMON_EXECUTABLE_DIR; const exePath = path__WEBPACK_IMPORTED_MODULE_5__.join(installDir, 'cimon.exe'); - if (fs__WEBPACK_IMPORTED_MODULE_6__.existsSync(exePath)) { - return exePath; + // SECURITY: wipe any stale state before install, even within the + // per-job subdir computed at module load. This defends against a + // malicious step earlier in this same job planting a pre-built + // cimon.exe at our expected path. + if (fs__WEBPACK_IMPORTED_MODULE_6__.existsSync(installDir)) { + fs__WEBPACK_IMPORTED_MODULE_6__.rmSync(installDir, { recursive: true, force: true }); } + fs__WEBPACK_IMPORTED_MODULE_6__.mkdirSync(installDir, { recursive: true }); const authHeaders = {}; if (githubToken) { @@ -127713,11 +127733,18 @@ async function run(config) { } else { _actions_core__WEBPACK_IMPORTED_MODULE_0__.info('Running Cimon from latest release path'); - if (!fs__WEBPACK_IMPORTED_MODULE_6__.existsSync(CIMON_SCRIPT_PATH)) { - await downloadToFile(CIMON_SCRIPT_DOWNLOAD_URL, CIMON_SCRIPT_PATH); + // SECURITY: wipe any stale state under the per-job subdir before + // install, then always re-download the install script. Defends + // against a malicious earlier step in this job planting an + // install script or pre-built cimon binary at our expected paths. + if (fs__WEBPACK_IMPORTED_MODULE_6__.existsSync(CIMON_TMP_DIR)) { + fs__WEBPACK_IMPORTED_MODULE_6__.rmSync(CIMON_TMP_DIR, { recursive: true, force: true }); } + fs__WEBPACK_IMPORTED_MODULE_6__.mkdirSync(CIMON_TMP_DIR, { recursive: true }); - if (!fs__WEBPACK_IMPORTED_MODULE_6__.existsSync(CIMON_EXECUTABLE_DIR)) { + await downloadToFile(CIMON_SCRIPT_DOWNLOAD_URL, CIMON_SCRIPT_PATH); + + { let params = [CIMON_SCRIPT_PATH, '-b', CIMON_EXECUTABLE_DIR]; if ( config.cimon.logLevel == 'debug' || diff --git a/attest/index.js b/attest/index.js index 9eea16c..f2ae59d 100644 --- a/attest/index.js +++ b/attest/index.js @@ -9,12 +9,28 @@ import os from 'os'; const IS_WINDOWS = process.platform === 'win32'; -// Linux: bootstrap from the cimon-releases install.sh, same as before. +// SECURITY: scope every install under a unique per-job subdirectory so a +// malicious previous step / job on a self-hosted runner cannot plant a +// pre-existing cimon binary or install script that we'd silently reuse. +// GHES self-hosted runners commonly persist $RUNNER_TEMP between jobs; +// public hosted runners give us a fresh VM, but we apply the same +// hardening unconditionally. +const CIMON_TMP_BASE = process.env.RUNNER_TEMP || os.tmpdir(); +const CIMON_RUN_KEY = [ + process.env.GITHUB_RUN_ID || 'local', + process.env.GITHUB_RUN_ATTEMPT || '0', + process.env.GITHUB_JOB || 'job', +] + .map((s) => String(s).replace(/[^A-Za-z0-9._-]/g, '_')) + .join('-'); +const CIMON_TMP_DIR = path.join(CIMON_TMP_BASE, `cimon-${CIMON_RUN_KEY}`); + +// Linux: bootstrap from the cimon-releases install.sh. const CIMON_SCRIPT_DOWNLOAD_URL = 'https://cimon-releases.s3.amazonaws.com/install.sh'; -const CIMON_SCRIPT_PATH = '/tmp/install.sh'; -const CIMON_EXECUTABLE_DIR = '/tmp/cimon'; -const CIMON_EXECUTABLE_PATH = '/tmp/cimon/cimon'; +const CIMON_SCRIPT_PATH = path.join(CIMON_TMP_DIR, 'install.sh'); +const CIMON_EXECUTABLE_DIR = path.join(CIMON_TMP_DIR, 'cimon'); +const CIMON_EXECUTABLE_PATH = path.join(CIMON_EXECUTABLE_DIR, 'cimon'); // Windows: install.ps1 is not yet published to S3, so we fetch the // platform-specific zip directly from the cimon-releases GitHub release. @@ -44,13 +60,17 @@ async function downloadToFile(url, filePath) { // which ignores Authorization, but using it on the api.github.com side // is what matters. async function installCimonWindows(githubToken) { - const tmpDir = process.env.RUNNER_TEMP || os.tmpdir(); - const installDir = path.join(tmpDir, 'cimon'); + const installDir = CIMON_EXECUTABLE_DIR; const exePath = path.join(installDir, 'cimon.exe'); - if (fs.existsSync(exePath)) { - return exePath; + // SECURITY: wipe any stale state before install, even within the + // per-job subdir computed at module load. This defends against a + // malicious step earlier in this same job planting a pre-built + // cimon.exe at our expected path. + if (fs.existsSync(installDir)) { + fs.rmSync(installDir, { recursive: true, force: true }); } + fs.mkdirSync(installDir, { recursive: true }); const authHeaders = {}; if (githubToken) { @@ -153,11 +173,18 @@ async function run(config) { } else { core.info('Running Cimon from latest release path'); - if (!fs.existsSync(CIMON_SCRIPT_PATH)) { - await downloadToFile(CIMON_SCRIPT_DOWNLOAD_URL, CIMON_SCRIPT_PATH); + // SECURITY: wipe any stale state under the per-job subdir before + // install, then always re-download the install script. Defends + // against a malicious earlier step in this job planting an + // install script or pre-built cimon binary at our expected paths. + if (fs.existsSync(CIMON_TMP_DIR)) { + fs.rmSync(CIMON_TMP_DIR, { recursive: true, force: true }); } + fs.mkdirSync(CIMON_TMP_DIR, { recursive: true }); + + await downloadToFile(CIMON_SCRIPT_DOWNLOAD_URL, CIMON_SCRIPT_PATH); - if (!fs.existsSync(CIMON_EXECUTABLE_DIR)) { + { let params = [CIMON_SCRIPT_PATH, '-b', CIMON_EXECUTABLE_DIR]; if ( config.cimon.logLevel == 'debug' ||