feat(registry): 写入改走鉴权网关,构建先扫描再推送
This commit is contained in:
16 files changed
+1814
-145
No files matched your search
+14
-12
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user