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
85 changes: 85 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,96 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Run govulncheck
id: scan
uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
# Findings are triaged in the next step against the exclusion list,
# so a non-zero exit here is expected and not itself the verdict.
# The next step is responsible for turning a genuinely failed scan
# into a failed job.
continue-on-error: true
with:
go-version-input: 'stable'
go-package: ./...
repo-checkout: false
output-format: json
output-file: govulncheck-output.json

- name: Check for vulnerabilities (with exclusions)
env:
SCAN_OUTCOME: ${{ steps.scan.outcome }}
run: |
set -euo pipefail

# Ignored vulnerabilities with justification:
#
# GO-2026-5932: golang.org/x/crypto/openpgp is deprecated-by-design
# ("unsafe, not maintained, should not be used"). No fixed version
# exists and none is planned.
#
# Nothing here imports openpgp. It arrives four levels up:
# container/signer -> sigstore-go/pkg/sign -> rekor/pkg/pki
# -> rekor/pkg/pki/pgp -> golang.org/x/crypto/openpgp
# rekor/pkg/pki is a pluggable signature-format registry, so
# importing it links every format, PGP included. Every govulncheck
# trace is package-init reachability, not a call: the code is
# linked but never parses PGP data on any ToolHive path.
#
# REMOVAL TRIGGER: rekor migrated to ProtonMail/go-crypto/openpgp
# in sigstore/rekor#2883 (merged 2026-07-15), which landed after
# v1.5.3 (2026-07-02) and so is not in any release yet. Bump rekor
# past v1.5.3 once a release contains it, then drop this entry.
#
# This mirrors the identical exclusion in stacklok/toolhive
# (.github/workflows/security-scan.yml), which consumes the same
# sigstore packages.
IGNORED_VULNS="GO-2026-5932"

# A scan that produced nothing is a failed scan, not a clean one.
# Without this the gate goes green when the action crashes, since
# "no findings parsed" and "no vulnerabilities" look identical.
if [ ! -s govulncheck-output.json ]; then
echo "::error::govulncheck produced no output (step outcome: ${SCAN_OUTCOME}); refusing to report success"
exit 1
fi

echo "::group::govulncheck raw output"
cat govulncheck-output.json
echo "::endgroup::"

# jq failures must be fatal. Truncated or malformed output would
# otherwise yield an empty finding list indistinguishable from a
# clean scan.
if ! jq -r 'select(.finding != null) | .finding.osv' govulncheck-output.json > found-osv.txt; then
echo "::error::could not parse govulncheck output; treating as a failed scan"
exit 1
fi

# Only "finding" entries with an osv field represent vulnerabilities
# whose vulnerable symbols are actually reachable. grep exiting 1
# here means "no matches", which is a legitimately clean result —
# that is the only non-zero status tolerated.
FOUND_VULNS=$(sort -u found-osv.txt | { grep -E '^GO-' || true; })

# The action exits non-zero both when it finds vulnerabilities and
# when it fails outright. Findings tell those apart.
if [ "${SCAN_OUTCOME}" = "failure" ] && [ -z "${FOUND_VULNS}" ]; then
echo "::error::govulncheck failed without reporting any finding — the scan itself errored"
exit 1
fi

UNEXPECTED=""
for v in $FOUND_VULNS; do
case " $IGNORED_VULNS " in
*" $v "*) echo "Ignoring $v (documented exclusion)" ;;
*) UNEXPECTED="$UNEXPECTED $v" ;;
esac
done

if [ -n "$UNEXPECTED" ]; then
echo "::error::Unexcluded vulnerabilities found:$UNEXPECTED"
exit 1
fi
echo "No unexcluded vulnerabilities found."

grype:
name: Grype Vulnerability Scan
Expand Down
237 changes: 237 additions & 0 deletions container/signer/cosign_attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package signer

import (
"bytes"
"context"
"crypto"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"

"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
"github.com/google/go-containerregistry/pkg/v1/static"
"github.com/google/go-containerregistry/pkg/v1/types"
"github.com/opencontainers/go-digest"
"github.com/sigstore/sigstore/pkg/signature"
)

const (
mediaTypeCosignSimpleSigningV1JSON = "application/vnd.dev.cosign.simplesigning.v1+json"
annotationCosignSignature = "dev.cosignproject.cosign/signature"
)

// cosignSimpleSigning is the payload cosign signs: it embeds the artifact's
// manifest digest, binding the signature to the exact artifact content.
type cosignSimpleSigning struct {
Critical cosignCritical `json:"critical"`
}

type cosignCritical struct {
Identity cosignIdentity `json:"identity"`
Image cosignImage `json:"image"`
Type string `json:"type"`
}

type cosignIdentity struct {
DockerReference string `json:"docker-reference"`
}

type cosignImage struct {
DockerManifestDigest string `json:"docker-manifest-digest"`
}

// SimpleSigningPayload builds the canonical simple-signing payload for the
// artifact at ref pinned to digestStr. This payload — not the manifest
// digest — is what gets signed, per the cosign convention: a verifier
// recovers the payload from the signature manifest's layer, checks the
// signature over it, and reads the bound manifest digest out of it.
// Exported because offline re-verification of a stored key-signed bundle
// must reconstruct exactly these bytes to check the signature's binding.
func SimpleSigningPayload(imageRef, digestStr string) ([]byte, error) {
ref, err := name.ParseReference(imageRef)
if err != nil {
return nil, fmt.Errorf("parsing image reference: %w", err)
}
d, err := parseManifestDigest(digestStr)
if err != nil {
return nil, err
}
payload := cosignSimpleSigning{
Critical: cosignCritical{
Identity: cosignIdentity{DockerReference: ref.Context().Name()},
Image: cosignImage{DockerManifestDigest: d.String()},
Type: "cosign container image signature",
},
}
return json.Marshal(payload)
}

