imagepolicy

package
v0.0.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package imagepolicy decides whether a container image may be used to execute a project's code, and pins the answer to a digest.

The hole it closes

cloop's premise is that the hub never runs untrusted code on the host: every workload goes into a container or a Pod. A project's .cloop/sandbox.yaml can name the image that container is built from, and that file arrives by `git pull` — anyone who can open a pull request can propose one. Before this package, the only check on that name was that it was syntactically usable as an argv token. `image: evil.example/backdoor:latest` passed.

An image is not data the sandbox processes; it is the sandbox. Its entrypoint, its libraries and its PATH are the environment the harness runs inside, and the credentials the hub injects at start are handed straight to it. An unverified image does not weaken the isolation boundary — it is on the trusted side of it, which puts the whole thing back where it started.

Shape

Evaluate is pure. It takes a string and returns a decision, with no clock, no filesystem and no network. That is what makes the registry-confusion cases below testable as a table, and it is what lets the UI render the same verdict a run would get before any task has started. Everything impure — resolving a tag to a digest, shelling out to cosign — lives behind the Resolver and Verifier interfaces in enforce.go and is composed on top.

Deny by default

A configured policy denies anything it does not explicitly allow. An *unconfigured* policy (Configured() false) allows everything, because turning on image trust must be a decision an operator makes, not one an upgrade makes for them by breaking every project that pins a tag.

Index

Constants

View Source
const (
	// RuleSyntax: the reference is not a reference.
	RuleSyntax = "reference-syntax"
	// RuleUnqualified: the reference named no registry and the policy has no
	// default, so which registry it would come from is undecidable.
	RuleUnqualified = "unqualified-reference"
	// RuleRegistry: the registry is not on allowed_registries.
	RuleRegistry = "registry-allowlist"
	// RuleRepository: the repository is not on allowed_repos.
	RuleRepository = "repository-allowlist"
	// RuleDigest: require_digest is set and the reference is tag-only.
	RuleDigest = "digest-required"
	// RuleResolution: the reference had to be pinned and could not be.
	RuleResolution = "digest-resolution"
	// RuleSignature: require_signature is set and verification did not pass.
	RuleSignature = "signature-required"
	// RuleConfig: the policy itself is unusable.
	RuleConfig = "policy-configuration"
)

Rule names identify which rule produced a denial. They appear in the error text, in the audit payload and in the API response, so they are stable strings rather than an enum whose numbering could shift.

View Source
const CosignBinary = "cosign"

CosignBinary is the executable looked up on PATH.

View Source
const DockerHub = "docker.io"

DockerHub is the canonical spelling of Docker Hub's registry host.

View Source
const MaxRefLen = 512

MaxRefLen bounds a reference. Real ones are well under 200 bytes; the limit exists so a malformed spec cannot hand megabytes to a runtime's argv.

Variables

View Source
var ErrBadReference = errors.New("imagepolicy: malformed image reference")

ErrBadReference is the class of every parse failure, so a caller that only wants "this is not a reference" does not match on message text.

View Source
var ErrCosignMissing = errors.New("imagepolicy: cosign is not installed")

ErrCosignMissing reports that the cosign binary is not installed. It is a sentinel so the UI can render an install hint rather than a generic failure.

View Source
var ErrDenied = errors.New("imagepolicy: image denied by policy")

ErrDenied is the class of every refusal, for callers that want errors.Is rather than a type assertion.

View Source
var ErrNoDigest = errors.New("imagepolicy: no digest is available for this reference")

ErrNoDigest is returned by a Resolver that reached the image but found no registry-reproducible digest for it — true for locally built images and for images loaded from a tarball. It is distinct from a lookup failure because the two need opposite handling: one is "there is nothing to pin to", the other is "the image is not there at all".

View Source
var ErrRegistry = errors.New("imagepolicy: the registry did not resolve the reference")

ErrRegistry is the class of every failure to resolve a reference at its registry: unreachable, refusing, not knowing the tag, or answering something that is not a digest. It is distinct from a policy denial because it is not a verdict on the image; the caller decides whether not knowing is a refusal.

