feat(registry): 写入改走鉴权网关,构建先扫描再推送

This commit is contained in:
Lemon-miaow committed 2026-09-24 14:25:17 +08:00
1 parent 8f684cedc0
commit 3424852a39
16 files changed
+1814 -145

No files matched your search

+14 -12
View File
@@ -1,22 +1,24 @@
// Package build implements the image build subsystem (spec §16) — "the
// platform's biggest security surface". A SysAdmin uploads a Dockerfile and a
// context tarball; felis-api starts an in-cluster Kaniko Job that builds and
// pushes to the internal registry, after which a Trivy scan gates admission to
// the image whitelist.
// context tarball; felis-api starts an in-cluster Job in which Kaniko builds the
// image into a tarball, Trivy scans that tarball, and only a clean image is
// pushed to the internal registry and admitted to the image whitelist.
//
// Trust model (spec §16, §22): we trust the SysAdmin at the *ingress* (only an
// admin through Zero Trust may submit a build) but never trust the *Dockerfile
// at runtime* — an arbitrary Dockerfile is build-time RCE whose victim is the
// cluster, not the uploader. So the build Pod runs with a deliberately weak
// service account in an isolated namespace that can only push to the registry
// service account in an isolated namespace that can only reach the registry
// and cannot touch the minecraft namespace, the felis database, or the K8s API
// (spec §21). Those isolation guarantees live in the Job/NetworkPolicy specs
// (jobspec.go) and are asserted by unit tests, since no cluster runs here.
//
// The Trivy gate is enforced as the build Pod's *exit code*: a kaniko
// initContainer builds and pushes, then a trivy container scans the pushed ref
// with `--exit-code 1 --severity CRITICAL`. Therefore "Job Succeeded" is
// equivalent to "pushed AND no CRITICAL CVE". felis-api observes the Job phase
// initContainer builds into a tarball (--no-push), a trivy initContainer scans it
// with `--exit-code 1 --severity CRITICAL`, and only then does the push container
// — the one holding the registry credential — publish it. Therefore "Job
// Succeeded" is equivalent to "no CRITICAL CVE AND pushed", and a rejected image
// never reaches the registry. felis-api observes the Job phase
// and performs the database writes — the build Pod itself never has database
// credentials (the weak-SA red line). On success the image is admitted to
// image_whitelist with enabled=true (recording added_by); on failure the build
@@ -70,11 +72,11 @@ const (
JobUnknown JobPhase = iota
JobPending
JobRunning
// JobSucceeded means kaniko pushed AND trivy found no CRITICAL CVE — the
// scan gate passed (spec §16).
// JobSucceeded means trivy found no CRITICAL CVE AND the image was pushed —
// the scan gate passed (spec §16).
JobSucceeded
// JobFailed means kaniko failed OR trivy found a CRITICAL CVE — the build
// is rejected and nothing is admitted.
// JobFailed means kaniko failed, trivy found a CRITICAL CVE, or the push
// failed — the build is rejected and nothing is admitted.
JobFailed
)
@@ -390,7 +392,7 @@ func (b *Builder) Get(ctx context.Context, id string) (*Build, error) {
// translation (spec §16). A terminal build is returned unchanged (idempotent).
//
// - JobSucceeded → status=succeeded AND the image is admitted to the whitelist
// with enabled=true (kaniko pushed and trivy found no CRITICAL CVE).
// with enabled=true (trivy found no CRITICAL CVE and the push landed).
// - JobFailed / JobUnknown → status=failed, nothing admitted (a CRITICAL CVE
// surfaces here as a failed Job, since trivy runs with --exit-code 1).
// - JobPending / JobRunning → no change.
+30
View File
@@ -217,6 +217,36 @@ 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.
func TestValidateRejectsReservedRepos(t *testing.T) {
cfg := Config{RegistryURL: "registry.felis.svc:5000"}
for _, ref := range []string{
"registry.felis.svc:5000/felis/felis:v0.1.0",
"registry.felis.svc:5000/felis:latest",
"registry.felis.svc:5000/felis",
"registry.felis.svc:5000/mirror/trivy-db:2",
} {
req := goodRequest()
req.ImageRef = ref
if err := Validate(req, cfg); !errors.Is(err, ErrInvalid) {
t.Errorf("Validate(%q) = %v, want ErrInvalid", ref, err)
}
}
for _, ref := range []string{
"registry.felis.svc:5000/user-uploads/s1:latest",
"registry.felis.svc:5000/felis-pack:1",
"registry.felis.svc:5000/builds/felis:1",
} {
req := goodRequest()
req.ImageRef = ref
if err := Validate(req, cfg); err != nil {
t.Errorf("Validate(%q) = %v, want accepted", ref, err)
}
}
}
func TestSubmitRejectsEmptyAndOversizeDockerfile(t *testing.T) {
b, _, _ := newBuilder()
req := goodRequest()
+111 -36
View File
@@ -26,14 +26,16 @@ const (
)
// Container names within the build Pod. Kaniko is the initContainer that builds
// and pushes the image — its log IS the "build log" an admin watches (spec §16);
// Trivy is the main container whose CRITICAL-CVE verdict gates admission and is
// surfaced via the build status, not the log stream. Exported so the build-log
// streamer (internal/api.K8sBuildLogStreamer, spec §416 日志流复用 §8) follows the
// same container this Job defines — one source of truth for the name.
// the image into a tarball — its log IS the "build log" an admin watches (spec
// §16); Trivy is the next initContainer, whose CRITICAL-CVE verdict gates both the
// push and admission and is surfaced via the build status, not the log stream;
// Push is the main container that publishes the scanned tarball. Exported so the
// build-log streamer (internal/api.K8sBuildLogStreamer, spec §416 日志流复用 §8)
// follows the same container this Job defines — one source of truth for the name.
const (
ContainerKaniko = "kaniko"
ContainerTrivy = "trivy"
ContainerPush = "push"
// ContainerFetch is the initContainer that pulls a submission's build context
// from the felis-api internal face and extracts it into the shared emptyDir.
// It exists only for an http(s) ContextRef (see BuildJob); a ref Kaniko can
@@ -44,8 +46,19 @@ const (
// initContainer writes the extracted tree there, Kaniko reads it read-only.
contextVolume = "context"
contextMountPath = "/context"
// imageVolume/imageTarPath carry the built image from Kaniko (--tar-path) to
// Trivy (--input) and then to the push container.
imageVolume = "image"
imageMountPath = "/image"
imageTarPath = imageMountPath + "/image.tar"
)
// imageSizeLimit bounds the built image tarball. A modpack image is typically a
// JRE, a server jar and a few hundred MiB of mods; 10 GiB leaves ample room while
// still stopping a runaway build from filling the node's disk.
var imageSizeLimit = resource.MustParse("10Gi")
// contextSizeLimit bounds the extracted (attacker-controlled) context tree so a
// tarball bomb wedges the build pod instead of the node's disk. The compressed
// upload is capped at 1 GiB by the submit lane; 4 GiB leaves expansion room.
@@ -120,14 +133,25 @@ func buildLabels(p JobParams) map[string]string {
// CRITICAL CVE fails the Pod and therefore the Job — the only retained
// automatic admission gate (spec §16).
//
// Sequencing: kaniko runs as an initContainer (build + push to the internal
// registry) and trivy as the main container (scan the pushed ref). The Pod
// succeeds only if kaniko pushed AND trivy found no CRITICAL CVE.
// Sequencing: kaniko builds with --no-push into a tarball, trivy scans that
// tarball, and only then does the push container publish it. So:
//
// - an image that fails the scan is never published — it used to be pushed to
// the final tag first and scanned after, overwriting whatever that tag held;
// - the registry credential lives in the push container alone. Kaniko executes
// the untrusted Dockerfile and holds no credential at all, and the registry
// refuses anonymous writes (internal/registrygate).
//
// The Pod succeeds only if kaniko built, trivy found no CRITICAL CVE, and the push
// landed.
func BuildJob(p JobParams) (*batchv1.Job, error) {
limits, err := resourceLimits(p.CPULimit, p.MemLimit)
if err != nil {
return nil, err
}
if p.FelisImage == "" {
return nil, fmt.Errorf("build: FelisImage is required: the push container runs it")
}
deadline := int64(p.Deadline / time.Second)
if deadline <= 0 {
deadline = int64(defaultDeadline / time.Second)
@@ -164,12 +188,15 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
// in place (s3://, or a path an installer pre-mounted) passes through untouched.
contextPath := p.ContextRef
initContainers := []corev1.Container{}
var kanikoMounts []corev1.VolumeMount
var podVolumes []corev1.Volume
imageMount := corev1.VolumeMount{Name: imageVolume, MountPath: imageMountPath}
kanikoMounts := []corev1.VolumeMount{imageMount}
podVolumes := []corev1.Volume{{
Name: imageVolume,
VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{
SizeLimit: quantityPtr(imageSizeLimit),
}},
}}
if isHTTPContextRef(p.ContextRef) {
if p.FelisImage == "" {
return nil, fmt.Errorf("build: context ref %q needs FelisImage for the fetch initContainer", 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
@@ -209,17 +236,17 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
SecurityContext: fetchSec,
}
initContainers = append(initContainers, fetch)
kanikoMounts = []corev1.VolumeMount{{Name: contextVolume, MountPath: contextMountPath, ReadOnly: true}}
podVolumes = []corev1.Volume{{
kanikoMounts = append(kanikoMounts, corev1.VolumeMount{Name: contextVolume, MountPath: contextMountPath, ReadOnly: true})
podVolumes = append(podVolumes, corev1.Volume{
Name: contextVolume,
VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{
// The extracted tree is attacker-controlled; bound it so a tarball
// bomb wedges THIS pod (admitted failure) instead of filling the
// node's disk. The compressed upload is capped at 1 GiB by the
// submit lane, and 4 GiB leaves room for a typical expansion.
SizeLimit: sizeLimitPtr(),
SizeLimit: quantityPtr(contextSizeLimit),
}},
}}
})
}
kaniko := corev1.Container{
@@ -228,16 +255,16 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
Args: []string{
"--dockerfile=Dockerfile",
"--context=" + contextPath,
// --destination only names the image inside the tarball; --no-push
// keeps Kaniko off the registry's write path entirely.
"--destination=" + p.ImageRef,
// The internal registry is in-cluster only and may serve plain HTTP;
// it is never a public ingress (spec §17). Both directions need the
// insecure flags: --insecure/--skip-tls-verify cover the PUSH, while
// the pull side needs its own pair — a Dockerfile's `FROM
// registry.felis.svc:5000/...` otherwise fails with "server gave
// HTTP response to HTTPS client", breaking every build based on a
"--no-push",
"--tar-path=" + imageTarPath,
// The internal registry is in-cluster only and serves plain HTTP; it
// is never a public ingress (spec §17). A Dockerfile's `FROM
// registry.felis.svc:5000/...` fails with "server gave HTTP response
// to HTTPS client" without these, breaking every build based on a
// platform image (the canonical modpack shape).
"--insecure",
"--skip-tls-verify",
"--insecure-pull",
"--skip-tls-verify-pull",
},
@@ -249,6 +276,7 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
trivyArgs := []string{
"image",
"--input", imageTarPath,
"--exit-code", "1",
"--severity", "CRITICAL",
"--no-progress",
@@ -265,14 +293,44 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
if p.TrivyJavaDBRepository != "" {
trivyArgs = append(trivyArgs, "--java-db-repository", p.TrivyJavaDBRepository)
}
trivyArgs = append(trivyArgs, p.ImageRef)
trivy := corev1.Container{
Name: ContainerTrivy,
Image: p.TrivyImage,
Args: trivyArgs,
VolumeMounts: []corev1.VolumeMount{{Name: imageVolume, MountPath: imageMountPath, ReadOnly: true}},
Resources: corev1.ResourceRequirements{Limits: limits, Requests: buildRequests(limits)},
SecurityContext: sec,
}
initContainers = append(initContainers, trivy)
// The publish step: the only container that holds the registry credential,
// read from a Secret the installer materializes in this namespace. It runs
// the felis binary (internal/imagepush), which only reads the tarball.
pushSec := sec.DeepCopy()
pushSec.ReadOnlyRootFilesystem = boolPtr(true)
secretEnv := func(name, key string) corev1.EnvVar {
return corev1.EnvVar{Name: name, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: naming.RegistryPushSecretName},
Key: key,
}}}
}
push := corev1.Container{
Name: ContainerPush,
Image: p.FelisImage,
Args: []string{
"push-image",
"--tar=" + imageTarPath,
"--ref=" + p.ImageRef,
"--scheme=http",
},
Env: []corev1.EnvVar{
secretEnv("FELIS_REGISTRY_USERNAME", naming.RegistryPushUsernameKey),
secretEnv("FELIS_REGISTRY_PASSWORD", naming.RegistryPushPasswordKey),
},
VolumeMounts: []corev1.VolumeMount{{Name: imageVolume, MountPath: imageMountPath, ReadOnly: true}},
Resources: corev1.ResourceRequirements{Limits: limits, Requests: buildRequests(limits)},
SecurityContext: pushSec,
}
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
@@ -293,7 +351,7 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
ServiceAccountName: p.ServiceAccount,
AutomountServiceAccountToken: boolPtr(false),
InitContainers: initContainers,
Containers: []corev1.Container{trivy},
Containers: []corev1.Container{push},
Volumes: podVolumes,
},
},
@@ -309,6 +367,20 @@ func isHTTPContextRef(ref string) bool {
return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://")
}
// ClusterDNSPeer selects the cluster DNS pods (CoreDNS in kube-system, labelled
// k8s-app=kube-dns on k3s and upstream alike) — the only resolver a sandboxed pod
// needs.
func ClusterDNSPeer() networkingv1.NetworkPolicyPeer {
return networkingv1.NetworkPolicyPeer{
NamespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"kubernetes.io/metadata.name": "kube-system"},
},
PodSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"k8s-app": "kube-dns"},
},
}
}
// NetPolParams parameterises the build-namespace egress lock.
type NetPolParams struct {
Namespace string
@@ -329,8 +401,8 @@ type NetPolParams struct {
// BuildNetworkPolicy renders the default-deny egress policy for build Pods
// (spec §16, §21: build ns egress 仅放 registry + 包源,默认拒外网). It selects
// build Pods by the managed-by label, denies all ingress, and allows egress
// only to DNS, the internal registry, and any explicitly configured package
// mirrors. There is deliberately no allow-all egress rule.
// only to the cluster DNS pods, the internal registry, felis-api's internal
// face, and any explicitly configured package mirrors. There is deliberately no allow-all egress rule.
func BuildNetworkPolicy(p NetPolParams) *networkingv1.NetworkPolicy {
port := p.RegistryPort
if port == 0 {
@@ -351,9 +423,13 @@ func BuildNetworkPolicy(p NetPolParams) *networkingv1.NetworkPolicy {
ctxPort := intstr.FromInt32(apiPort)
egress := []networkingv1.NetworkPolicyEgressRule{
// DNS resolution: port-restricted to 53, so this is not an open-internet
// hole — name resolution only.
// DNS resolution, to the cluster resolver only. Port 53 to ANY address
// would be an exfiltration channel out of an otherwise sealed sandbox
// (a Dockerfile RUN can speak DNS, or anything else, to a resolver it
// controls); the cluster DNS Service is DNATed to these pods before the
// policy is evaluated, so selecting them is what "resolve names" means.
{
To: []networkingv1.NetworkPolicyPeer{ClusterDNSPeer()},
Ports: []networkingv1.NetworkPolicyPort{
{Protocol: &dnsUDP, Port: &dns53},
{Protocol: &dnsTCP, Port: &dns53},
@@ -487,11 +563,10 @@ func buildRequests(limits corev1.ResourceList) corev1.ResourceList {
func boolPtr(b bool) *bool { return &b }
func int32Ptr(i int32) *int32 { return &i }
// sizeLimitPtr returns a copy of contextSizeLimit for a VolumeSource (the API
// object only ever gets serialized, but a shared pointer across rendered Jobs
// invites accidental aliasing).
func sizeLimitPtr() *resource.Quantity {
q := contextSizeLimit
// quantityPtr returns a pointer to a copy of q for a VolumeSource (the API object
// only ever gets serialized, but a shared pointer across rendered Jobs invites
// accidental aliasing).
func quantityPtr(q resource.Quantity) *resource.Quantity {
return &q
}
func int64Ptr(i int64) *int64 { return &i }
+116 -45
View File
@@ -17,6 +17,7 @@ func sampleJobParams() JobParams {
Namespace: defaultNamespace,
ServiceAccount: defaultServiceAccount,
RegistryURL: "registry.felis.svc:5000",
FelisImage: "felis:test",
KanikoImage: defaultKanikoImage,
TrivyImage: defaultTrivyImage,
Deadline: 30 * time.Minute,
@@ -159,55 +160,119 @@ func TestBuildJobRequestsAreASchedulableFloor(t *testing.T) {
}
}
// kaniko builds and pushes to the request's exact target; trivy gates admission
// with --exit-code 1 --severity CRITICAL on that same ref.
func TestBuildJobKanikoPushesAndTrivyGates(t *testing.T) {
// kaniko builds the request's exact target into a tarball and never pushes;
// trivy gates on that tarball with --exit-code 1 --severity CRITICAL; only then
// does the push container publish it. An image that fails the scan is therefore
// never in the registry, and the credential is never where the Dockerfile runs.
func TestBuildJobScansBeforePush(t *testing.T) {
p := sampleJobParams()
job, err := BuildJob(p)
if err != nil {
t.Fatalf("BuildJob: %v", err)
}
if len(job.Spec.Template.Spec.InitContainers) != 1 {
t.Fatalf("expected exactly one (kaniko) initContainer")
inits := job.Spec.Template.Spec.InitContainers
if len(inits) != 2 || inits[0].Name != ContainerKaniko || inits[1].Name != ContainerTrivy {
t.Fatalf("initContainers = %v, want [kaniko trivy]", initNames(inits))
}
kaniko := job.Spec.Template.Spec.InitContainers[0]
if kaniko.Name != "kaniko" {
t.Errorf("init container = %q, want kaniko", kaniko.Name)
kaniko, trivy := inits[0], inits[1]
for _, want := range []string{"--destination=" + p.ImageRef, "--no-push", "--tar-path=" + imageTarPath} {
if !hasArg(kaniko.Args, want) {
t.Errorf("kaniko args = %v, want %s", kaniko.Args, want)
}
}
if !hasArg(kaniko.Args, "--destination="+p.ImageRef) {
t.Errorf("kaniko must push to %q, args=%v", p.ImageRef, kaniko.Args)
for _, pushFlag := range []string{"--insecure", "--skip-tls-verify"} {
if hasArg(kaniko.Args, pushFlag) {
t.Errorf("kaniko args = %v still carry the push-side %s", kaniko.Args, pushFlag)
}
}
// The pull direction needs its own flags: --insecure/--skip-tls-verify only
// cover the push, and without the pull pair a Dockerfile's `FROM` fails
// against the plain-HTTP registry ("server gave HTTP response to HTTPS
// client") — the live failure this guards.
// The pull direction needs its own flags: without the pull pair a
// Dockerfile's `FROM` fails against the plain-HTTP registry ("server gave
// HTTP response to HTTPS client") — the live failure this guards.
for _, flag := range []string{"--insecure-pull", "--skip-tls-verify-pull"} {
if !hasArg(kaniko.Args, flag) {
t.Errorf("kaniko args = %v, want %s so base-image pulls use plain HTTP", kaniko.Args, flag)
}
}
if len(job.Spec.Template.Spec.Containers) != 1 {
t.Fatalf("expected exactly one (trivy) main container")
// The scan gate: a CRITICAL CVE must fail the Pod (and thus the Job) before
// the push container ever starts.
if !argPairPresent(trivy.Args, "--input", imageTarPath) {
t.Errorf("trivy must scan the built tarball, args=%v", trivy.Args)
}
trivy := job.Spec.Template.Spec.Containers[0]
if trivy.Name != "trivy" {
t.Errorf("main container = %q, want trivy", trivy.Name)
if hasArg(trivy.Args, p.ImageRef) {
t.Errorf("trivy must not scan the registry ref (nothing is pushed yet), args=%v", trivy.Args)
}
// The scan gate: a CRITICAL CVE must fail the Pod (and thus the Job).
if !argPairPresent(trivy.Args, "--exit-code", "1") {
t.Errorf("trivy must run with --exit-code 1, args=%v", trivy.Args)
}
if !argPairPresent(trivy.Args, "--severity", "CRITICAL") {
t.Errorf("trivy must gate on --severity CRITICAL, args=%v", trivy.Args)
}
if !hasArg(trivy.Args, p.ImageRef) {
t.Errorf("trivy must scan the pushed ref %q, args=%v", p.ImageRef, trivy.Args)
}
// No DB repositories configured: Trivy keeps its own defaults.
if hasArg(trivy.Args, "--db-repository") || hasArg(trivy.Args, "--java-db-repository") {
t.Errorf("unset DB repositories must not render --db-repository/--java-db-repository, args=%v", trivy.Args)
}
if len(job.Spec.Template.Spec.Containers) != 1 {
t.Fatalf("expected exactly one (push) main container")
}
push := job.Spec.Template.Spec.Containers[0]
if push.Name != ContainerPush || push.Image != p.FelisImage {
t.Errorf("main container = %s (%s), want push running the platform image", push.Name, push.Image)
}
if !hasArg(push.Args, "push-image") || !hasArg(push.Args, "--ref="+p.ImageRef) || !hasArg(push.Args, "--tar="+imageTarPath) {
t.Errorf("push args = %v, want push-image --tar=%s --ref=%s", push.Args, imageTarPath, p.ImageRef)
}
creds := map[string]string{}
for _, e := range push.Env {
if e.Value != "" || e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil {
t.Errorf("push env %s must come from a secretKeyRef, got %#v", e.Name, e)
continue
}
creds[e.Name] = e.ValueFrom.SecretKeyRef.Name
}
for _, name := range []string{"FELIS_REGISTRY_USERNAME", "FELIS_REGISTRY_PASSWORD"} {
if creds[name] != "felis-registry-push" {
t.Errorf("push env %s from secret %q, want felis-registry-push", name, creds[name])
}
}
// Nothing else in the pod may hold the credential.
for _, c := range inits {
for _, e := range c.Env {
if e.ValueFrom != nil && e.ValueFrom.SecretKeyRef != nil && e.ValueFrom.SecretKeyRef.Name == "felis-registry-push" {
t.Errorf("container %s holds the registry credential", c.Name)
}
}
}
// The tarball is shared through a bounded emptyDir, read-only past kaniko.
var imgVol *corev1.Volume
for i := range job.Spec.Template.Spec.Volumes {
if job.Spec.Template.Spec.Volumes[i].Name == imageVolume {
imgVol = &job.Spec.Template.Spec.Volumes[i]
}
}
if imgVol == nil || imgVol.EmptyDir == nil || imgVol.EmptyDir.SizeLimit == nil {
t.Fatalf("image volume must be a size-limited emptyDir, got %#v", imgVol)
}
for _, c := range []corev1.Container{trivy, push} {
ro := false
for _, m := range c.VolumeMounts {
if m.Name == imageVolume && m.ReadOnly {
ro = true
}
}
if !ro {
t.Errorf("%s must mount the image tarball read-only, got %v", c.Name, c.VolumeMounts)
}
}
}
func TestBuildJobNeedsFelisImage(t *testing.T) {
p := sampleJobParams()
p.FelisImage = ""
if _, err := BuildJob(p); err == nil {
t.Fatal("a build without FelisImage has no push container and must fail to render")
}
}
// Kaniko (and only kaniko) must carry back exactly the unpacking capability
@@ -274,21 +339,23 @@ func TestBuildJobTrivyDBRepositoryOverride(t *testing.T) {
if err != nil {
t.Fatalf("BuildJob: %v", err)
}
trivy := job.Spec.Template.Spec.Containers[0]
trivy := job.Spec.Template.Spec.InitContainers[1]
if trivy.Name != ContainerTrivy {
t.Fatalf("initContainers = %v, want trivy second", initNames(job.Spec.Template.Spec.InitContainers))
}
if !argPairPresent(trivy.Args, "--db-repository", p.TrivyDBRepository) {
t.Errorf("trivy args = %v, want --db-repository %s", trivy.Args, p.TrivyDBRepository)
}
if !argPairPresent(trivy.Args, "--java-db-repository", p.TrivyJavaDBRepository) {
t.Errorf("trivy args = %v, want --java-db-repository %s", trivy.Args, p.TrivyJavaDBRepository)
}
// The scanned image ref must stay the last argument.
if last := trivy.Args[len(trivy.Args)-1]; last != p.ImageRef {
t.Errorf("image ref must remain the last argument, args=%v", trivy.Args)
if !argPairPresent(trivy.Args, "--input", imageTarPath) {
t.Errorf("trivy args = %v, want --input %s", trivy.Args, imageTarPath)
}
}
// The build namespace egress lock must be default-deny: deny all ingress, and
// allow egress only to DNS + the internal registry — never an allow-all rule.
// allow egress only to cluster DNS + the internal registry — never an allow-all rule.
func TestBuildNetworkPolicyIsDefaultDeny(t *testing.T) {
np := BuildNetworkPolicy(NetPolParams{
Namespace: "felis-build",
@@ -321,6 +388,19 @@ func TestBuildNetworkPolicyIsDefaultDeny(t *testing.T) {
if !egressAllowsPort(np, 53) {
t.Error("egress must allow DNS (port 53)")
}
// ...but only to the cluster resolver: port 53 to any address is a way out
// of the sandbox for anything that speaks DNS (or anything at all) on 53.
for i, rule := range np.Spec.Egress {
for _, port := range rule.Ports {
if port.Port == nil || port.Port.IntVal != 53 {
continue
}
if len(rule.To) != 1 || rule.To[0].PodSelector == nil || rule.To[0].PodSelector.MatchLabels["k8s-app"] != "kube-dns" ||
rule.To[0].NamespaceSelector == nil || rule.To[0].NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"] != "kube-system" {
t.Errorf("egress rule %d opens port 53 to %v, want only kube-system/k8s-app=kube-dns", i, rule.To)
}
}
}
// The context fetch: build Pods stream submissions from the control
// namespace's internal face (defaults: felis + 8081).
if !egressAllowsNamespace(np, "felis") {
@@ -343,8 +423,8 @@ func TestBuildJobFetchesHTTPContext(t *testing.T) {
t.Fatalf("BuildJob: %v", err)
}
inits := job.Spec.Template.Spec.InitContainers
if len(inits) != 2 || inits[0].Name != ContainerFetch || inits[1].Name != ContainerKaniko {
t.Fatalf("initContainers = %v, want [%s %s]", initNames(inits), ContainerFetch, ContainerKaniko)
if len(inits) != 3 || inits[0].Name != ContainerFetch || inits[1].Name != ContainerKaniko || inits[2].Name != ContainerTrivy {
t.Fatalf("initContainers = %v, want [%s %s %s]", initNames(inits), ContainerFetch, ContainerKaniko, ContainerTrivy)
}
fetch, kaniko := inits[0], inits[1]
if fetch.Image != p.FelisImage {
@@ -405,17 +485,6 @@ func TestBuildJobFetchesHTTPContext(t *testing.T) {
}
}
// Without the platform image the fetch initContainer cannot run, so rendering an
// http(s) context must fail loudly at Job-creation time, not with an ImagePull
// error at 3am.
func TestBuildJobHTTPContextNeedsFelisImage(t *testing.T) {
p := sampleJobParams()
p.ContextRef = "https://example.invalid/sub-abc/context"
if _, err := BuildJob(p); err == nil {
t.Fatal("http(s) context without FelisImage must fail to render")
}
}
// A ref Kaniko reads natively (or an installer pre-mounted) must NOT grow the
// fetch initContainer: the transport is for http(s) only.
func TestBuildJobNativeContextNeedsNoFetch(t *testing.T) {
@@ -425,11 +494,13 @@ func TestBuildJobNativeContextNeedsNoFetch(t *testing.T) {
if err != nil {
t.Fatalf("BuildJob: %v", err)
}
if len(job.Spec.Template.Spec.InitContainers) != 1 || job.Spec.Template.Spec.InitContainers[0].Name != ContainerKaniko {
t.Errorf("a native ref must render just kaniko, got %v", initNames(job.Spec.Template.Spec.InitContainers))
if inits := job.Spec.Template.Spec.InitContainers; len(inits) != 2 || inits[0].Name != ContainerKaniko {
t.Errorf("a native ref must render just kaniko + trivy, got %v", initNames(inits))
}
if len(job.Spec.Template.Spec.Volumes) != 0 {
t.Errorf("a native ref must render no context volume, got %v", job.Spec.Template.Spec.Volumes)
for _, v := range job.Spec.Template.Spec.Volumes {
if v.Name == contextVolume {
t.Errorf("a native ref must render no context volume, got %v", job.Spec.Template.Spec.Volumes)
}
}
}
+2 -2
View File
@@ -61,8 +61,8 @@ func (k *K8sJobs) JobPhase(ctx context.Context, jobName string) (JobPhase, error
case batchv1.JobComplete:
return JobSucceeded, nil
case batchv1.JobFailed:
// Covers a CRITICAL CVE (trivy --exit-code 1), a kaniko failure, and
// DeadlineExceeded — all are a rejected build.
// Covers a CRITICAL CVE (trivy --exit-code 1), a kaniko or push
// failure, and DeadlineExceeded — all are a rejected build.
return JobFailed, nil
}
}
+11
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"regexp"
"strings"
"felis.lolicon.best/internal/registrygate"
)
// invalidf builds a validation error wrapping ErrInvalid so the API layer maps
@@ -112,6 +114,15 @@ func validateRegistryTarget(ref, registryURL string) error {
return invalidf("image reference %q must target the internal registry %q, not %q",
ref, registryHost(registryURL), host)
}
// The registry gate refuses the build principal these repositories anyway
// (they hold the platform's own images and the scanner's DB mirrors); refusing
// here turns a build that would fail at its last step into a 400 up front.
root, _, _ := strings.Cut(rest, "/")
for _, reserved := range registrygate.ReservedRepoRoots {
if root == reserved || strings.HasPrefix(root, reserved+":") {
return invalidf("image reference %q is in %s/, which is reserved for the platform's own images", ref, reserved)
}
}
return nil
}