// parseManifestDigest validates and normalizes an artifact manifest digest
// string, defaulting a bare hex value to sha256.
func parseManifestDigest(digestStr string) (digest.Digest, error) {
digestStr = strings.TrimSpace(digestStr)
if digestStr == "" {
return "", fmt.Errorf("digest is required for signing")
}
if !strings.Contains(digestStr, ":") {
digestStr = "sha256:" + digestStr
}
d, err := digest.Parse(digestStr)
if err != nil {
return "", fmt.Errorf("parsing digest: %w", err)
}
return d, nil
}

// attachCosignSignature writes the cosign signature manifest for the
// artifact: an OCI image at the "sha256-<hex>.sig" tag whose single layer is
// the simple-signing payload, carrying the signature in the layer's
// annotations. This is the classic cosign layout, chosen deliberately for
// interop — "cosign verify --key" and any Sigstore-aware registry tooling
// can discover and verify it.
func attachCosignSignature(
ctx context.Context,
keychain authn.Keychain,
imageRef, digestStr string,
payload, signatureBytes []byte,
pub crypto.PublicKey,
) error {
ref, err := name.ParseReference(imageRef)
if err != nil {
return fmt.Errorf("parsing image reference: %w", err)
}
d, err := parseManifestDigest(digestStr)
if err != nil {
return err
}

h, err := v1.NewHash(d.String())
if err != nil {
return fmt.Errorf("parsing digest hash: %w", err)
}
sigTag := ref.Context().Tag(fmt.Sprint(h.Algorithm, "-", h.Hex, ".sig"))
remoteOpts := []remote.Option{remote.WithAuthFromKeychain(keychain), remote.WithContext(ctx)}

// An artifact can carry signatures from several signers, so the new
// layer is appended to whatever is already at the .sig tag rather than
// replacing it. Building from empty.Image unconditionally would delete
// every existing signature — including other people's trust material —
// on the next push. This mirrors cosign's own append behaviour.
base, err := existingSignatureImage(sigTag, remoteOpts)
if err != nil {
return err
}

already, err := signedByKey(base, payload, pub)
if err != nil {
return err
}
if already {
// Re-signing with the same key is a no-op; pushing repeatedly must
// not grow the manifest without bound. Comparing signature bytes
// would not work — ECDSA is randomised, so the same key produces a
// different signature every time — so this asks the question that
// actually matters: is one of the existing signatures already ours?
return nil
}
encodedSig := base64.StdEncoding.EncodeToString(signatureBytes)

layer := static.NewLayer(payload, mediaTypeCosignSimpleSigningV1JSON)
img, err := mutate.Append(base, mutate.Addendum{
Layer: layer,
Annotations: map[string]string{
annotationCosignSignature: encodedSig,
},
MediaType: mediaTypeCosignSimpleSigningV1JSON,
})
if err != nil {
return fmt.Errorf("building signature manifest: %w", err)
}
img = mutate.MediaType(img, types.OCIManifestSchema1)

if err := remote.Write(sigTag, img, remoteOpts...); err != nil {
return fmt.Errorf("pushing signature manifest: %w", err)
}
return nil
}

// existingSignatureImage fetches the signature manifest already at tag, or
// an empty image when none exists yet. Only a genuine "absent" answer from
// the registry is treated as empty — any other failure is returned, because
// silently starting from empty would discard existing signatures.
func existingSignatureImage(tag name.Tag, remoteOpts []remote.Option) (v1.Image, error) {
img, err := remote.Image(tag, remoteOpts...)
if err == nil {
return img, nil
}
if isAbsentFromRegistry(err) {
return empty.Image, nil
}
return nil, fmt.Errorf("reading existing signature manifest: %w", err)
}

// isAbsentFromRegistry reports whether err means "this tag does not exist"
// as opposed to a transport, auth, or server failure.
func isAbsentFromRegistry(err error) bool {
var terr *transport.Error
if !errors.As(err, &terr) {
return false
}
if terr.StatusCode == http.StatusNotFound {
return true
}
for _, diag := range terr.Errors {
if diag.Code == transport.ManifestUnknownErrorCode || diag.Code == transport.NameUnknownErrorCode {
return true
}
}
return false
}

// signedByKey reports whether img already carries a signature over payload
// that verifies with pub — i.e. whether this key has already signed this
// artifact. This is the same question cosign's dupe detector asks, and the
// only reliable one: ECDSA signatures are randomised, so two signatures
// from one key never match byte-for-byte.
func signedByKey(img v1.Image, payload []byte, pub crypto.PublicKey) (bool, error) {
manifest, err := img.Manifest()
if err != nil {
return false, fmt.Errorf("reading signature manifest layers: %w", err)
}
if len(manifest.Layers) == 0 {
return false, nil
}
sigVerifier, err := signature.LoadVerifier(pub, crypto.SHA256)
if err != nil {
return false, fmt.Errorf("loading verifier for duplicate detection: %w", err)
}
for _, l := range manifest.Layers {
encoded := l.Annotations[annotationCosignSignature]
if encoded == "" {
continue
}
raw, decodeErr := base64.StdEncoding.DecodeString(encoded)
if decodeErr != nil {
// A layer we cannot decode is not one of ours; leave it alone
// rather than failing the whole push over someone else's
// malformed annotation.
continue
}
if sigVerifier.VerifySignature(bytes.NewReader(raw), bytes.NewReader(payload)) == nil {
return true, nil
}
}
return false, nil
}
Loading