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 ¶
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.
const CosignBinary = "cosign"
CosignBinary is the executable looked up on PATH.
const DockerHub = "docker.io"
DockerHub is the canonical spelling of Docker Hub's registry host.
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 ¶
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.
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.
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.
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".
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.
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.
type DenyError ¶
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) Malformed ¶
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 ¶
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 ¶
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) Configured ¶
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 ¶
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 ¶
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 ¶
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) Validate ¶
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 ¶
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 ¶
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) Pinned ¶
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 ¶
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 ¶
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 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.