fix(submit): 审阅绑定上下文 sha256,批准须带摘要,构建只从内部 API 取上下文并校验字节

This commit is contained in:
Lemon-miaow committed 2026-09-24 22:24:50 +08:00
1 parent 13b65e19ec
commit e521cf5976
30 files changed
+910 -98

No files matched your search

+16
View File
@@ -129,6 +129,11 @@ type Request struct {
// ContextRef locates the uploaded tar.gz context in object storage / a PVC
// (spec §17: Kaniko pulls it; Git context is intentionally not supported).
ContextRef string
// ContextDigest is the lowercase hex sha256 of the context tarball an admin
// approved. When set, the context-fetch initContainer refuses any other bytes,
// so a context replaced after review never reaches Kaniko. It needs an
// http(s) ContextRef: a ref Kaniko fetches itself has no fetch step to check.
ContextDigest string
// BaseImage is the resolved FROM, recorded for audit only — it is NOT a hard
// gate (spec §16: base FROM is not hard-gated; the scan + egress lock cover
// poisoned bases).
@@ -152,6 +157,10 @@ type Build struct {
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
// ContextDigest is Request.ContextDigest, kept on the row as the audit record
// of which bytes the build was allowed to consume.
ContextDigest string `json:"context_digest,omitempty"`
}
// Image mirrors an image_whitelist row (spec §6): the dynamic, auditable image
@@ -257,6 +266,11 @@ type Config struct {
// RuntimeClass runs build pods under a sandbox RuntimeClass (gVisor, Kata)
// when set. The class must exist on the cluster.
RuntimeClass string
// ContextOrigin is the scheme://host[:port] of the platform's internal API
// face, the only host an http(s) ContextRef may name: the fetch step presents
// the service token to it. Empty refuses every http(s) context.
ContextOrigin string
}
// Values of Config.UserNamespaces.
@@ -375,6 +389,7 @@ func (b *Builder) Submit(ctx context.Context, req Request) (*Build, error) {
RequestedBy: req.RequestedBy,
CreatedAt: now,
}
bld.ContextDigest = req.ContextDigest
if err := b.Store.CreateBuild(ctx, bld); err != nil {
return nil, err
}
@@ -408,6 +423,7 @@ func (b *Builder) jobParams(bld *Build, cfg Config) JobParams {
BuildID: bld.ID,
ImageRef: bld.ImageRef,
ContextRef: bld.ContextRef,
ContextDigest: bld.ContextDigest,
Namespace: cfg.Namespace,
ServiceAccount: cfg.ServiceAccount,
RegistryURL: cfg.RegistryURL,
+55
View File
@@ -221,6 +221,61 @@ func TestSubmitRejectsExternalRegistryTarget(t *testing.T) {
// The platform's own images and the scanner's DB mirrors live under felis/ and
// mirror/; the registry gate refuses the build principal there, and Validate turns
// that into a 400 before a Job spends minutes building an image it cannot push.
// A context digest must be a real sha256 and needs a fetch step to enforce it.
func TestValidateContextDigest(t *testing.T) {
cfg := Config{RegistryURL: "registry.felis.svc:5000", ContextOrigin: "http://felis-api-internal:8081"}
req := Request{ImageRef: "registry.felis.svc:5000/user-uploads/sub-1:latest", Dockerfile: "FROM scratch",
ContextRef: "http://felis-api-internal:8081/api/v1/internal/submissions/sub-1/context", ContextDigest: strings.Repeat("e", 64)}
if err := Validate(req, cfg); err != nil {
t.Fatalf("valid digest: %v", err)
}
bad := req
bad.ContextDigest = strings.Repeat("E", 64)
if err := Validate(bad, cfg); err == nil {
t.Fatal("an uppercase digest was accepted")
}
bad = req
bad.ContextRef = "s3://bucket/ctx.tar.gz"
if err := Validate(bad, cfg); err == nil {
t.Fatal("a digest on a context Kaniko fetches itself was accepted")
}
}
// The fetch step hands the service token to the context URL's host, so an
// http(s) context must be an upload on the internal face (build-supply-chain-13).
func TestValidateConfinesHTTPContextsToTheInternalFace(t *testing.T) {
cfg := Config{RegistryURL: "registry.felis.svc:5000", ContextOrigin: "http://felis-api-internal.felis.svc.cluster.local:8081/"}
req := goodRequest()
for _, ref := range []string{
"http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context",
"HTTP://Felis-API-Internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context",
"tar://contexts/abc.tar.gz",
} {
req.ContextRef = ref
if err := Validate(req, cfg); err != nil {
t.Errorf("Validate(%q) = %v, want accepted", ref, err)
}
}
for _, ref := range []string{
"https://attacker.example/api/v1/internal/submissions/sub-1/context",
"http://felis-api-internal.felis.svc.cluster.local:8082/api/v1/internal/submissions/sub-1/context",
"https://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context",
"http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/servers",
"http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/../x/context",
"http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context?next=https://x",
"http://u:[email protected]:8081/api/v1/internal/submissions/sub-1/context",
} {
req.ContextRef = ref
if err := Validate(req, cfg); !errors.Is(err, ErrInvalid) {
t.Errorf("Validate(%q) = %v, want ErrInvalid", ref, err)
}
}
req.ContextRef = "http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context"
if err := Validate(req, Config{RegistryURL: "registry.felis.svc:5000"}); !errors.Is(err, ErrInvalid) {
t.Errorf("without an internal face configured an http context was accepted: %v", err)
}
}
func TestValidateRejectsReservedRepos(t *testing.T) {
cfg := Config{RegistryURL: "registry.felis.svc:5000"}
for _, ref := range []string{
+20 -11
View File
@@ -100,9 +100,12 @@ const buildJobTTL = 7 * 24 * time.Hour
// Build + Config by the Builder; jobspec is a pure function of them so the
// security-critical Job shape is unit-tested without a cluster.
type JobParams struct {
BuildID string
ImageRef string
ContextRef string
BuildID string
ImageRef string
ContextRef string
// ContextDigest, when set, is passed to the fetch container, which refuses a
// context whose sha256 differs (see Request.ContextDigest).
ContextDigest string
Namespace string
ServiceAccount string
RegistryURL string
@@ -269,7 +272,7 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
SizeLimit: quantityPtr(imageSizeLimit),
}},
}}
if isHTTPContextRef(p.ContextRef) {
if IsHTTPContextRef(p.ContextRef) {
contextPath = contextMountPath
// The fetch container runs as root while Kaniko keeps the image default
// (also root): Kaniko re-copies the Dockerfile out of the context and
@@ -287,11 +290,7 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
fetch := corev1.Container{
Name: ContainerFetch,
Image: p.FelisImage,
Args: []string{
"fetch-context",
"--url=" + p.ContextRef,
"--out=" + contextMountPath,
},
Args: fetchArgs(p),
// The internal face is service-token gated, and the token is read from a
// Secret the installer materializes in THIS namespace (secretKeyRef is
// namespace-local). It is mounted into this initContainer only: the Kaniko
@@ -461,10 +460,20 @@ func withDisk(limits corev1.ResourceList, d diskBounds) corev1.ResourceRequireme
return corev1.ResourceRequirements{Limits: lim, Requests: req}
}
// isHTTPContextRef reports whether ref is an http(s) URL — the shape the submit
// fetchArgs is the context-fetch container's argv. The digest flag rides along
// only when the build pins one; admin builds from a URL they supplied have none.
func fetchArgs(p JobParams) []string {
args := []string{"fetch-context", "--url=" + p.ContextRef, "--out=" + contextMountPath}
if p.ContextDigest != "" {
args = append(args, "--sha256="+p.ContextDigest)
}
return args
}
// IsHTTPContextRef reports whether ref is an http(s) URL — the shape the submit
// lane derives when the API is the blob transport — i.e. a context only the
// fetch initContainer can turn into a local path for Kaniko.
func isHTTPContextRef(ref string) bool {
func IsHTTPContextRef(ref string) bool {
return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://")
}
+31
View File
@@ -527,6 +527,37 @@ func TestBuildJobGatesEgressFirst(t *testing.T) {
}
}
// An approved submission pins its context digest on the fetch container, which
// then refuses any other bytes; a build without one fetches unpinned.
func TestBuildJobPinsContextDigest(t *testing.T) {
p := sampleJobParams()
p.ContextRef = "http://felis-api-internal.felis.svc.cluster.local:8081/ctx"
fetchArgsOf := func(p JobParams) []string {
t.Helper()
job, err := BuildJob(p)
if err != nil {
t.Fatalf("BuildJob: %v", err)
}
for _, c := range job.Spec.Template.Spec.InitContainers {
if c.Name == ContainerFetch {
return c.Args
}
}
t.Fatal("no fetch container")
return nil
}
for _, a := range fetchArgsOf(p) {
if strings.HasPrefix(a, "--sha256") {
t.Fatalf("unpinned build carries %q", a)
}
}
p.ContextDigest = strings.Repeat("d", 64)
args := fetchArgsOf(p)
if args[len(args)-1] != "--sha256="+p.ContextDigest {
t.Fatalf("fetch args = %v, want the digest pinned", args)
}
}
// The pod runs under RuntimeDefault seccomp always, and in a user namespace or
// a sandbox runtime when the install asks for them.
func TestBuildJobSandboxing(t *testing.T) {
+11 -7
View File
@@ -21,17 +21,17 @@ func NewPGStore(db *sql.DB) *PGStore { return &PGStore{db: db} }
func (s *PGStore) CreateBuild(ctx context.Context, b *Build) error {
const q = `INSERT INTO image_builds
(id, image_ref, status, dockerfile, context_ref, base_image, requested_by, created_at)
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, ''), $7, $8)`
(id, image_ref, status, dockerfile, context_ref, base_image, requested_by, created_at, context_digest)
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, ''), $7, $8, NULLIF($9, ''))`
_, err := s.db.ExecContext(ctx, q,
b.ID, b.ImageRef, string(b.Status), b.Dockerfile, b.ContextRef, b.BaseImage,
b.RequestedBy, b.CreatedAt)
b.RequestedBy, b.CreatedAt, b.ContextDigest)
return err
}
func (s *PGStore) GetBuild(ctx context.Context, id string) (*Build, error) {
const q = `SELECT id, image_ref, status, dockerfile, context_ref, base_image,
requested_by, job_name, log_ref, error, created_at, finished_at
requested_by, job_name, log_ref, error, created_at, finished_at, context_digest
FROM image_builds WHERE id = $1`
return s.scanBuild(s.db.QueryRowContext(ctx, q, id))
}
@@ -42,9 +42,10 @@ func (s *PGStore) scanBuild(row *sql.Row) (*Build, error) {
status string
ctxRef, base, jobName, logRef, eMsg sql.NullString
finished sql.NullTime
digest sql.NullString
)
switch err := row.Scan(&b.ID, &b.ImageRef, &status, &b.Dockerfile, &ctxRef, &base,
&b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished); {
&b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished, &digest); {
case err == sql.ErrNoRows:
return nil, ErrNotFound
case err != nil:
@@ -56,6 +57,7 @@ func (s *PGStore) scanBuild(row *sql.Row) (*Build, error) {
b.JobName = jobName.String
b.LogRef = logRef.String
b.Error = eMsg.String
b.ContextDigest = digest.String
if finished.Valid {
t := finished.Time
b.FinishedAt = &t
@@ -91,7 +93,7 @@ func (s *PGStore) FinishBuild(ctx context.Context, id string, status Status, err
func (s *PGStore) ListUnfinishedBuilds(ctx context.Context) ([]Build, error) {
const q = `SELECT id, image_ref, status, dockerfile, context_ref, base_image,
requested_by, job_name, log_ref, error, created_at, finished_at
requested_by, job_name, log_ref, error, created_at, finished_at, context_digest
FROM image_builds WHERE status IN ('pending', 'building') ORDER BY created_at ASC`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
@@ -105,9 +107,10 @@ func (s *PGStore) ListUnfinishedBuilds(ctx context.Context) ([]Build, error) {
status string
ctxRef, base, jobName, logRef, eMsg sql.NullString
finished sql.NullTime
digest sql.NullString
)
if err := rows.Scan(&b.ID, &b.ImageRef, &status, &b.Dockerfile, &ctxRef, &base,
&b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished); err != nil {
&b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished, &digest); err != nil {
return nil, err
}
b.Status = Status(status)
@@ -116,6 +119,7 @@ func (s *PGStore) ListUnfinishedBuilds(ctx context.Context) ([]Build, error) {
b.JobName = jobName.String
b.LogRef = logRef.String
b.Error = eMsg.String
b.ContextDigest = digest.String
if finished.Valid {
t := finished.Time
b.FinishedAt = &t
+58
View File
@@ -2,6 +2,7 @@ package build
import (
"fmt"
"net/url"
"regexp"
"strings"
@@ -37,9 +38,66 @@ func Validate(req Request, cfg Config) error {
if strings.TrimSpace(req.ContextRef) == "" {
return invalidf("context reference is required")
}
if IsHTTPContextRef(req.ContextRef) {
if err := validateContextURL(req.ContextRef, cfg.ContextOrigin); err != nil {
return err
}
}
if req.ContextDigest != "" {
if !IsSHA256Hex(req.ContextDigest) {
return invalidf("context digest %q is not a lowercase hex sha256", req.ContextDigest)
}
if !IsHTTPContextRef(req.ContextRef) {
return invalidf("a context digest needs an http(s) context reference, whose fetch step checks it")
}
}
return nil
}
// internalContextPathRE is the one internal-face route a build fetches from.
var internalContextPathRE = regexp.MustCompile(`^/api/v1/internal/submissions/[A-Za-z0-9_-]+/context$`)
// validateContextURL admits an http(s) context only when it is an uploaded
// submission on the platform's internal face. The fetch step sends the service
// token to whatever host the URL names, so an admin-typed URL pointing anywhere
// else would hand that token to a stranger.
func validateContextURL(ref, origin string) error {
if origin == "" {
return invalidf("an http(s) context reference is fetched from the platform's internal API, which this builder is not configured with")
}
u, err := url.Parse(ref)
if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" ||
URLOrigin(ref) != URLOrigin(origin) || !internalContextPathRE.MatchString(u.Path) {
return invalidf("an http(s) context reference must be an uploaded submission on the internal API (%s/api/v1/internal/submissions/<id>/context)",
strings.TrimRight(origin, "/"))
}
return nil
}
// URLOrigin returns the lowercased scheme://host[:port] of raw, or "" when raw
// is not an absolute http(s) URL.
func URLOrigin(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return ""
}
return strings.ToLower(u.Scheme + "://" + u.Host)
}
// IsSHA256Hex reports whether s is a lowercase hex-encoded sha256 digest, the
// form the submit lane records and the context fetcher compares against.
func IsSHA256Hex(s string) bool {
if len(s) != 64 {
return false
}
for _, c := range s {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
// ValidateImageRef checks a bare image reference (used by external admission,
// where there is no registry-target constraint beyond well-formedness). A
// whitelist entry may be a tag wildcard ("registry/foo:*", a legitimate