Skip to content
Open
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
123 changes: 122 additions & 1 deletion .github/workflows/docker-build-push-jfrog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,31 @@
required: false
type: boolean
default: true
sign_image:
description: "Sign the pushed image with JFrog Evidence"
required: false
type: boolean
default: true
signing_key_alias:
description: "JFrog key alias for the Evidence signing key"
required: false
type: string
default: "jfrog-evidence-image-signing"
predicate_type:
description: "Predicate type URI for the signature Evidence"
required: false
type: string
default: "https://jfrog.com/evidence/signature/v1"
secrets:
git_token:
description: "Git token to use for checkout"
required: false
infisical_identity_id:
description: "Infisical machine identity ID for the image-signing key (required when sign_image is true)"
required: false
infisical_project_id:
description: "Infisical project ID holding the image-signing private key (required when sign_image is true)"
required: false

permissions:
id-token: write
Expand Down Expand Up @@ -189,7 +210,9 @@
with:
registry: "artifactory"
image_name: ${{ steps.env-vars.outputs.IMAGE_NAME }}
image_tags: ${{ inputs.additional_tags }}
image_tags: |
${{ github.sha }}
${{ inputs.additional_tags }}
Comment on lines +213 to +215
push-to-registry: ${{ inputs.push }}
platforms: ${{ inputs.platforms }}
context: ${{ inputs.context }}
Expand All @@ -202,3 +225,101 @@
build-args: ${{ inputs.docker_build_args }}
ignore_trivy: ${{ inputs.ignore_trivy }}
run_trivy: ${{ inputs.run_trivy }}

- name: Resolve image digest
id: digest
if: ${{ inputs.push && inputs.sign_image }}
env:
IMAGE_REF: "${{ inputs.jfrog_url }}/${{ steps.env-vars.outputs.IMAGE_NAME }}:${{ github.sha }}"
run: |
set -euo pipefail
DIGEST="$(docker buildx imagetools inspect "$IMAGE_REF" --format '{{json .Manifest.Digest}}' | jq -r .)"
if [[ -z "$DIGEST" || "$DIGEST" != sha256:* ]]; then
echo "Failed to resolve a sha256 digest for $IMAGE_REF (got: '$DIGEST')." >&2
exit 1
fi
echo "IMAGE_DIGEST=$DIGEST" >> "$GITHUB_OUTPUT"
echo "Resolved $IMAGE_REF -> $DIGEST"

- name: Fetch signing key from Infisical
if: ${{ inputs.push && inputs.sign_image }}
uses: NethermindEth/github-workflows/get_infisical_secrets@main

Check warning

Code scanning / CodeQL

Unpinned tag for a non-immutable Action in workflow or composite action Medium

Unpinned 3rd party Action 'docker-build-push-jfrog.yaml' step
Uses Step
uses 'NethermindEth/github-workflows/get_infisical_secrets' with ref 'main', not a pinned commit hash
Comment on lines +244 to +246
with:
identity-id: ${{ secrets.infisical_identity_id }}
project-id: ${{ secrets.infisical_project_id }}
env-slug: prod
secret-path: "/github/workflows/shared/github-workflows"

- name: Generate signature predicate
if: ${{ inputs.push && inputs.sign_image }}
env:
P_ACTOR: ${{ github.actor }}
P_WORKFLOW: ${{ github.workflow }}
P_RUN_ID: ${{ github.run_id }}
P_COMMIT: ${{ github.sha }}
P_REPOSITORY: ${{ github.repository }}
P_REF: ${{ github.ref }}
P_DIGEST: ${{ steps.digest.outputs.IMAGE_DIGEST }}
run: |
set -euo pipefail
jq -n \
--arg actor "$P_ACTOR" \
--arg workflow "$P_WORKFLOW" \
--arg run_id "$P_RUN_ID" \
--arg commit "$P_COMMIT" \
--arg repository "$P_REPOSITORY" \
--arg ref "$P_REF" \
--arg image_digest "$P_DIGEST" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{actor:$actor, workflow:$workflow, run_id:$run_id, commit:$commit, repository:$repository, ref:$ref, timestamp:$timestamp, image_digest:$image_digest}' \
> predicate.json
cat predicate.json