Functions

This section is empty.

Types

type CosignVerifier

type CosignVerifier struct {
	// Binary overrides the executable path. Tests set it; deployments do not.
	Binary string
	// contains filtered or unexported fields
}

CosignVerifier verifies signatures with the cosign CLI.

The zero value is usable and looks cosign up on PATH.

func NewCosignVerifier

func NewCosignVerifier() *CosignVerifier

NewCosignVerifier returns a verifier using cosign from PATH.

func (*CosignVerifier) Verify

func (v *CosignVerifier) Verify(ctx context.Context, ref string, p Policy) error

Verify implements Verifier. ref must be digest-pinned.

type Decision

type Decision struct {
	// Ref is the parsed, normalized reference. Zero when parsing failed.
	Ref Reference `json:"ref"`
	// Allowed is the verdict.
	Allowed bool `json:"allowed"`
	// Rule names the rule that decided. On an allow it is empty; on a denial
	// it is one of the Rule* constants.
	Rule string `json:"rule,omitempty"`
	// Reason states what was observed, in the operator's terms.
	Reason string `json:"reason,omitempty"`
	// Remediation states what to do about it.
	Remediation string `json:"remediation,omitempty"`
	// NeedsPin is true for an allowed reference that is not digest-pinned.
	// The executor must resolve and pin it before running; see Enforcer.
	NeedsPin bool `json:"needs_pin,omitempty"`
	// NeedsSignature is true when the policy requires cosign verification of
	// this image. Evaluate does not perform it — it is impure.
	NeedsSignature bool `json:"needs_signature,omitempty"`
	// PolicyActive reports whether any rule was applied at all.
	PolicyActive bool `json:"policy_active"`
}

Decision is the verdict on one reference.

It is returned whether or not the reference was allowed, so a UI can render "this would be refused, because X" without catching an error, and an audit row can record the same fields the operator was shown.

func (Decision) Err

func (d Decision) Err() error

Err returns a *DenyError for a refused decision, nil for an allowed one.

type DenyError

type DenyError struct {
	Ref         string
	Rule        string
	Reason      string
	Remediation string
}

DenyError is a refusal, carrying the rule that produced it.

It is typed for the reason every other refusal in cloop is: the developer who wrote the image reference and the operator who owns the allowlist are different people, and the error has to give the first enough to file a useful request to the second.

func (*DenyError) Error

func (e *DenyError) Error() string

Error implements error.

func (*DenyError) Is

func (e *DenyError) Is(target error) bool

Is makes errors.Is(err, ErrDenied) true for every refusal.

func (*DenyError) Malformed

func (e *DenyError) Malformed() bool

Malformed reports a refusal caused by the reference not being a reference, as opposed to one caused by a rule the operator configured.

The distinction decides who is being told to do something. A malformed reference is a broken file whose author can fix it alone — the drivers map it onto executor.ErrInvalidSpec, which the API renders as 400. Every other rule is a well-formed request that conflicts with the hub's configuration, which is a 409 and may need an operator.

type Enforcer

type Enforcer struct {
	Policy   Policy
	Resolver Resolver
	Verifier Verifier
}

Enforcer applies a Policy, using Resolver to pin and Verifier to check signatures. Both may be nil; a nil one fails closed wherever the policy actually needs it, and is simply unused where it does not.

func (Enforcer) Authorize

func (e Enforcer) Authorize(ctx context.Context, ref string) (Result, error)

Authorize evaluates ref, pins it, and verifies its signature.

The returned Result.Ref is what the caller must run. On refusal the error is a *DenyError naming the rule; on an infrastructure failure (the image is not present, the registry is unreachable) it is that error, wrapped — the two are different problems for different people and must not be flattened into one.

type Identity

