fix(submit): 审阅绑定上下文 sha256,批准须带摘要,构建只从内部 API 取上下文并校验字节
This commit is contained in:
30 files changed
+910
-98
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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://")
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user