- name: Sign image with JFrog Evidence
if: ${{ inputs.push && inputs.sign_image }}
env:
IMAGE_NAME: ${{ steps.env-vars.outputs.IMAGE_NAME }}
PACKAGE_VERSION: ${{ github.sha }}
REPO_NAME_INPUT: ${{ inputs.repo_name }}
GROUP_NAME_INPUT: ${{ inputs.group_name }}
PREDICATE_TYPE: ${{ inputs.predicate_type }}
KEY_ALIAS: ${{ inputs.signing_key_alias }}
PRIVATE_KEY: ${{ env.DOCKER_IMAGE_SIGNING_PRIVATE_KEY }}
run: |
set -euo pipefail
if [[ -n "$REPO_NAME_INPUT" ]]; then
REPO_NAME="$REPO_NAME_INPUT"
else
REPO_NAME="${GROUP_NAME_INPUT}-oci-local-dev"
fi
# IMAGE_NAME is "<repo>/<image>"; package-name is just "<image>"
PACKAGE_NAME="${IMAGE_NAME#*/}"
jf evd create \
--package-name "$PACKAGE_NAME" \
--package-version "$PACKAGE_VERSION" \
--package-repo-name "$REPO_NAME" \
--predicate ./predicate.json \
--predicate-type "$PREDICATE_TYPE" \
--key "$PRIVATE_KEY" \
--key-alias "$KEY_ALIAS"
Comment on lines +288 to +304

- name: Evidence summary
if: ${{ inputs.push && inputs.sign_image }}
env:
IMAGE_NAME: ${{ steps.env-vars.outputs.IMAGE_NAME }}
PACKAGE_VERSION: ${{ github.sha }}
IMAGE_DIGEST: ${{ steps.digest.outputs.IMAGE_DIGEST }}
PREDICATE_TYPE: ${{ inputs.predicate_type }}
KEY_ALIAS: ${{ inputs.signing_key_alias }}
run: |
{
echo "### JFrog Evidence signed"
echo ""
echo "| Field | Value |"
echo "|---|---|"
echo "| Image | \`${IMAGE_NAME}\` |"
echo "| Package version (tag) | \`${PACKAGE_VERSION}\` |"
echo "| Manifest digest | \`${IMAGE_DIGEST}\` |"
echo "| Predicate type | \`${PREDICATE_TYPE}\` |"
echo "| Key alias | \`${KEY_ALIAS}\` |"
} >> "$GITHUB_STEP_SUMMARY"
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
# JFrog Evidence Image Signing — Design (ANG-2396)

## Summary

Sign every Docker image produced by CI with JFrog Evidence using a managed
ECDSA key pair, so downstream Kubernetes admission control (ANG-2397, Kyverno)
can verify image provenance before allowing a workload to start.