type Identity struct {
	// Issuer is the exact OIDC issuer URL, e.g.
	// "https://token.actions.githubusercontent.com".
	Issuer string `json:"issuer" yaml:"issuer"`
	// SubjectRegexp is a regular expression the certificate subject must
	// match, e.g. "^https://github\\.com/acme/.+".
	SubjectRegexp string `json:"subject_regexp" yaml:"subject_regexp"`
}

Identity is one keyless-signing identity: a Fulcio certificate whose OIDC issuer matches Issuer and whose subject matches SubjectRegexp.

type Policy

type Policy struct {
	// AllowedRegistries are the registry hosts a project may pull from.
	// Matching is on the parsed host, whole-string and case-insensitive. Two
	// forms beyond an exact host are accepted:
	//
	//	*.example.com   any subdomain of example.com, at a label boundary
	//	*               any registry (use with require_digest to constrain
	//	                provenance without constraining the source)
	//
	// Empty means this dimension is not constrained — a policy of nothing but
	// require_digest is a legitimate one ("I do not care where images come
	// from, but they must be immutable"), and reading an empty list as
	// deny-all would make it a hub that refuses every image. Validate refuses
	// the one combination where that reading would be a hole: a registry-less
	// allowed_repos entry with no allowed_registries constrains the path and
	// not the source.
	AllowedRegistries []string `json:"allowed_registries,omitempty" yaml:"allowed_registries,omitempty"`

	// AllowedRepos further narrows which repositories within those registries
	// are permitted. Empty means "any repository on an allowed registry".
	//
	// An entry may name a registry ("ghcr.io/acme/tools") or not
	// ("acme/tools"), in which case it applies within whichever registry
	// already passed AllowedRegistries. A trailing "/*" matches the prefix at
	// a path-component boundary.
	AllowedRepos []string `json:"allowed_repos,omitempty" yaml:"allowed_repos,omitempty"`

	// RequireDigest refuses any reference that is not already pinned to a
	// digest. Without it, a tag reference is still resolved and pinned where
	// the executor can do so — RequireDigest is what makes the *reference*
	// carry the pin, which is the only form that survives an executor with no
	// local image store to resolve against.
	RequireDigest bool `json:"require_digest,omitempty" yaml:"require_digest,omitempty"`

	// RequireSignature refuses any image whose signature cosign cannot verify
	// against CosignPublicKeys or CosignIdentities. Verification is on the
	// digest, never on a tag.
	RequireSignature bool `json:"require_signature,omitempty" yaml:"require_signature,omitempty"`

	// CosignPublicKeys are paths to public keys any one of which may carry the
	// signature (`cosign verify --key`).
	CosignPublicKeys []string `json:"cosign_public_keys,omitempty" yaml:"cosign_public_keys,omitempty"`

	// CosignIdentities are keyless identities any one of which may carry the
	// signature (`cosign verify --certificate-oidc-issuer …`).
	CosignIdentities []Identity `json:"cosign_identities,omitempty" yaml:"cosign_identities,omitempty"`

	// DefaultRegistry qualifies a reference that names no registry.
	//
	// Absent it, "alpine:3.20" is *denied* rather than assumed to be Docker
	// Hub. That is not pedantry: podman resolves an unqualified name against
	// unqualified-search-registries, which is host configuration the policy
	// cannot see, so the same reference can legitimately pull from different
	// registries on two executors in the same fleet. An operator who wants the
	// Docker convention writes it down here.
	DefaultRegistry string `json:"default_registry,omitempty" yaml:"default_registry,omitempty"`
}

Policy is the hub-wide image trust configuration.

The zero Policy is not "deny everything" — it is "no policy", which allows everything. See Configured.

func (Policy) AdmitsAnyRegistry added in v0.0.5

func (p Policy) AdmitsAnyRegistry() (string, bool)

AdmitsAnyRegistry reports whether the policy admits images from a registry it never names, and an image it admits that shows it. "Configured" is not that: require_digest alone, an allowed_registries entry of "*", or a repo entry that names no registry beside "*", all let any registry through.

