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
91 changes: 91 additions & 0 deletions .github/workflows/build-attest-dist.yaml
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions .github/workflows/verify-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
90 changes: 89 additions & 1 deletion attest/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,89 @@
# cimon-attest
# 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://<key-id>` (HashiCorp Vault transit) or
`--kms awskms://<arn>` (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.
6 changes: 3 additions & 3 deletions attest/action.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -87,5 +87,5 @@ inputs:
default: 'false'

runs:
using: node24
using: node20
main: 'dist/index.js'
49 changes: 38 additions & 11 deletions attest/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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' ||
Expand Down
Loading
Loading