Signing is implemented with explicit `jf evd create` and a managed key — **not**
the keyless `actions/attest-build-provenance` path. The keyless path is avoided
because (a) it depends on RFC3161 timestamping by a TSA that may be absent from
the public Sigstore TUF root for private repos, currently mishandled by
Kyverno's newer `ImageValidatingPolicy` API (kyverno/kyverno#16054), and (b)
using GitHub's private Sigstore instance with Kyverno requires a cluster-wide
TUF override that constrains verification of other signing sources
(kyverno/kyverno#11618). A managed key gives a self-contained chain of trust
entirely within JFrog and a stable public key to wire into Kyverno.

**Scope:** Docker images only. Language packages, generic artifacts, and release
bundles are explicitly out of scope for this iteration.

## Motivation

- ANG-2397 needs a verifiable, digest-bound provenance signal on every image.
- A managed key pair keeps the trust chain inside JFrog + GitHub, avoiding the
Sigstore/TUF coupling that breaks Kyverno verification for private repos.

## Architecture

All signing logic lives in the **reusable workflow**
`NethermindEth/github-workflows/.github/workflows/docker-build-push-jfrog.yaml`.
New steps run **after** the existing `Build and push` step, gated on
`inputs.push == true && inputs.sign_image == true`. Every repo consuming the
reusable workflow gets signing for free.

`angkor-platform-aws-sts` (pushing to `angkor-oci-local`) is the pilot.

### Why the reusable workflow, not the caller

Signing must apply to *every* image produced by CI. Centralising it in the
reusable workflow means one implementation, one key convention, and no
copy-paste per repo. Consumers opt out with `sign_image: false`.

### Why not modify the build action

`NethermindEth/github-action-image-build-and-push` exposes **no outputs** (no
digest, no tags). Rather than fork that action, the reusable workflow resolves
the digest itself post-push via `docker buildx imagetools inspect`. Self-contained
and avoids a cross-repo dependency.

## Evidence subject: how the digest pin works

The ticket's acceptance criteria mandate the **package form**
(`--package-name` / `--package-version` / `--package-repo-name`). JFrog's docs
confirm this form attaches evidence to the **resolved manifest**, not to the
mutable tag reference. Therefore, if `--package-version` is an **immutable,
unique-per-commit tag** (`${{ github.sha }}`), the evidence is bound to that
exact manifest — effectively digest-pinned.

To guarantee the tag exists and is unique, the workflow ensures the full
`${{ github.sha }}` is in the pushed tag set. The real sha256 manifest digest is
additionally resolved and recorded (in the predicate + job summary) so ANG-2397
has the exact digest, even though the `jf evd create` subject is the SHA tag.

A harder pin (`--subject-repo-path <repo>/<image>/<sha-tag>/manifest.json
--subject-sha256 <digest>`) is available but deliberately not used in this
iteration: it diverges from the acceptance-criteria command and the package form
already binds to the resolved manifest.

## New reusable-workflow inputs

| Input | Type | Default | Description |
|---|---|---|---|
| `sign_image` | boolean | `true` | Opt-out toggle for Evidence signing |
| `signing_key_alias` | string | `jfrog-evidence-image-signing` | JFrog key alias used at create + verify time |
| `predicate_type` | string | `https://jfrog.com/evidence/signature/v1` | Custom predicate-type URI |

## New reusable-workflow secrets

| Secret | Required when signing | Description |
|---|---|---|
| `infisical_identity_id` | yes | Infisical machine identity ID (OIDC) for the signing-key project |
| `infisical_project_id` | yes | Infisical project ID holding the private signing key |

The private key is stored at a **shared** Infisical path (Angkor Platform project)
`/github/workflows/shared/github-workflows` (key name
`DOCKER_IMAGE_SIGNING_PRIVATE_KEY`), not the per-repo `/github/workflows/<repo>`
path, so a single signing key serves all consumers. Fetched via a `secret-path`
override on `get_infisical_secrets`.

## Step sequence (added after `Build and push`)

All steps gated on `inputs.push == true && inputs.sign_image == true`.

1. **Ensure SHA tag pushed** — the full `${{ github.sha }}` is passed in the
build action's tag set so a deterministic, unique, immutable tag exists to
sign and to inspect.

2. **Resolve image digest**
```bash
IMAGE_DIGEST=$(docker buildx imagetools inspect \
"${JFROG_URL}/${IMAGE_NAME}:${GITHUB_SHA}" \
--format '{{json .Manifest.Digest}}' | jq -r .)
```
Returns the OCI index digest for multi-arch images, the image manifest digest
for single-arch. This is the immutable reference Kyverno resolves to.

3. **Fetch private key** — `NethermindEth/github-workflows/get_infisical_secrets`
with a `secret-path` override pointing at the shared signing-key path. Exports
the PEM as a masked env var (`add-mask` line-by-line per the existing pattern).

4. **Generate predicate** — injection-safe (`env:` block + `jq`, never inline
`${{ }}` in `run:`), writes `predicate.json`:
```json
{
"actor": "<github.actor>",
"workflow": "<github.workflow>",
"run_id": "<github.run_id>",
"commit": "<github.sha>",
"repository": "<github.repository>",
"ref": "<github.ref>",
"timestamp": "<ISO-8601 UTC>",
"image_digest": "sha256:<digest>"
}
```
`image_digest` is added beyond the ticket's shape so ANG-2397 gets the exact
digest. Predicate contents are not policy-relevant yet (Kyverno verifies only
that signed Evidence exists); switching to SLSA Provenance later is cheap.

5. **Sign**
```bash
jf evd create \
--package-name "${IMAGE_NAME}" \
--package-version "${GITHUB_SHA}" \
--package-repo-name "${REPO_NAME}" \
--predicate ./predicate.json \
--predicate-type "${PREDICATE_TYPE}" \
--key "${PRIVATE_KEY}" \
--key-alias "${SIGNING_KEY_ALIAS}"
```
JFrog auth is inherited from the existing `setup-jfrog-cli` OIDC step
(evidence create requires access-token/OIDC auth — basic auth is rejected).

6. **Job summary** — write image, SHA tag, resolved digest, predicate-type, and
key alias to `$GITHUB_STEP_SUMMARY` for traceability.

## Key management

### Generation (once, ECDSA P-256)

```bash
openssl ecparam -name prime256v1 -genkey -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pem
```

ECDSA P-256 is the cosign/Kyverno standard and compact. RSA is acceptable but
larger; ed25519 is supported by `jf evd` but less universally consumed.

### Distribution

- **Private key** → Infisical `/github/workflows/shared/github-workflows`
(`DOCKER_IMAGE_SIGNING_PRIVATE_KEY`, Angkor Platform project), CI-accessible via
OIDC machine identity. Never committed.
- **Public key** → committed to
`github-workflows/keys/jfrog-evidence-image-signing.pub`. Stable, versioned URL
for Kyverno / ArgoCD to consume.

### Key-alias convention

`jfrog-evidence-image-signing` — matches the public-key filename and the
`--key-alias` passed to both `jf evd create` and `jf evd verify`.

### Rotation procedure

1. Generate a new ECDSA P-256 pair (commands above).
2. Replace the private key value in the Infisical shared path.
3. Commit the new public key to
`github-workflows/keys/jfrog-evidence-image-signing.pub` (optionally a
date-suffixed name during overlap).
4. Update the downstream Kyverno policy (ANG-2397) with the new public key.
5. Keep the old public key available until all previously-signed images have
aged out of admission-controlled clusters, then remove it.

## Verification (acceptance)

Offline, with the matching public key:

```bash
jf evd verify \
--package-name "${IMAGE_NAME}" \
--package-version "${GITHUB_SHA}" \
--package-repo-name "${REPO_NAME}" \
--public-keys ./public.pem
```

No JFrog auth required for offline verify with `--public-keys` (purely
client-side).

## Prerequisites (ops, not automated by this change)

- Artifactory instance has the **Evidence** feature enabled (subscription gated).
- JFrog CLI ≥ 2.65.0 for `jf evd` (setup-jfrog-cli installs latest — satisfied).
- ECDSA key pair generated; private key uploaded to Infisical; public key
committed.
- Infisical machine identity + project for the signing key, bound to the calling
repos via OIDC.

## Files touched

- `github-workflows/.github/workflows/docker-build-push-jfrog.yaml` — new inputs,
secrets, and signing steps.
- `github-workflows/keys/jfrog-evidence-image-signing.pub` — public key.
- `github-workflows/docs/docker/` — signing usage + key rotation docs.
- `angkor-platform-aws-sts/.github/workflows/docker-build-push.yaml` — bump
reusable-workflow version ref once released; pass Infisical secrets if not
org-level.

## Acceptance criteria mapping

| Criterion | Satisfied by |
|---|---|
| ECDSA/RSA key pair generated; private in CI secret store; public consumable by Kyverno | ECDSA P-256; private → Infisical; public → committed `.pub` |
| Predicate JSON generated at runtime with CI provenance fields + custom predicate-type URI | Step 4 |
| Signs every image immediately after push via `jf evd create` package form | Steps 5, gated on `push && sign_image` |
| Evidence appears on Docker package in Artifactory Evidence tab | Step 5 result (manual verification at pilot) |
| Out-of-cluster `jf evd verify` with public key succeeds | Verification section |
| Predicate shape, predicate-type URI, key-alias convention documented | This doc |
| Key rotation procedure documented | Rotation section |
| At least one pilot image signed + verified end-to-end | `angkor-platform-aws-sts` pilot |
| Only Docker images signed this iteration | Scope section |

## Out of scope

- Non-Docker artifact types (packages, generic artifacts, release bundles).
- Kyverno admission policy (ANG-2397).
- Infisical / JFrog / key-pair infrastructure provisioning (documented as
prerequisites).
Loading