Judged by Evaluate itself, against digest-pinned references on a reserved registry (.invalid, RFC 2606): one at a path no policy names, and one under each allowed_repos path that names no registry — the paths a policy admits from whichever registries it allows. `cloop hub doctor` asks this rather than reading the lists; its single probe once called a policy "deny-by-default" that admitted evil.example/acme/tools (Task 20387).

func (Policy) Configured

func (p Policy) Configured() bool

Configured reports whether this policy constrains anything.

Only the allowlists and the two Require flags count. Cosign keys alone do not: a policy carrying keys but no RequireSignature has nothing to enforce, and treating it as configured would turn a half-finished edit into a hub that denies every image.

func (Policy) Evaluate

func (p Policy) Evaluate(ref string) (Decision, error)

Evaluate decides whether ref may be used. It is pure.

An unconfigured policy allows any *syntactically valid* reference: even with no rules, a string that is not a reference is refused, because it would fail later in a runtime's argv with a far worse message.

func (Policy) Fingerprint

func (p Policy) Fingerprint() string

Fingerprint is a stable hash of the fields that affect a verdict. It keys the signature-verification cache, so a policy edit invalidates it rather than letting a run inherit a verdict reached under the previous rules.

func (Policy) Normalize

func (p Policy) Normalize() Policy

Normalize returns a copy with entries trimmed, lower-cased and de-duplicated. Evaluate calls it, so callers never have to; it is exported because the config layer wants to store the normalized form.

func (Policy) RegistryHosts added in v0.0.5

func (p Policy) RegistryHosts() []string

RegistryHosts lists the concrete registries the policy names, sorted: each allowed_registries entry that is not a wildcard, and the registry of each allowed_repos entry that names one — read by the parser the policy evaluates with, so "acme/tools" names a repository and no registry, exactly as it does when an image is checked against it. Docker Hub is named by its canonical host, DockerHub.

`cloop hub doctor` probes these. It used to split repo entries on their first "/" itself, and failed a hub over a registry called "acme" that the policy had never named (Task 20387).

func (Policy) Validate

func (p Policy) Validate() error

Validate reports a policy that cannot do what it says.

It is separate from Evaluate because these are the operator's mistakes, not a project's: they must surface where the config was written, at `cloop config set` and at hub start, rather than as a denial on some project's first run.

type Reference

type Reference struct {
	// Original is the reference exactly as supplied.
	Original string `json:"original"`
	// Registry is the normalized host[:port]. Empty when the reference named
	// no registry and no default was applied — see Qualified.
	Registry string `json:"registry,omitempty"`
	// Repository is the path below the registry, e.g. "library/alpine".
	Repository string `json:"repository"`
	// Tag is the tag, empty for a digest-only reference.
	Tag string `json:"tag,omitempty"`
	// Digest is the "sha256:…" content digest, empty when the reference is
	// tag-only.
	Digest string `json:"digest,omitempty"`
	// Qualified reports whether the *reference itself* named a registry.
	// False means the registry, if any, came from Policy.DefaultRegistry.
	Qualified bool `json:"qualified"`
}

Reference is a parsed image reference.

Registry and Repository are the two fields policy matching uses, and they are only ever set from the parse — there is no path that assigns them from a prefix or a suffix of the original string.

func ParseReference

func ParseReference(ref string) (Reference, error)

ParseReference parses ref without applying any default registry.

A reference with no registry component yields Qualified false and an empty Registry. That is not an oversight: which registry a bare "alpine:3" resolves to is a property of the *host's* runtime configuration (podman consults unqualified-search-registries, docker assumes Docker Hub), so the reference alone does not determine it and a policy must not pretend otherwise. Policy decides what to do about that; see Policy.DefaultRegistry.

func (Reference) Canonical

func (r Reference) Canonical() string

Canonical is the strongest form of this reference: "registry/repo@digest". Empty when there is no digest, because there is then no canonical form — and returning the tag would let a caller believe it had pinned something.

func (Reference) Name

func (r Reference) Name() string

Name is "registry/repository", the identity a signature is made over.

func (Reference) Pinned

func (r Reference) Pinned() bool

Pinned reports whether this reference names an exact artifact. A tag never does: it is a mutable pointer, and the registry is free to repoint it between the moment a policy accepted it and the moment a runtime pulls it.

func (Reference) String

func (r Reference) String() string

String re-renders the reference from its parts. It round-trips a qualified reference exactly and differs from Original only where normalization applied (host case folding, a Docker Hub alias, an added default registry).

func (Reference) WithDigest

func (r Reference) WithDigest(digest string) (Reference, error)

WithDigest returns a copy pinned to digest, dropping the tag.

The tag is dropped rather than kept as "repo:tag@digest". Both forms are legal and resolve identically, but only one of them is unambiguous to a human reading a Pod spec six months later, and the tag it would carry is by then a claim nobody can check.

type RegistryCredential added in v0.0.6

type RegistryCredential struct {
	Username string
	Password string
}

RegistryCredential is a login at a registry: a username with its password or access token.

func DockerConfigCredential added in v0.0.6

func DockerConfigCredential(registry string) (RegistryCredential, bool)

DockerConfigCredential returns the login the first credentials file that holds one has for registry. A file that is missing, unreadable or malformed is skipped: it holds no login, which a resolution without one already handles by trying anonymously.

type RegistryResolver added in v0.0.6

type RegistryResolver struct {
	// Client performs the requests. Nil uses one bounded by
	// registryRequestTimeout that honours the proxy environment.
	Client *http.Client
	// Credentials returns the login for a registry host, false when there is
	// none. Nil reads DockerConfigCredential.
	Credentials func(registry string) (RegistryCredential, bool)
	// Endpoint returns the base URL a registry's API is served at. Nil serves
	// every registry over https at its own host and Docker Hub at
	// registry-1.docker.io. Tests point it at a local server.
	Endpoint func(registry string) string
}

RegistryResolver implements Resolver by asking the registry a reference names. The zero value is usable.

func NewRegistryResolver added in v0.0.6

func NewRegistryResolver() *RegistryResolver

NewRegistryResolver returns a resolver with the defaults.

func (*RegistryResolver) ResolveDigest added in v0.0.6

func (r *RegistryResolver) ResolveDigest(ctx context.Context, ref string) (string, error)

ResolveDigest implements Resolver: the digest ref's registry serves for its tag now. A reference already pinned resolves to its own digest without a request.

type Resolver

type Resolver interface {
	// ResolveDigest returns the "sha256:…" digest for ref, or ErrNoDigest when
	// the image exists but carries none.
	ResolveDigest(ctx context.Context, ref string) (string, error)
}

Resolver turns a reference into the digest it names right now.

The container driver implements it against the local image store. The Kubernetes driver has none — a cluster has no image store the control plane can read — so it passes nil, and a policy that needs a pin there says so with RequireDigest instead of pretending it can resolve one.

type Result

type Result struct {
	// Ref is what the caller must actually run. It is the digest-pinned
	// canonical form whenever one could be obtained.
	Ref string `json:"ref"`
	// Digest is the pinned digest, empty when the reference could not be
	// pinned and the policy did not require it.
	Digest string `json:"digest,omitempty"`
	// Decision is the verdict Evaluate reached on the original reference.
	Decision Decision `json:"decision"`
	// Verified reports that a signature was checked and accepted.
	Verified bool `json:"verified,omitempty"`
	// Warnings describe an accepted-but-weaker outcome, e.g. an image that
	// could not be pinned on a hub that does not require pinning.
	Warnings []string `json:"warnings,omitempty"`
}

Result is an authorized image, ready to run.

func (Result) Pinned

func (r Result) Pinned() bool

Pinned reports whether Ref names an exact artifact.

type Verifier

type Verifier interface {
	Verify(ctx context.Context, ref string, p Policy) error
}

Verifier checks that an image carries a signature the policy accepts.

ref is always a digest-pinned reference: verifying a tag would verify whatever the tag pointed at during the check, which is the problem this file exists to remove.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL