diff --git a/.changes/unreleased/+application-startup-verifier.yaml b/.changes/unreleased/+application-startup-verifier.yaml new file mode 100644 index 00000000..2589e275 --- /dev/null +++ b/.changes/unreleased/+application-startup-verifier.yaml @@ -0,0 +1,2 @@ +kind: Security +body: Package a trusted startup verifier into application images and fail closed unless seccomp, no-new-privileges, and empty capability sets are active before workload execution. diff --git a/docs/CONTROLLED_SESSION_DESIGN.md b/docs/CONTROLLED_SESSION_DESIGN.md index c840ca6c..1168fdd0 100644 --- a/docs/CONTROLLED_SESSION_DESIGN.md +++ b/docs/CONTROLLED_SESSION_DESIGN.md @@ -9,7 +9,10 @@ summary: Capability-scoped execution sessions that inherit Reploy's global conta ## Status - Decision state: Focused review complete; high-level decisions approved -- Implementation state: Not started +- Implementation state: Initial global sandbox prerequisites and trusted + application-startup verification implemented in the current slice; + controlled-session authorization, protocol, lifecycle, and Docker + orchestration remain later slices - Initial runtime: Linux containers under Docker - Motivating clients: OmegaFlow recording, sandboxed AI agents, security inspection, and untrusted-code execution @@ -1046,8 +1049,17 @@ commands directly as the final identity, drops all capabilities, enables `no-new-privileges`, explicitly selects Docker's built-in seccomp profile, and prohibits privileged mode, host namespaces, and host devices in the common plan. Live Docker tests inspect both runtime paths. Trusted production startup -verification, mount/root authority, network denial, and resource limits remain -separate prerequisite slices. +verification is also implemented: Reploy packages the platform-specific probe +in a final runtime layer, records that layer outside the provider graph, and +uses its fixed verify-and-exec contract as the outermost process for persistent +workloads, transient commands, shells, and lifecycle commands. The verifier +fails closed unless `/proc/self/status` reports seccomp filtering, +`no-new-privileges`, and empty effective, permitted, and bounding capability +sets, then directly executes the exact application argv. Private-environment +workloads use one additional fixed Reploy step: after verification, the probe +executes the environment injector, which imports the private variables and then +executes the unchanged application argv. Mount/root authority, network denial, +and resource limits remain separate prerequisite slices. ### Slice 2: Controlled-Session Lifecycle Core diff --git a/internal/deploy/build_lock.go b/internal/deploy/build_lock.go index 4cd143fa..7ff5bae1 100644 --- a/internal/deploy/build_lock.go +++ b/internal/deploy/build_lock.go @@ -28,6 +28,7 @@ type BuildLockV1 struct { Nodes []NodeLockV1 `json:"nodes"` Catalog []providers.RealizedOutput `json:"catalog"` RuntimePolicy RuntimePolicyV1 `json:"runtime_policy"` + RuntimeLayer ApplicationRuntimeLayerV1 `json:"runtime_layer"` ValidationRecord providerstore.StoreObjectRef `json:"validation_record"` FinalImage providers.RealizedImageV1 `json:"final_image"` } @@ -152,6 +153,14 @@ func ValidateBuildLockV1(lock BuildLockV1, validateProfileOwner providers.Requir if err := ValidateRuntimePolicyV1(lock.RuntimePolicy); err != nil { return fmt.Errorf("build lock runtime policy: %w", err) } + if err := ValidateApplicationRuntimeLayerV1(lock.RuntimeLayer, lock.Platform); err != nil { + return fmt.Errorf("build lock runtime layer: %w", err) + } + if lock.RuntimeLayer.Verifier.Schema != lock.RuntimePolicy.StartupVerifier.Schema || + lock.RuntimeLayer.Verifier.RecipeVersion != lock.RuntimePolicy.StartupVerifier.RecipeVersion || + lock.RuntimeLayer.Verifier.Path != lock.RuntimePolicy.StartupVerifier.Path { + return fmt.Errorf("build lock runtime layer verifier does not match the runtime policy") + } if err := lock.ValidationRecord.Validate(); err != nil { return fmt.Errorf("build lock validation record: %w", err) } @@ -202,10 +211,13 @@ func validateBuildLockImageLineage(lock BuildLockV1) error { } current = node.Result } - if lock.FinalImage.RootFSSubject != current.RootFSSubject { + if lock.RuntimeLayer.Upstream != current { + return fmt.Errorf("build lock runtime layer upstream does not match the final graph prefix") + } + if lock.FinalImage.RootFSSubject != lock.RuntimeLayer.Result.RootFSSubject { return fmt.Errorf( - "build lock final image root filesystem %s does not match the final graph prefix %s", - lock.FinalImage.RootFSSubject, current.RootFSSubject, + "build lock final image root filesystem %s does not match the application runtime layer %s", + lock.FinalImage.RootFSSubject, lock.RuntimeLayer.Result.RootFSSubject, ) } return nil diff --git a/internal/deploy/build_lock_test.go b/internal/deploy/build_lock_test.go index 37eaae77..b8ced3bf 100644 --- a/internal/deploy/build_lock_test.go +++ b/internal/deploy/build_lock_test.go @@ -26,6 +26,15 @@ func validBuildLock(t *testing.T) BuildLockV1 { if err != nil { t.Fatal(err) } + upstream := providers.RealizedImageV1{Digest: base.ConfigDigest, ConfigDigest: base.ConfigDigest, RootFSSubject: baseRootFS} + verifier := ApplicationStartupVerifierContractV1() + verifier.Artifact = buildLockTestDigest("6") + verifier.Size = "123" + transaction, err := ApplicationRuntimeLayerTransactionDigestV1(verifier, upstream, platform) + if err != nil { + t.Fatal(err) + } + result := providers.RealizedImageV1{Digest: buildLockTestDigest("5"), ConfigDigest: buildLockTestDigest("5"), RootFSSubject: buildLockTestDigest("7")} return BuildLockV1{ Schema: BuildLockSchemaV1, BlueprintDigest: buildLockTestDigest("0"), Overlay: EmptyRequestOverlayV1(), PackageOverrides: EmptyPackageOverrideIntentV1("demo"), @@ -33,8 +42,12 @@ func validBuildLock(t *testing.T) BuildLockV1 { Base: base, Graph: ProviderGraphLockV1{Nodes: []providers.NodeID{"base"}, Edges: []providers.ProviderEdgeV1{}}, Nodes: []NodeLockV1{}, Catalog: []providers.RealizedOutput{}, RuntimePolicy: validRuntimePolicy(), + RuntimeLayer: ApplicationRuntimeLayerV1{ + Schema: ApplicationRuntimeLayerSchemaV1, Verifier: verifier, TransactionDigest: transaction, + Upstream: upstream, Result: result, + }, ValidationRecord: providerstore.StoreObjectRef{Kind: providerstore.ValidationRecordKind, Digest: buildLockTestDigest("4")}, - FinalImage: providers.RealizedImageV1{Digest: buildLockTestDigest("5"), ConfigDigest: buildLockTestDigest("5"), RootFSSubject: baseRootFS}, + FinalImage: result, } } @@ -84,7 +97,11 @@ func addValidAPTNode(t *testing.T, lock *BuildLockV1) { GeneratedExecutables: []providers.RealizedGeneratedExecutable{}, Outputs: []providers.RealizedOutput{}, }} - lock.FinalImage.RootFSSubject = result.RootFSSubject + lock.RuntimeLayer.Upstream = result + lock.RuntimeLayer.TransactionDigest, err = ApplicationRuntimeLayerTransactionDigestV1(lock.RuntimeLayer.Verifier, result, lock.Platform) + if err != nil { + t.Fatal(err) + } } func TestBuildLockV1CanonicalRoundTripAndIdentity(t *testing.T) { @@ -160,7 +177,7 @@ func TestBuildLockV1RejectsDisconnectedImageLineage(t *testing.T) { t.Run("final root filesystem", func(t *testing.T) { lock := validBuildLock(t) lock.FinalImage.RootFSSubject = buildLockTestDigest("6") - if _, err := BuildLockDigestV1(lock, acceptBuildLockProfile); err == nil || !strings.Contains(err.Error(), "does not match the final graph prefix") { + if _, err := BuildLockDigestV1(lock, acceptBuildLockProfile); err == nil || !strings.Contains(err.Error(), "does not match the application runtime layer") { t.Fatalf("lineage error = %v", err) } }) @@ -179,6 +196,9 @@ func TestBuildLockV1RejectsInvalidNestedIdentity(t *testing.T) { {name: "missing node lock", mutate: func(value *BuildLockV1) { value.Graph.Nodes = []providers.NodeID{"apt", "base"} }, want: "missing node"}, {name: "nil catalog", mutate: func(value *BuildLockV1) { value.Catalog = nil }, want: "catalog"}, {name: "runtime policy", mutate: func(value *BuildLockV1) { value.RuntimePolicy.Schema = "bad" }, want: "runtime policy"}, + {name: "runtime verifier contract", mutate: func(value *BuildLockV1) { value.RuntimeLayer.Verifier.Path = "/bin/true" }, want: "startup verifier"}, + {name: "runtime layer transaction", mutate: func(value *BuildLockV1) { value.RuntimeLayer.TransactionDigest = buildLockTestDigest("f") }, want: "transaction digest"}, + {name: "runtime layer upstream", mutate: func(value *BuildLockV1) { value.RuntimeLayer.Upstream.ConfigDigest = buildLockTestDigest("f") }, want: "final graph prefix"}, {name: "validation kind", mutate: func(value *BuildLockV1) { value.ValidationRecord.Kind = providerstore.BlobKind }, want: "validation-record"}, {name: "final image", mutate: func(value *BuildLockV1) { value.FinalImage.RootFSSubject = "bad" }, want: "final image"}, } diff --git a/internal/deploy/runtime_policy.go b/internal/deploy/runtime_policy.go index f7946975..f4459562 100644 --- a/internal/deploy/runtime_policy.go +++ b/internal/deploy/runtime_policy.go @@ -26,9 +26,10 @@ const ( ) type RuntimePolicyV1 struct { - Schema string `json:"schema"` - ProtectedPaths []ProtectedPathV1 `json:"protected_paths"` - Plans []RuntimePlanV1 `json:"plans"` + Schema string `json:"schema"` + StartupVerifier ApplicationStartupVerifierV1 `json:"startup_verifier"` + ProtectedPaths []ProtectedPathV1 `json:"protected_paths"` + Plans []RuntimePlanV1 `json:"plans"` } type ProtectedPathV1 struct { @@ -63,6 +64,9 @@ func ValidateRuntimePolicyV1(policy RuntimePolicyV1) error { if policy.ProtectedPaths == nil || policy.Plans == nil { return fmt.Errorf("runtime policy collections must use arrays") } + if err := ValidateApplicationStartupVerifierV1(policy.StartupVerifier, false); err != nil { + return fmt.Errorf("runtime policy startup verifier: %w", err) + } for index, protected := range policy.ProtectedPaths { if err := validateRuntimeAbsolutePath("protected path", protected.Path); err != nil { return err diff --git a/internal/deploy/runtime_policy_test.go b/internal/deploy/runtime_policy_test.go index fe197e1b..88ea5222 100644 --- a/internal/deploy/runtime_policy_test.go +++ b/internal/deploy/runtime_policy_test.go @@ -9,7 +9,7 @@ import ( func validRuntimePolicy() RuntimePolicyV1 { return RuntimePolicyV1{ - Schema: RuntimePolicySchemaV1, + Schema: RuntimePolicySchemaV1, StartupVerifier: ApplicationStartupVerifierContractV1(), ProtectedPaths: []ProtectedPathV1{ {Path: "/.reploy", Kind: ProtectedPathReployRoot, Owner: "reploy"}, {Path: "/opt/app/bin/tool", Kind: ProtectedPathExecutablePath, Owner: "app.tool"}, diff --git a/internal/deploy/runtime_verifier.go b/internal/deploy/runtime_verifier.go new file mode 100644 index 00000000..422bf78a --- /dev/null +++ b/internal/deploy/runtime_verifier.go @@ -0,0 +1,102 @@ +package deploy + +import ( + "fmt" + "strconv" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/providers" +) + +const ( + ApplicationStartupVerifierSchemaV1 = "application-startup-verifier-v1" + ApplicationStartupVerifierRecipeV1 = "linux-proc-status-verify-exec-v1" + ApplicationStartupVerifierPathV1 = "/reploy-probe" + ApplicationRuntimeLayerSchemaV1 = "application-runtime-layer-v1" +) + +type ApplicationStartupVerifierV1 struct { + Schema string `json:"schema"` + RecipeVersion string `json:"recipe_version"` + Path string `json:"path"` + Artifact canonical.Digest `json:"artifact"` + Size string `json:"size"` +} + +type ApplicationRuntimeLayerV1 struct { + Schema string `json:"schema"` + Verifier ApplicationStartupVerifierV1 `json:"verifier"` + TransactionDigest canonical.Digest `json:"transaction_digest"` + Upstream providers.RealizedImageV1 `json:"upstream"` + Result providers.RealizedImageV1 `json:"result"` +} + +func ApplicationStartupVerifierContractV1() ApplicationStartupVerifierV1 { + return ApplicationStartupVerifierV1{ + Schema: ApplicationStartupVerifierSchemaV1, RecipeVersion: ApplicationStartupVerifierRecipeV1, + Path: ApplicationStartupVerifierPathV1, + } +} + +func ValidateApplicationStartupVerifierV1(verifier ApplicationStartupVerifierV1, requireArtifact bool) error { + want := ApplicationStartupVerifierContractV1() + if verifier.Schema != want.Schema || verifier.RecipeVersion != want.RecipeVersion || verifier.Path != want.Path { + return fmt.Errorf("application startup verifier does not use the supported fixed contract") + } + if !requireArtifact { + if verifier.Artifact != "" || verifier.Size != "" { + return fmt.Errorf("application startup verifier policy contract must not contain an artifact") + } + return nil + } + if err := verifier.Artifact.Validate(); err != nil { + return fmt.Errorf("application startup verifier artifact: %w", err) + } + size, err := strconv.ParseUint(verifier.Size, 10, 64) + if err != nil || size == 0 || strconv.FormatUint(size, 10) != verifier.Size { + return fmt.Errorf("application startup verifier size must be a canonical positive integer") + } + return nil +} + +func ApplicationRuntimeLayerTransactionDigestV1( + verifier ApplicationStartupVerifierV1, + upstream providers.RealizedImageV1, + platform blueprint.Platform, +) (canonical.Digest, error) { + if err := ValidateApplicationStartupVerifierV1(verifier, true); err != nil { + return "", err + } + if err := upstream.Validate(); err != nil { + return "", fmt.Errorf("application runtime layer upstream: %w", err) + } + if err := platform.Validate(); err != nil { + return "", fmt.Errorf("application runtime layer platform: %w", err) + } + return canonical.Sum("application-runtime-layer", ApplicationRuntimeLayerSchemaV1, struct { + Verifier ApplicationStartupVerifierV1 `json:"verifier"` + Upstream providers.RealizedImageV1 `json:"upstream"` + Platform blueprint.Platform `json:"platform"` + }{Verifier: verifier, Upstream: upstream, Platform: platform}) +} + +func ValidateApplicationRuntimeLayerV1(layer ApplicationRuntimeLayerV1, platform blueprint.Platform) error { + if layer.Schema != ApplicationRuntimeLayerSchemaV1 { + return fmt.Errorf("application runtime layer schema must be %q", ApplicationRuntimeLayerSchemaV1) + } + want, err := ApplicationRuntimeLayerTransactionDigestV1(layer.Verifier, layer.Upstream, platform) + if err != nil { + return err + } + if layer.TransactionDigest != want { + return fmt.Errorf("application runtime layer transaction digest does not match its inputs") + } + if err := layer.Result.Validate(); err != nil { + return fmt.Errorf("application runtime layer result: %w", err) + } + if layer.Result.RootFSSubject == layer.Upstream.RootFSSubject { + return fmt.Errorf("application runtime layer result must add the verifier filesystem layer") + } + return nil +} diff --git a/internal/dockerdeploy/application_runtime_layer.go b/internal/dockerdeploy/application_runtime_layer.go new file mode 100644 index 00000000..4f90e0d4 --- /dev/null +++ b/internal/dockerdeploy/application_runtime_layer.go @@ -0,0 +1,241 @@ +package dockerdeploy + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/probearchive" + "github.com/omry/reploy/internal/providerstore" +) + +type ApplicationRuntimeLayerBuildRequest struct { + Source InspectedImageCandidate + Verifier deploy.ApplicationStartupVerifierV1 + Platform blueprint.Platform +} + +var locateApplicationRuntimeExecutable = os.Executable +var runApplicationRuntimeBuildCommand = runCommand +var runApplicationRuntimeBuildDocker = runDockerOutput + +func LoadApplicationStartupVerifierV1(platform blueprint.Platform) (deploy.ApplicationStartupVerifierV1, error) { + if err := platform.Validate(); err != nil { + return deploy.ApplicationStartupVerifierV1{}, fmt.Errorf("load application startup verifier platform: %w", err) + } + if platform.OS != "linux" || !probearchive.Supports(platform.Canonical) { + return deploy.ApplicationStartupVerifierV1{}, fmt.Errorf("application startup verifier does not support platform %q", platform.Canonical) + } + executable, err := locateApplicationRuntimeExecutable() + if err != nil { + return deploy.ApplicationStartupVerifierV1{}, fmt.Errorf("locate Reploy probe archive: %w", err) + } + manifest, err := probearchive.Verify(executable) + if err != nil { + return deploy.ApplicationStartupVerifierV1{}, fmt.Errorf("load Reploy probe archive: %w", err) + } + for _, entry := range manifest.Entries { + if entry.Platform != platform.Canonical { + continue + } + verifier := deploy.ApplicationStartupVerifierContractV1() + verifier.Artifact = entry.SHA256 + verifier.Size = entry.Size + if err := deploy.ValidateApplicationStartupVerifierV1(verifier, true); err != nil { + return deploy.ApplicationStartupVerifierV1{}, err + } + return verifier, nil + } + return deploy.ApplicationStartupVerifierV1{}, fmt.Errorf("Reploy probe archive omits platform %q", platform.Canonical) +} + +func ApplicationRuntimeLayerDockerfile(request ApplicationRuntimeLayerBuildRequest) ([]byte, error) { + if err := validateApplicationRuntimeLayerBuildRequest(request); err != nil { + return nil, err + } + originalUser := "" + if request.Source.Config.User != "" { + var err error + originalUser, err = quoteDockerfileWord(request.Source.Config.User) + if err != nil { + return nil, fmt.Errorf("render application runtime source user: %w", err) + } + } + var output bytes.Buffer + fmt.Fprintf(&output, "# syntax=%s\n", MaterializationDockerfileSyntax) + output.WriteString("ARG REPLOY_BASE_IMAGE=scratch\n") + output.WriteString("FROM ${REPLOY_BASE_IMAGE}\n") + if originalUser != "" { + output.WriteString("USER 0:0\n") + } + fmt.Fprintf( + &output, + "RUN --mount=type=bind,source=%s,target=/reploy-build-probe,readonly [\"/reploy-build-probe\", \"install-runtime-verifier\", %s]\n", + probearchive.ExtractedFileName, + strconv.Quote(request.Verifier.Path), + ) + if originalUser != "" { + fmt.Fprintf(&output, "USER %s\n", originalUser) + } + return output.Bytes(), nil +} + +func BuildApplicationRuntimeLayerCandidate( + store providerstore.Store, + request ApplicationRuntimeLayerBuildRequest, + options RunOptions, +) (result BuiltImageCandidate, resultErr error) { + dockerfile, err := ApplicationRuntimeLayerDockerfile(request) + if err != nil { + return BuiltImageCandidate{}, err + } + workspace, err := store.NewWorkspace("build-*") + if err != nil { + return BuiltImageCandidate{}, err + } + preserveWorkspace := false + defer func() { + if !preserveWorkspace { + _ = os.RemoveAll(workspace) + } + }() + ctx := options.Context + if ctx == nil { + ctx = context.Background() + } + contextDir := filepath.Join(workspace, "context") + if err := os.Mkdir(contextDir, 0o700); err != nil { + return BuiltImageCandidate{}, fmt.Errorf("create application runtime build context: %w", err) + } + executable, err := locateApplicationRuntimeExecutable() + if err != nil { + return BuiltImageCandidate{}, fmt.Errorf("locate Reploy probe archive: %w", err) + } + extracted, err := probearchive.Extract(ctx, executable, request.Platform.Canonical, contextDir) + if err != nil { + return BuiltImageCandidate{}, fmt.Errorf("extract application startup verifier: %w", err) + } + if extracted.SHA256 != request.Verifier.Artifact || extracted.Size != request.Verifier.Size || filepath.Base(extracted.Path) != probearchive.ExtractedFileName { + return BuiltImageCandidate{}, fmt.Errorf("extracted application startup verifier does not match the build request") + } + baseReference, cleanupBaseReference, err := prepareTemporaryBuildBaseReference( + ctx, store.Root(), workspace, request.Source.Image, runApplicationRuntimeBuildDocker, + ) + if err != nil { + return BuiltImageCandidate{}, err + } + defer func() { + if cleanupErr := cleanupTemporaryBuildBaseReferenceAfterBuild( + context.WithoutCancel(ctx), cleanupBaseReference, result, runApplicationRuntimeBuildDocker, + ); cleanupErr != nil { + preserveWorkspace = true + if resultErr != nil { + resultErr = fmt.Errorf("%w; cleanup temporary application runtime base reference: %v", resultErr, cleanupErr) + } else { + result = BuiltImageCandidate{} + resultErr = fmt.Errorf("cleanup temporary application runtime base reference: %w", cleanupErr) + } + } + }() + outputReference, err := prepareTemporaryBuildOutputReference(ctx, store.Root(), workspace, runApplicationRuntimeBuildDocker) + if err != nil { + return BuiltImageCandidate{}, err + } + defer func() { + if resultErr == nil { + return + } + if cleanupErr := removeTemporaryBuildReference(context.WithoutCancel(ctx), outputReference, "", runApplicationRuntimeBuildDocker); cleanupErr != nil { + preserveWorkspace = true + resultErr = fmt.Errorf("%w; cleanup temporary application runtime output reference: %v", resultErr, cleanupErr) + } + }() + dockerfilePath := filepath.Join(workspace, "Dockerfile") + if err := os.WriteFile(dockerfilePath, dockerfile, 0o600); err != nil { + return BuiltImageCandidate{}, fmt.Errorf("write application runtime Dockerfile: %w", err) + } + iidPath := filepath.Join(workspace, "result.iid") + command, err := MaterializationBuildCommand(MaterializationBuildPlan{ + BaseReference: baseReference, OutputReference: outputReference, Platform: request.Platform, + DockerfilePath: dockerfilePath, ContextDir: contextDir, IIDFile: iidPath, NoCache: options.NoCache, + }) + if err != nil { + return BuiltImageCandidate{}, err + } + if err := runApplicationRuntimeBuildCommand(command, options); err != nil { + return BuiltImageCandidate{}, fmt.Errorf("build application runtime layer: %w", err) + } + content, err := os.ReadFile(iidPath) + if err != nil { + return BuiltImageCandidate{}, fmt.Errorf("read application runtime image ID: %w", err) + } + imageID := canonical.Digest(strings.TrimSpace(string(content))) + if err := imageID.Validate(); err != nil { + return BuiltImageCandidate{}, fmt.Errorf("application runtime image ID: %w", err) + } + preserveWorkspace = true + return BuiltImageCandidate{ImageID: imageID, TemporaryReference: outputReference, Workspace: workspace}, nil +} + +func InspectApplicationRuntimeLayerCandidate( + ctx context.Context, + built BuiltImageCandidate, + request ApplicationRuntimeLayerBuildRequest, +) (InspectedImageCandidate, error) { + if err := validateApplicationRuntimeLayerBuildRequest(request); err != nil { + return InspectedImageCandidate{}, err + } + candidate, err := inspectBuiltImageCandidate(ctx, built, request.Platform, runDockerOutput) + if err != nil { + return InspectedImageCandidate{}, err + } + if err := ValidateInspectedApplicationRuntimeLayerCandidate(request, candidate); err != nil { + return InspectedImageCandidate{}, err + } + return candidate, nil +} + +func ValidateInspectedApplicationRuntimeLayerCandidate( + request ApplicationRuntimeLayerBuildRequest, + candidate InspectedImageCandidate, +) error { + if err := validateApplicationRuntimeLayerBuildRequest(request); err != nil { + return err + } + if err := ValidateInspectedImageCandidateIdentity(candidate); err != nil { + return err + } + if !reflect.DeepEqual(candidate.Config, request.Source.Config) || !reflect.DeepEqual(candidate.Labels, request.Source.Labels) { + return fmt.Errorf("application runtime layer changed inherited image configuration") + } + wantPrefix := request.Source.Descriptor.RootFSDiffIDs + got := candidate.Descriptor.RootFSDiffIDs + if len(got) != len(wantPrefix)+1 || !reflect.DeepEqual(got[:len(wantPrefix)], wantPrefix) { + return fmt.Errorf("application runtime layer must add exactly one filesystem layer to its source") + } + return nil +} + +func validateApplicationRuntimeLayerBuildRequest(request ApplicationRuntimeLayerBuildRequest) error { + if err := ValidateInspectedImageCandidateIdentity(request.Source); err != nil { + return fmt.Errorf("application runtime layer source: %w", err) + } + if err := deploy.ValidateApplicationStartupVerifierV1(request.Verifier, true); err != nil { + return err + } + if err := request.Platform.Validate(); err != nil { + return fmt.Errorf("application runtime layer platform: %w", err) + } + if request.Platform.OS != "linux" || request.Source.Descriptor.Platform != request.Platform { + return fmt.Errorf("application runtime layer requires its exact Linux source platform") + } + return nil +} diff --git a/internal/dockerdeploy/application_runtime_layer_retention.go b/internal/dockerdeploy/application_runtime_layer_retention.go new file mode 100644 index 00000000..07967888 --- /dev/null +++ b/internal/dockerdeploy/application_runtime_layer_retention.go @@ -0,0 +1,72 @@ +package dockerdeploy + +import ( + "context" + "fmt" + "strings" + + "github.com/omry/reploy/internal/providers" +) + +const verifiedApplicationRuntimeLayerRepository = "reploy/cache/application-runtime-layer" + +func RetainVerifiedApplicationRuntimeLayer( + ctx context.Context, + candidate BuiltImageCandidate, + image providers.RealizedImageV1, +) error { + return retainVerifiedApplicationRuntimeLayer(ctx, candidate, image, runDockerOutput) +} + +func retainVerifiedApplicationRuntimeLayer( + ctx context.Context, + candidate BuiltImageCandidate, + image providers.RealizedImageV1, + run dockerOutputRunner, +) error { + if ctx == nil { + return fmt.Errorf("retain application runtime layer requires a context") + } + if err := ctx.Err(); err != nil { + return err + } + if err := image.Validate(); err != nil { + return fmt.Errorf("retain application runtime layer image: %w", err) + } + if run == nil { + return fmt.Errorf("retain application runtime layer requires a Docker runner") + } + reference := verifiedApplicationRuntimeLayerReference(image) + output, err := run(ctx, "image", "inspect", "--format", "{{.Id}}", reference) + if err == nil { + if got := strings.TrimSpace(output); got != string(image.ConfigDigest) { + return fmt.Errorf("application runtime layer cache reference %q names config ID %q, want %s", reference, got, image.ConfigDigest) + } + return removeBuiltImageCandidate(ctx, candidate, run) + } + if !dockerImageInspectReportsMissing(err) { + return fmt.Errorf("inspect application runtime layer cache reference %q: %w", reference, err) + } + if _, err := run(ctx, "image", "tag", string(image.ConfigDigest), reference); err != nil { + return fmt.Errorf("create application runtime layer cache reference %q: %w", reference, err) + } + output, inspectErr := run(ctx, "image", "inspect", "--format", "{{.Id}}", reference) + if inspectErr == nil && strings.TrimSpace(output) == string(image.ConfigDigest) { + return removeBuiltImageCandidate(ctx, candidate, run) + } + cleanupErr := removeExactEnvironmentImageReference(ctx, image, reference, run) + if inspectErr != nil { + if cleanupErr != nil { + return fmt.Errorf("verify application runtime layer cache reference %q: %v; cleanup failed: %w", reference, inspectErr, cleanupErr) + } + return fmt.Errorf("verify application runtime layer cache reference %q: %w", reference, inspectErr) + } + if cleanupErr != nil { + return fmt.Errorf("application runtime layer cache reference %q names config ID %q, want %s; cleanup failed: %w", reference, strings.TrimSpace(output), image.ConfigDigest, cleanupErr) + } + return fmt.Errorf("application runtime layer cache reference %q names config ID %q, want %s", reference, strings.TrimSpace(output), image.ConfigDigest) +} + +func verifiedApplicationRuntimeLayerReference(image providers.RealizedImageV1) string { + return verifiedApplicationRuntimeLayerRepository + ":sha256-" + strings.TrimPrefix(string(image.ConfigDigest), "sha256:") +} diff --git a/internal/dockerdeploy/application_runtime_layer_retention_test.go b/internal/dockerdeploy/application_runtime_layer_retention_test.go new file mode 100644 index 00000000..2b0d7342 --- /dev/null +++ b/internal/dockerdeploy/application_runtime_layer_retention_test.go @@ -0,0 +1,142 @@ +package dockerdeploy + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/providers" +) + +func retainedApplicationRuntimeLayerFixture() providers.RealizedImageV1 { + return providers.RealizedImageV1{ + Digest: rendererDigest("7"), + ConfigDigest: rendererDigest("7"), + RootFSSubject: rendererDigest("8"), + } +} + +func TestRetainVerifiedApplicationRuntimeLayerCreatesContentAddressedReference(t *testing.T) { + image := retainedApplicationRuntimeLayerFixture() + candidate := BuiltImageCandidate{ + ImageID: image.ConfigDigest, TemporaryReference: temporaryBuildReferencePrefix + "12345678:build-output", + } + reference := verifiedApplicationRuntimeLayerReference(image) + calls := [][]string{} + err := retainVerifiedApplicationRuntimeLayer(t.Context(), candidate, image, func(_ context.Context, args ...string) (string, error) { + calls = append(calls, append([]string{}, args...)) + switch len(calls) { + case 1: + return "", errors.New("Error response from daemon: No such image: " + reference) + case 2: + return "", nil + case 3, 4: + return string(image.ConfigDigest) + "\n", nil + case 5: + return "", nil + default: + t.Fatalf("unexpected Docker call %d: %#v", len(calls), args) + return "", nil + } + }) + if err != nil { + t.Fatal(err) + } + want := [][]string{ + {"image", "inspect", "--format", "{{.Id}}", reference}, + {"image", "tag", string(image.ConfigDigest), reference}, + {"image", "inspect", "--format", "{{.Id}}", reference}, + {"image", "ls", "--quiet", "--no-trunc", candidate.TemporaryReference}, + {"image", "rm", candidate.TemporaryReference}, + } + if !reflect.DeepEqual(calls, want) { + t.Fatalf("Docker calls = %#v, want %#v", calls, want) + } +} + +func TestRetainVerifiedApplicationRuntimeLayerReusesExactReference(t *testing.T) { + image := retainedApplicationRuntimeLayerFixture() + candidate := BuiltImageCandidate{ + ImageID: image.ConfigDigest, TemporaryReference: temporaryBuildReferencePrefix + "12345678:build-output", + } + reference := verifiedApplicationRuntimeLayerReference(image) + calls := [][]string{} + err := retainVerifiedApplicationRuntimeLayer(t.Context(), candidate, image, func(_ context.Context, args ...string) (string, error) { + calls = append(calls, append([]string{}, args...)) + switch len(calls) { + case 1, 2: + return string(image.ConfigDigest), nil + case 3: + return "", nil + default: + t.Fatalf("unexpected Docker call %d: %#v", len(calls), args) + return "", nil + } + }) + if err != nil { + t.Fatal(err) + } + want := [][]string{ + {"image", "inspect", "--format", "{{.Id}}", reference}, + {"image", "ls", "--quiet", "--no-trunc", candidate.TemporaryReference}, + {"image", "rm", candidate.TemporaryReference}, + } + if !reflect.DeepEqual(calls, want) { + t.Fatalf("Docker calls = %#v, want %#v", calls, want) + } +} + +func TestRetainVerifiedApplicationRuntimeLayerRejectsReferenceMismatch(t *testing.T) { + image := retainedApplicationRuntimeLayerFixture() + reference := verifiedApplicationRuntimeLayerReference(image) + calls := 0 + err := retainVerifiedApplicationRuntimeLayer(t.Context(), BuiltImageCandidate{ImageID: image.ConfigDigest}, image, func(_ context.Context, args ...string) (string, error) { + calls++ + if !reflect.DeepEqual(args, []string{"image", "inspect", "--format", "{{.Id}}", reference}) { + t.Fatalf("Docker call = %#v", args) + } + return string(rendererDigest("9")), nil + }) + if err == nil || !strings.Contains(err.Error(), "want "+string(image.ConfigDigest)) || calls != 1 { + t.Fatalf("calls = %d; mismatch error = %v", calls, err) + } +} + +func TestRetainVerifiedApplicationRuntimeLayerRollsBackUnverifiedReference(t *testing.T) { + image := retainedApplicationRuntimeLayerFixture() + reference := verifiedApplicationRuntimeLayerReference(image) + calls := [][]string{} + err := retainVerifiedApplicationRuntimeLayer(t.Context(), BuiltImageCandidate{ImageID: image.ConfigDigest}, image, func(_ context.Context, args ...string) (string, error) { + calls = append(calls, append([]string{}, args...)) + switch len(calls) { + case 1: + return "", errors.New("Error response from daemon: No such image: " + reference) + case 2: + return "", nil + case 3: + return "", errors.New("inspect failed") + case 4: + return string(image.ConfigDigest), nil + case 5: + return "", nil + default: + t.Fatalf("unexpected Docker call %d: %#v", len(calls), args) + return "", nil + } + }) + if err == nil || !strings.Contains(err.Error(), "inspect failed") { + t.Fatalf("rollback error = %v", err) + } + want := [][]string{ + {"image", "inspect", "--format", "{{.Id}}", reference}, + {"image", "tag", string(image.ConfigDigest), reference}, + {"image", "inspect", "--format", "{{.Id}}", reference}, + {"image", "ls", "--quiet", "--no-trunc", reference}, + {"image", "rm", "--force", reference}, + } + if !reflect.DeepEqual(calls, want) { + t.Fatalf("Docker calls = %#v, want %#v", calls, want) + } +} diff --git a/internal/dockerdeploy/application_runtime_layer_test.go b/internal/dockerdeploy/application_runtime_layer_test.go new file mode 100644 index 00000000..16c886ca --- /dev/null +++ b/internal/dockerdeploy/application_runtime_layer_test.go @@ -0,0 +1,121 @@ +package dockerdeploy + +import ( + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/deploy" +) + +func applicationRuntimeLayerTestRequest(t *testing.T) ApplicationRuntimeLayerBuildRequest { + t.Helper() + _, finalization := finalizationBuildFixture(t) + verifier := deploy.ApplicationStartupVerifierContractV1() + verifier.Artifact = rendererDigest("a") + verifier.Size = "123" + return ApplicationRuntimeLayerBuildRequest{ + Source: finalization.Source, Verifier: verifier, Platform: finalization.Platform, + } +} + +func applicationRuntimeLayerTestCandidate(t *testing.T, request ApplicationRuntimeLayerBuildRequest) InspectedImageCandidate { + t.Helper() + candidate := request.Source + candidate.Descriptor.RootFSDiffIDs = append(append([]canonical.Digest{}, candidate.Descriptor.RootFSDiffIDs...), rendererDigest("b")) + candidate.Descriptor.AuthorReference = string(rendererDigest("c")) + candidate.Descriptor.ImmutableReference = string(rendererDigest("c")) + candidate.Descriptor.ConfigDigest = rendererDigest("c") + rootFS, err := deploy.RootFSSubject(candidate.Descriptor.RootFSDiffIDs) + if err != nil { + t.Fatal(err) + } + candidate.Image.Digest = candidate.Descriptor.ConfigDigest + candidate.Image.ConfigDigest = candidate.Descriptor.ConfigDigest + candidate.Image.RootFSSubject = rootFS + return candidate +} + +func TestApplicationRuntimeLayerDockerfileAddsOnlyFixedVerifier(t *testing.T) { + request := applicationRuntimeLayerTestRequest(t) + content, err := ApplicationRuntimeLayerDockerfile(request) + if err != nil { + t.Fatal(err) + } + dockerfile := string(content) + for _, want := range []string{ + "# syntax=" + MaterializationDockerfileSyntax, + "FROM ${REPLOY_BASE_IMAGE}", + `RUN --mount=type=bind,source=reploy-probe,target=/reploy-build-probe,readonly ["/reploy-build-probe", "install-runtime-verifier", "/reploy-probe"]`, + } { + if !strings.Contains(dockerfile, want) { + t.Fatalf("Dockerfile missing %q:\n%s", want, dockerfile) + } + } + for _, forbidden := range []string{"COPY ", "ADD ", "USER ", "ENTRYPOINT ", "CMD "} { + if strings.Contains(dockerfile, forbidden) { + t.Fatalf("Dockerfile contains %q:\n%s", forbidden, dockerfile) + } + } +} + +func TestApplicationRuntimeLayerDockerfileRestoresInheritedUser(t *testing.T) { + request := applicationRuntimeLayerTestRequest(t) + request.Source.Config.User = "12345:23456" + content, err := ApplicationRuntimeLayerDockerfile(request) + if err != nil { + t.Fatal(err) + } + dockerfile := string(content) + if !strings.Contains(dockerfile, "USER 0:0\nRUN ") || !strings.HasSuffix(dockerfile, "USER \"12345:23456\"\n") { + t.Fatalf("Dockerfile does not switch to root and restore the inherited user:\n%s", dockerfile) + } +} + +func TestValidateInspectedApplicationRuntimeLayerCandidatePreservesConfigAndAddsOneLayer(t *testing.T) { + request := applicationRuntimeLayerTestRequest(t) + candidate := applicationRuntimeLayerTestCandidate(t, request) + if err := ValidateInspectedApplicationRuntimeLayerCandidate(request, candidate); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + mutate func(*InspectedImageCandidate) + want string + }{ + {name: "configuration", mutate: func(value *InspectedImageCandidate) { value.Config.User = "0:0" }, want: "configuration"}, + {name: "labels", mutate: func(value *InspectedImageCandidate) { value.Labels = map[string]string{"changed": "yes"} }, want: "configuration"}, + {name: "no layer", mutate: func(value *InspectedImageCandidate) { + value.Descriptor.RootFSDiffIDs = append([]canonical.Digest{}, request.Source.Descriptor.RootFSDiffIDs...) + value.Image.RootFSSubject = request.Source.Image.RootFSSubject + }, want: "exactly one"}, + {name: "changed prefix", mutate: func(value *InspectedImageCandidate) { + value.Descriptor.RootFSDiffIDs[0] = rendererDigest("d") + rootFS, err := deploy.RootFSSubject(value.Descriptor.RootFSDiffIDs) + if err != nil { + t.Fatal(err) + } + value.Image.RootFSSubject = rootFS + }, want: "exactly one"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + changed := candidate + changed.Descriptor.RootFSDiffIDs = append([]canonical.Digest{}, candidate.Descriptor.RootFSDiffIDs...) + changed.Labels = make(map[string]string, len(candidate.Labels)) + for name, value := range candidate.Labels { + changed.Labels[name] = value + } + test.mutate(&changed) + err := ValidateInspectedApplicationRuntimeLayerCandidate(request, changed) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } + if !reflect.DeepEqual(candidate.Config, request.Source.Config) { + t.Fatal("fixture changed inherited config") + } +} diff --git a/internal/dockerdeploy/application_sandbox_plan.go b/internal/dockerdeploy/application_sandbox_plan.go index 698442a7..8a48c727 100644 --- a/internal/dockerdeploy/application_sandbox_plan.go +++ b/internal/dockerdeploy/application_sandbox_plan.go @@ -5,6 +5,8 @@ import ( "path" "slices" "strconv" + + "github.com/omry/reploy/internal/deploy" ) const applicationSeccompProfileBuiltinV1 = "builtin" @@ -23,17 +25,19 @@ type ApplicationKernelPolicyV1 struct { // currently enforces; later sandbox slices extend this plan rather than adding // renderer-specific flags. type ApplicationSandboxPlanV1 struct { - RuntimeUser RuntimeUserPlan - ReadOnlyRoot bool - TemporaryHome string - Kernel ApplicationKernelPolicyV1 + RuntimeUser RuntimeUserPlan + ReadOnlyRoot bool + TemporaryHome string + StartupVerifier deploy.ApplicationStartupVerifierV1 + Kernel ApplicationKernelPolicyV1 } func newApplicationSandboxPlanV1(runtimeUser RuntimeUserPlan) ApplicationSandboxPlanV1 { return ApplicationSandboxPlanV1{ - RuntimeUser: runtimeUser, - ReadOnlyRoot: true, - TemporaryHome: environmentTemporaryHome, + RuntimeUser: runtimeUser, + ReadOnlyRoot: true, + TemporaryHome: environmentTemporaryHome, + StartupVerifier: deploy.ApplicationStartupVerifierContractV1(), Kernel: ApplicationKernelPolicyV1{ DropAllCapabilities: true, NoNewPrivileges: true, @@ -70,6 +74,9 @@ func ValidateApplicationSandboxPlanV1(plan ApplicationSandboxPlanV1) error { if plan.TemporaryHome != environmentTemporaryHome || !path.IsAbs(plan.TemporaryHome) || path.Clean(plan.TemporaryHome) != plan.TemporaryHome { return fmt.Errorf("application sandbox temporary home must be %s", environmentTemporaryHome) } + if err := deploy.ValidateApplicationStartupVerifierV1(plan.StartupVerifier, false); err != nil { + return fmt.Errorf("application sandbox startup verifier: %w", err) + } if !plan.Kernel.DropAllCapabilities { return fmt.Errorf("application sandbox must drop all Linux capabilities") } diff --git a/internal/dockerdeploy/application_sandbox_plan_test.go b/internal/dockerdeploy/application_sandbox_plan_test.go index f27f4b80..88a448d1 100644 --- a/internal/dockerdeploy/application_sandbox_plan_test.go +++ b/internal/dockerdeploy/application_sandbox_plan_test.go @@ -66,7 +66,7 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) { if !containsInOrder(transient.Args, []string{"--user", "501:20", "--cap-drop", "ALL"}) || !containsInOrder(transient.Args, []string{"--group-add", "33", "--group-add", "44"}) || !containsInOrder(transient.Args, []string{"--security-opt", "no-new-privileges=true", "--security-opt", "seccomp=builtin"}) || - !containsInOrder(transient.Args, []string{"--entrypoint", "/bin/true", plan.Image}) { + !containsInOrder(transient.Args, []string{"--entrypoint", plan.Sandbox.StartupVerifier.Path, plan.Image, "verify-exec", "--", "/bin/true"}) { t.Fatalf("transient sandbox runtime identity = %#v", transient.Args) } @@ -98,6 +98,11 @@ func TestApplicationSandboxPlanRejectsIdentityAndKernelEscapes(t *testing.T) { {name: "privileged", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.Privileged = true }, want: "privileged"}, {name: "host namespace", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.HostNamespaces = []string{"pid"} }, want: "host namespaces"}, {name: "host device", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.HostDevices = []string{"/dev/kvm"} }, want: "host devices"}, + {name: "verifier path", mutate: func(plan *ApplicationSandboxPlanV1) { plan.StartupVerifier.Path = "/bin/true" }, want: "startup verifier"}, + {name: "verifier recipe", mutate: func(plan *ApplicationSandboxPlanV1) { plan.StartupVerifier.RecipeVersion = "unchecked-exec-v1" }, want: "startup verifier"}, + {name: "verifier artifact", mutate: func(plan *ApplicationSandboxPlanV1) { + plan.StartupVerifier.Artifact = rendererDigest("1") + }, want: "must not contain an artifact"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/dockerdeploy/application_startup_verifier_integration_test.go b/internal/dockerdeploy/application_startup_verifier_integration_test.go new file mode 100644 index 00000000..9fd93f38 --- /dev/null +++ b/internal/dockerdeploy/application_startup_verifier_integration_test.go @@ -0,0 +1,274 @@ +package dockerdeploy + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/probearchive" + "github.com/omry/reploy/internal/providerstore" +) + +func TestApplicationStartupVerifierDockerIntegration(t *testing.T) { + if os.Getenv("REPLOY_DOCKER_INTEGRATION") != "1" { + t.Skip("set REPLOY_DOCKER_INTEGRATION=1 to run Docker integration evidence") + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + + image, platform := buildApplicationStartupVerifierIntegrationImage(t, ctx) + plan := DockerExecutionPlan{ + EnvironmentID: "verifier-integration", DeploymentDir: t.TempDir(), Phase: blueprint.PhaseStaged, + Image: image, ContainerName: uniqueDockerIntegrationName("reploy-verifier"), + NetworkName: uniqueDockerIntegrationName("reploy-verifier-network"), + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{ + UID: 12345, GID: 23456, DockerUser: "12345:23456", + }), + Workload: &WorkloadExecutionPlan{Argv: []string{ + "/bin/sh", "-eu", "-c", `test "$1" = 'literal $(not-shell)'; printf 'persistent-verifier-pass\n'`, "reploy-test", "literal $(not-shell)", + }}, + } + + t.Run("persistent", func(t *testing.T) { + rendered, err := RenderDockerInputs(plan, "verifier-integration") + if err != nil { + t.Fatal(err) + } + composePath := filepath.Join(t.TempDir(), "compose.yaml") + if err := os.WriteFile(composePath, rendered.Compose, 0o600); err != nil { + t.Fatal(err) + } + cleanup := exec.CommandContext(context.Background(), "docker", "compose", "--project-name", plan.NetworkName, "-f", composePath, "down", "--remove-orphans") + t.Cleanup(func() { _ = cleanup.Run() }) + output := runDockerIntegration( + t, ctx, "compose", "--project-name", plan.NetworkName, "-f", composePath, + "up", "--pull", "never", "--abort-on-container-exit", "--exit-code-from", "environment", + ) + if !strings.Contains(output, "persistent-verifier-pass") { + t.Fatalf("persistent workload output = %q", output) + } + }) + + t.Run("transient", func(t *testing.T) { + transientPlan := plan + transientPlan.ContainerName = uniqueDockerIntegrationName("reploy-verifier-transient") + command := ResolvedEnvironmentCommand{Argv: []string{ + "/bin/sh", "-eu", "-c", `test "$1" = 'literal $(not-shell)'; printf 'transient-verifier-pass\n'`, "reploy-test", "literal $(not-shell)", + }} + spec, err := TransientCommandSpec(transientPlan, command, nil, false, false) + if err != nil { + t.Fatal(err) + } + output := runDockerIntegration(t, ctx, spec.Args...) + if strings.TrimSpace(output) != "transient-verifier-pass" { + t.Fatalf("transient workload output = %q", output) + } + }) + + t.Run("preserves application exit code", func(t *testing.T) { + transientPlan := plan + transientPlan.ContainerName = uniqueDockerIntegrationName("reploy-verifier-exit") + command := ResolvedEnvironmentCommand{Argv: []string{ + "/bin/sh", "-c", "printf 'exit-preserved\\n'; exit 42", + }} + spec, err := TransientCommandSpec(transientPlan, command, nil, false, false) + if err != nil { + t.Fatal(err) + } + output, err := exec.CommandContext(ctx, "docker", spec.Args...).CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 42 { + t.Fatalf("transient exit error = %v, output = %q", err, output) + } + if strings.TrimSpace(string(output)) != "exit-preserved" { + t.Fatalf("transient exit output = %q", output) + } + }) + + // A container can inherit an outer seccomp filter even when Docker is asked + // for seccomp=unconfined, so this integration test cannot portably produce + // Seccomp: 0. The parser's fail-closed Seccomp cases are covered by unit + // tests; Docker evidence here proves the controls the daemon can omit. + for _, test := range []struct { + name string + dockerArgs []string + want string + }{ + {name: "missing no-new-privileges", dockerArgs: []string{"--cap-drop", "ALL"}, want: "NoNewPrivs is 0, want 1"}, + {name: "retained capability bounding set", dockerArgs: []string{"--security-opt", "no-new-privileges=true"}, want: "CapBnd is"}, + } { + t.Run("rejects "+test.name, func(t *testing.T) { + args := []string{"run", "--rm", "--pull", "never", "--user", "12345:23456", "--read-only"} + args = append(args, test.dockerArgs...) + args = append(args, + "--entrypoint", deploy.ApplicationStartupVerifierPathV1, image, + "verify-exec", "--", "/bin/sh", "-c", "printf 'untrusted-application-ran\\n'", + ) + command := exec.CommandContext(ctx, "docker", args...) + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("incomplete sandbox unexpectedly succeeded: %s", output) + } + if bytes.Contains(output, []byte("untrusted-application-ran")) || !bytes.Contains(output, []byte(test.want)) { + t.Fatalf("incomplete sandbox output = %q, want diagnostic %q and no application output", output, test.want) + } + }) + } + + if platform.Canonical == "" { + t.Fatal("integration helper returned an empty platform") + } +} + +func TestApplicationRuntimeLayerDoesNotFollowInheritedVerifierSymlink(t *testing.T) { + if os.Getenv("REPLOY_DOCKER_INTEGRATION") != "1" { + t.Skip("set REPLOY_DOCKER_INTEGRATION=1 to run Docker integration evidence") + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + + baseContext := t.TempDir() + baseDockerfile := `FROM debian:bookworm-slim +RUN mkdir -p /mnt/attacker && ln -s /mnt/attacker/reploy-probe /reploy-probe +USER 12345:23456 +` + if err := os.WriteFile(filepath.Join(baseContext, "Dockerfile"), []byte(baseDockerfile), 0o600); err != nil { + t.Fatal(err) + } + base := uniqueDockerIntegrationName("reploy-verifier-symlink-base") + runDockerIntegration(t, ctx, "build", "--tag", base, baseContext) + t.Cleanup(func() { + _ = exec.CommandContext(context.Background(), "docker", "image", "rm", base).Run() + }) + + image, _ := buildApplicationStartupVerifierIntegrationImageFromBase(t, ctx, base) + attackerDirectory := t.TempDir() + if err := os.Chmod(attackerDirectory, 0o755); err != nil { + t.Fatal(err) + } + attackerVerifier := []byte("#!/bin/sh\nprintf 'attacker-verifier-ran\\n'\n") + if err := os.WriteFile(filepath.Join(attackerDirectory, "reploy-probe"), attackerVerifier, 0o755); err != nil { + t.Fatal(err) + } + mount, err := dockerMountArgument( + "type=bind", "source="+attackerDirectory, "target=/mnt/attacker", "readonly", + ) + if err != nil { + t.Fatal(err) + } + output := runDockerIntegration( + t, ctx, + "run", "--rm", "--pull", "never", "--user", "12345:23456", "--read-only", + "--cap-drop", "ALL", "--security-opt", "no-new-privileges=true", + "--mount", mount, + "--entrypoint", deploy.ApplicationStartupVerifierPathV1, image, + "verify-exec", "--", "/bin/sh", "-c", "printf 'trusted-verifier-pass\\n'", + ) + if strings.TrimSpace(output) != "trusted-verifier-pass" { + t.Fatalf("verifier output = %q, want the trusted verifier to execute the workload", output) + } +} + +func buildApplicationStartupVerifierIntegrationImage(t *testing.T, ctx context.Context) (string, blueprint.Platform) { + t.Helper() + return buildApplicationStartupVerifierIntegrationImageFromBase(t, ctx, "debian:bookworm-slim") +} + +func buildApplicationStartupVerifierIntegrationImageFromBase( + t *testing.T, + ctx context.Context, + base string, +) (string, blueprint.Platform) { + t.Helper() + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("startup verifier Docker integration does not build a helper for %s", runtime.GOARCH) + } + platform, err := blueprint.ParsePlatform("linux/" + runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate integration test source") + } + repositoryRoot := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..")) + workspace := t.TempDir() + helperPath := filepath.Join(workspace, "reploy-probe") + build := exec.CommandContext(ctx, "go", "build", "-buildvcs=false", "-o", helperPath, "./cmd/reploy-probe") + build.Dir = repositoryRoot + build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+runtime.GOARCH, "GOCACHE="+filepath.Join(workspace, "go-cache")) + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build integration reploy-probe: %v\n%s", err, output) + } + carrierPath := filepath.Join(workspace, "reploy-carrier") + if err := os.WriteFile(carrierPath, []byte("reploy integration archive carrier\n"), 0o700); err != nil { + t.Fatal(err) + } + inputs := []probearchive.HelperInput{ + {Platform: "linux/amd64", Path: helperPath}, + {Platform: "linux/arm/v7", Path: helperPath}, + {Platform: "linux/arm64", Path: helperPath}, + } + if err := probearchive.Append(carrierPath, inputs); err != nil { + t.Fatal(err) + } + previousLocator := locateApplicationRuntimeExecutable + locateApplicationRuntimeExecutable = func() (string, error) { return carrierPath, nil } + t.Cleanup(func() { locateApplicationRuntimeExecutable = previousLocator }) + + if command := exec.CommandContext(ctx, "docker", "image", "inspect", base); command.Run() != nil { + runDockerIntegration(t, ctx, "pull", base) + } + baseID := canonical.Digest(strings.TrimSpace(runDockerIntegration(t, ctx, "image", "inspect", "--format", "{{.Id}}", base))) + if err := baseID.Validate(); err != nil { + t.Fatalf("base image ID: %v", err) + } + source, err := InspectBuiltImageCandidate(ctx, BuiltImageCandidate{ImageID: baseID}, platform) + if err != nil { + t.Fatal(err) + } + verifier, err := LoadApplicationStartupVerifierV1(platform) + if err != nil { + t.Fatal(err) + } + deploymentRoot := filepath.Join(workspace, "deployment") + if err := os.Mkdir(deploymentRoot, 0o700); err != nil { + t.Fatal(err) + } + store, err := providerstore.NewStore(deploymentRoot) + if err != nil { + t.Fatal(err) + } + built, err := BuildApplicationRuntimeLayerCandidate(store, ApplicationRuntimeLayerBuildRequest{ + Source: source, Verifier: verifier, Platform: platform, + }, RunOptions{Context: ctx}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := RemoveBuiltImageCandidate(context.Background(), built); err != nil { + t.Errorf("remove startup verifier integration image: %v", err) + } + }) + if _, err := InspectApplicationRuntimeLayerCandidate(ctx, built, ApplicationRuntimeLayerBuildRequest{ + Source: source, Verifier: verifier, Platform: platform, + }); err != nil { + t.Fatal(err) + } + return string(built.ImageID), platform +} + +func uniqueDockerIntegrationName(prefix string) string { + return fmt.Sprintf("%s-%d-%d", prefix, os.Getpid(), time.Now().UnixNano()) +} diff --git a/internal/dockerdeploy/build_lock_assembly.go b/internal/dockerdeploy/build_lock_assembly.go index 2f586006..cc02dc66 100644 --- a/internal/dockerdeploy/build_lock_assembly.go +++ b/internal/dockerdeploy/build_lock_assembly.go @@ -21,6 +21,7 @@ type BuildLockAssemblyInput struct { Base deploy.ImageDescriptor Graph providers.GraphExecutionResult RuntimePolicy deploy.RuntimePolicyV1 + RuntimeLayer deploy.ApplicationRuntimeLayerV1 ValidationRecord providerstore.StoreObjectRef FinalImage providers.RealizedImageV1 } @@ -147,7 +148,8 @@ func AssembleBuildLock( Nodes: graphNodes, Edges: append([]providers.ProviderEdgeV1{}, input.Graph.SelectedEdges...), }, Nodes: locks, Catalog: append([]providers.RealizedOutput{}, input.Graph.Catalog...), - RuntimePolicy: input.RuntimePolicy, ValidationRecord: input.ValidationRecord, FinalImage: input.FinalImage, + RuntimePolicy: input.RuntimePolicy, RuntimeLayer: input.RuntimeLayer, + ValidationRecord: input.ValidationRecord, FinalImage: input.FinalImage, } if err := deploy.ValidateBuildLockV1(lock, registry.ValidateRequirementProfileV1); err != nil { return deploy.BuildLockV1{}, fmt.Errorf("assemble build lock: %w", err) @@ -177,6 +179,9 @@ func validateGraphLockAssemblyShape(input BuildLockAssemblyInput) error { if err := deploy.ValidateRuntimePolicyV1(input.RuntimePolicy); err != nil { return err } + if err := deploy.ValidateApplicationRuntimeLayerV1(input.RuntimeLayer, input.ResolvedRequest.Platform); err != nil { + return err + } if err := input.ValidationRecord.Validate(); err != nil || input.ValidationRecord.Kind != providerstore.ValidationRecordKind { return fmt.Errorf("assemble build lock validation record is invalid") } @@ -184,8 +189,8 @@ func validateGraphLockAssemblyShape(input BuildLockAssemblyInput) error { return fmt.Errorf("assemble build lock final image: %w", err) } last := input.Graph.PrefixImages[len(input.Graph.PrefixImages)-1] - if input.FinalImage.RootFSSubject != last.RootFSSubject { - return fmt.Errorf("assemble build lock final image root filesystem does not match the graph result") + if input.RuntimeLayer.Upstream != last || input.FinalImage.RootFSSubject != input.RuntimeLayer.Result.RootFSSubject { + return fmt.Errorf("assemble build lock application runtime layer does not connect the graph result to the final image") } return nil } diff --git a/internal/dockerdeploy/build_lock_assembly_test.go b/internal/dockerdeploy/build_lock_assembly_test.go index 2adb51bc..cfcdf4e8 100644 --- a/internal/dockerdeploy/build_lock_assembly_test.go +++ b/internal/dockerdeploy/build_lock_assembly_test.go @@ -46,9 +46,9 @@ func TestAssembleBuildLockPublishesCompleteGraphLock(t *testing.T) { t.Fatal(err) } finalEvidence := lockedNode.ValidationEvidence - finalEvidence.SubjectRootFS = lockedNode.Result.RootFSSubject + finalEvidence.SubjectRootFS = fixture.lock.RuntimeLayer.Result.RootFSSubject validationReference, err := deploy.PublishPrefixValidation(context.Background(), fixture.store, deploy.PrefixValidationV1{ - Schema: deploy.PrefixValidationSchemaV1, SubjectRootFS: lockedNode.Result.RootFSSubject, + Schema: deploy.PrefixValidationSchemaV1, SubjectRootFS: fixture.lock.RuntimeLayer.Result.RootFSSubject, Profiles: []providers.ValidationEvidence{finalEvidence}, RuntimePolicy: policyDigest, ExposedOutputs: []providers.ExecutableEvidence{}, }) @@ -69,7 +69,8 @@ func TestAssembleBuildLockPublishesCompleteGraphLock(t *testing.T) { lock, err := AssembleBuildLock(context.Background(), fixture.store, BuildLockAssemblyInput{ BlueprintDigest: rendererDigest("b"), ResolvedRequest: request, Overlay: overlay, PackageOverrides: fixture.lock.PackageOverrides, Base: fixture.lock.Base, Graph: graph, - RuntimePolicy: fixture.lock.RuntimePolicy, ValidationRecord: validationReference, FinalImage: lockedNode.Result, + RuntimePolicy: fixture.lock.RuntimePolicy, RuntimeLayer: fixture.lock.RuntimeLayer, + ValidationRecord: validationReference, FinalImage: fixture.lock.FinalImage, }) if err != nil { t.Fatal(err) diff --git a/internal/dockerdeploy/build_publication_test.go b/internal/dockerdeploy/build_publication_test.go index 6d7faf34..7a930ba8 100644 --- a/internal/dockerdeploy/build_publication_test.go +++ b/internal/dockerdeploy/build_publication_test.go @@ -397,9 +397,16 @@ func publicationLockFixture(t *testing.T, dir string, imageChar string, configCh if err != nil { t.Fatal(err) } - image := providers.RealizedImageV1{Digest: rendererDigest(imageChar), ConfigDigest: rendererDigest(configChar), RootFSSubject: rootFSSubject} + upstream := providers.RealizedImageV1{Digest: base.ConfigDigest, ConfigDigest: base.ConfigDigest, RootFSSubject: rootFSSubject} + runtimeDiffIDs := append(append([]canonical.Digest{}, base.RootFSDiffIDs...), rendererDigest("e")) + runtimeRootFS, err := deploy.RootFSSubject(runtimeDiffIDs) + if err != nil { + t.Fatal(err) + } + runtimeImage := providers.RealizedImageV1{Digest: rendererDigest(rootChar), ConfigDigest: rendererDigest(rootChar), RootFSSubject: runtimeRootFS} + image := providers.RealizedImageV1{Digest: rendererDigest(imageChar), ConfigDigest: rendererDigest(configChar), RootFSSubject: runtimeRootFS} policy := deploy.RuntimePolicyV1{ - Schema: deploy.RuntimePolicySchemaV1, + Schema: deploy.RuntimePolicySchemaV1, StartupVerifier: deploy.ApplicationStartupVerifierContractV1(), ProtectedPaths: []deploy.ProtectedPathV1{}, Plans: []deploy.RuntimePlanV1{}, } policyDigest, err := deploy.RuntimePolicyDigestV1(policy) @@ -422,7 +429,8 @@ func publicationLockFixture(t *testing.T, dir string, imageChar string, configCh Base: base, Graph: deploy.ProviderGraphLockV1{Nodes: []providers.NodeID{"base"}, Edges: []providers.ProviderEdgeV1{}}, Nodes: []deploy.NodeLockV1{}, Catalog: []providers.RealizedOutput{}, - RuntimePolicy: policy, ValidationRecord: validation, FinalImage: image, + RuntimePolicy: policy, RuntimeLayer: testApplicationRuntimeLayerV1(t, platform, upstream, runtimeImage), + ValidationRecord: validation, FinalImage: image, } if err := deploy.ValidateBuildLockV1(lock, registry.ValidateRequirementProfileV1); err != nil { t.Fatal(err) diff --git a/internal/dockerdeploy/command_execution.go b/internal/dockerdeploy/command_execution.go index b5227121..239fd200 100644 --- a/internal/dockerdeploy/command_execution.go +++ b/internal/dockerdeploy/command_execution.go @@ -283,10 +283,10 @@ func transientContainerCommandSpecV1(operation string, container string, plan Do ) } args = append(args, - "--entrypoint", command.Argv[0], + "--entrypoint", plan.Sandbox.StartupVerifier.Path, plan.Image, ) - args = append(args, command.Argv[1:]...) + args = append(args, verifiedApplicationArgvV1(command.Argv)...) return CommandSpec{Name: "docker", Args: args}, nil } diff --git a/internal/dockerdeploy/command_execution_integration_test.go b/internal/dockerdeploy/command_execution_integration_test.go index eb0e15d7..e5f0964a 100644 --- a/internal/dockerdeploy/command_execution_integration_test.go +++ b/internal/dockerdeploy/command_execution_integration_test.go @@ -13,8 +13,9 @@ func TestTransientCommandDockerIntegrationEnforcesIdentityAndKernelBaseline(t *t t.Skip("set REPLOY_DOCKER_INTEGRATION=1 to run Docker integration evidence") } ctx := context.Background() + image, _ := buildApplicationStartupVerifierIntegrationImage(t, ctx) plan := DockerExecutionPlan{ - DeploymentDir: t.TempDir(), Image: "debian:bookworm-slim", ContainerName: "reploy-transient-home-integration", + DeploymentDir: t.TempDir(), Image: image, ContainerName: uniqueDockerIntegrationName("reploy-transient-home"), Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 12345, GID: 23456, SupplementaryGIDs: []int{34567, 45678}, DockerUser: "12345:23456"}), } command := ResolvedEnvironmentCommand{Argv: []string{ diff --git a/internal/dockerdeploy/command_execution_test.go b/internal/dockerdeploy/command_execution_test.go index ac2b19e3..6e1e44ca 100644 --- a/internal/dockerdeploy/command_execution_test.go +++ b/internal/dockerdeploy/command_execution_test.go @@ -85,7 +85,7 @@ func TestTransientAndShellCommandsUseDockerExecArgv(t *testing.T) { t.Fatal(err) } joined := strings.Join(spec.Args, "|") - if strings.Contains(joined, "sh|-c") || !reflect.DeepEqual(spec.Args[len(spec.Args)-5:], []string{"--entrypoint", "/opt/demo", plan.Image, ";rm", "$(touch pwned)"}) { + if strings.Contains(joined, "sh|-c") || !containsInOrder(spec.Args, []string{"--entrypoint", plan.Sandbox.StartupVerifier.Path, plan.Image, "verify-exec", "--", "/opt/demo", ";rm", "$(touch pwned)"}) { t.Fatalf("spec = %#v", spec) } if !containsInOrder(spec.Args, []string{"--mount", "type=bind,source=" + outputDir + ",target=" + runtimeOutputRoot, "--env", runtimeOutputFileVariable + "=" + runtimeOutputRoot + "/output"}) { @@ -96,11 +96,11 @@ func TestTransientAndShellCommandsUseDockerExecArgv(t *testing.T) { } if !containsInOrder(spec.Args, []string{"--user", "501:20", "--cap-drop", "ALL"}) || !containsInOrder(spec.Args, []string{"--group-add", "33", "--group-add", "44"}) || - !containsInOrder(spec.Args, []string{"--entrypoint", "/opt/demo", plan.Image, ";rm", "$(touch pwned)"}) { + !containsInOrder(spec.Args, []string{"--entrypoint", plan.Sandbox.StartupVerifier.Path, plan.Image, "verify-exec", "--", "/opt/demo", ";rm", "$(touch pwned)"}) { t.Fatalf("transient command does not start directly with its final identity and command: %#v", spec.Args) } shell := ShellCommandSpec(plan, true, true) - if !strings.Contains(strings.Join(shell.Args, " "), "--interactive --tty") || !containsInOrder(shell.Args, []string{"--entrypoint", "/bin/sh", plan.Image}) { + if !strings.Contains(strings.Join(shell.Args, " "), "--interactive --tty") || !containsInOrder(shell.Args, []string{"--entrypoint", plan.Sandbox.StartupVerifier.Path, plan.Image, "verify-exec", "--", "/bin/sh"}) { t.Fatalf("shell = %#v", shell) } if !containsInOrder(shell.Args, []string{"--read-only", "--tmpfs", transientHomeMountForPlan(plan)}) || @@ -204,7 +204,7 @@ func TestPlanTransientContainerExecutionV1SeparatesCreateStartAndCleanup(t *test t.Fatalf("create prefix = %#v", execution.Create.Args) } if !containsInOrder(execution.Create.Args, []string{"--interactive", "--tty"}) || - !reflect.DeepEqual(execution.Create.Args[len(execution.Create.Args)-4:], []string{"--entrypoint", "/opt/demo", plan.Image, "export"}) { + !containsInOrder(execution.Create.Args, []string{"--entrypoint", plan.Sandbox.StartupVerifier.Path, plan.Image, "verify-exec", "--", "/opt/demo", "export"}) { t.Fatalf("create args = %#v", execution.Create.Args) } if !reflect.DeepEqual(execution.Start.Args, []string{"start", "--attach", "--interactive", wantContainer}) { diff --git a/internal/dockerdeploy/current_build_reuse.go b/internal/dockerdeploy/current_build_reuse.go index 85f409d3..39362af7 100644 --- a/internal/dockerdeploy/current_build_reuse.go +++ b/internal/dockerdeploy/current_build_reuse.go @@ -17,6 +17,7 @@ type CurrentBuildReuseInput struct { Base deploy.ImageDescriptor Document blueprint.Document DockerPlan DockerExecutionPlan + StartupVerifier deploy.ApplicationStartupVerifierV1 } // CurrentBuildMatches returns false for a valid but changed build input. It @@ -65,6 +66,9 @@ func CurrentBuildMatches(current CurrentBuild, input CurrentBuildReuseInput) (bo if input.Base.Platform != input.ResolvedRequest.Platform { return false, fmt.Errorf("current build reuse base platform does not match the resolved request") } + if err := deploy.ValidateApplicationStartupVerifierV1(input.StartupVerifier, true); err != nil { + return false, fmt.Errorf("current build reuse startup verifier: %w", err) + } baseReference, err := resolvedRequestBaseReference(input.ResolvedRequest) if err != nil { return false, err @@ -97,7 +101,8 @@ func CurrentBuildMatches(current CurrentBuild, input CurrentBuildReuseInput) (bo !reflect.DeepEqual(current.Lock.PackageOverrides, input.PackageOverrides) || current.Lock.Platform != input.ResolvedRequest.Platform || !reflect.DeepEqual(current.Lock.Base, input.Base) || - lockedPolicyDigest != policyDigest { + lockedPolicyDigest != policyDigest || + !reflect.DeepEqual(current.Lock.RuntimeLayer.Verifier, input.StartupVerifier) { return false, nil } return true, nil diff --git a/internal/dockerdeploy/current_build_reuse_test.go b/internal/dockerdeploy/current_build_reuse_test.go index c6de1ef0..28e39d62 100644 --- a/internal/dockerdeploy/current_build_reuse_test.go +++ b/internal/dockerdeploy/current_build_reuse_test.go @@ -87,6 +87,9 @@ func TestCurrentBuildMatchesInvalidatesEverySemanticBoundary(t *testing.T) { Name: "alternate", Mode: blueprint.MountVolume, Target: "/mnt/alternate", }} }}, + {name: "startup verifier artifact", mutate: func(_ *CurrentBuild, input *CurrentBuildReuseInput) { + input.StartupVerifier.Artifact = rendererDigest("a") + }}, } { t.Run(test.name, func(t *testing.T) { current, input := currentBuildReuseFixture(t) @@ -179,6 +182,7 @@ func currentBuildReuseFixture(t *testing.T) (CurrentBuild, CurrentBuildReuseInpu input := CurrentBuildReuseInput{ ResolvedRequest: request, Overlay: overlay, PackageOverrides: lock.PackageOverrides, Base: lock.Base, Document: document, DockerPlan: dockerPlan, + StartupVerifier: lock.RuntimeLayer.Verifier, } lockDigest, err := deploy.BuildLockDigestV1(lock, registry.ValidateRequirementProfileV1) if err != nil { diff --git a/internal/dockerdeploy/current_build_verify.go b/internal/dockerdeploy/current_build_verify.go index ccf75c07..bd8aae41 100644 --- a/internal/dockerdeploy/current_build_verify.go +++ b/internal/dockerdeploy/current_build_verify.go @@ -296,12 +296,10 @@ func verifyLockedImagesV1( } source = layer inspectedImages++ - if nodeID == lastNonBaseNodeID(order) && !reflect.DeepEqual(record, storedValidation) { - return 0, fmt.Errorf("current provider validation evidence does not match the recorded final-prefix evidence") - } + _ = record } if len(lock.Nodes) == 0 { - record, err := ValidateImage(ctx, FullImageValidationInput{ + _, err := ValidateImage(ctx, FullImageValidationInput{ Image: base, Profiles: []providers.RequirementProfile{}, Outputs: append([]providers.RealizedOutput{}, baseOutputs...), @@ -310,11 +308,40 @@ func verifyLockedImagesV1( if err != nil { return 0, fmt.Errorf("verify current base image contents: %w", err) } - if !reflect.DeepEqual(record, storedValidation) { - return 0, fmt.Errorf("current base validation evidence does not match the recorded final-prefix evidence") - } } + runtimeImage, err := inspect( + ctx, + BuiltImageCandidate{ImageID: lock.RuntimeLayer.Result.ConfigDigest}, + lock.Platform, + ) + if err != nil { + return 0, currentBuildImageInspectionError( + "cached application runtime layer image", + lock.RuntimeLayer.Result.ConfigDigest, + err, + ) + } + if runtimeImage.Image != lock.RuntimeLayer.Result { + return 0, fmt.Errorf("cached application runtime layer image no longer matches its locked identity") + } + if err := ValidateInspectedApplicationRuntimeLayerCandidate(ApplicationRuntimeLayerBuildRequest{ + Source: source, Verifier: lock.RuntimeLayer.Verifier, Platform: lock.Platform, + }, runtimeImage); err != nil { + return 0, fmt.Errorf("verify cached application runtime layer: %w", err) + } + runtimeRecord, err := ValidateImage(ctx, FullImageValidationInput{ + Image: runtimeImage, Profiles: append([]providers.RequirementProfile{}, profiles...), + Outputs: append([]providers.RealizedOutput{}, outputs...), RuntimePolicy: lock.RuntimePolicy, + }, registry.ValidateRequirementProfileV1, run) + if err != nil { + return 0, fmt.Errorf("verify application runtime image contents: %w", err) + } + if !reflect.DeepEqual(runtimeRecord, storedValidation) { + return 0, fmt.Errorf("current application runtime validation evidence does not match the recorded final-prefix evidence") + } + inspectedImages++ + final, err := inspect( ctx, BuiltImageCandidate{ImageID: lock.FinalImage.ConfigDigest}, @@ -328,7 +355,7 @@ func verifyLockedImagesV1( ) } if err := validateInspectedFinalizedImageCandidate(final, FinalizationBuildRequest{ - Source: source, + Source: runtimeImage, Validation: storedValidation, ValidationReference: lock.ValidationRecord, Platform: lock.Platform, @@ -356,12 +383,3 @@ func currentBuildImageInspectionError( } return fmt.Errorf("verify %s: %w", subject, err) } - -func lastNonBaseNodeID(order []providers.NodeID) providers.NodeID { - for index := len(order) - 1; index >= 0; index-- { - if order[index] != "base" { - return order[index] - } - } - return "" -} diff --git a/internal/dockerdeploy/current_build_verify_test.go b/internal/dockerdeploy/current_build_verify_test.go index be1be286..0ef77215 100644 --- a/internal/dockerdeploy/current_build_verify_test.go +++ b/internal/dockerdeploy/current_build_verify_test.go @@ -17,12 +17,13 @@ import ( ) type currentBuildVerificationFixtureV1 struct { - store providerstore.Store - current CurrentBuild - runtime CurrentRuntimePlanV1 - base InspectedImageCandidate - final InspectedImageCandidate - recordPath string + store providerstore.Store + current CurrentBuild + runtime CurrentRuntimePlanV1 + base InspectedImageCandidate + runtimeImage InspectedImageCandidate + final InspectedImageCandidate + recordPath string } func TestVerifyLoadedCurrentBuildV1AuditsWithoutPublishing(t *testing.T) { @@ -39,7 +40,7 @@ func TestVerifyLoadedCurrentBuildV1AuditsWithoutPublishing(t *testing.T) { Store: fixture.store, Current: fixture.current, Runtime: fixture.runtime, RunValidation: func(_ context.Context, input FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { validationCalls++ - if input.Image.Image != fixture.base.Image || + if input.Image.Image != fixture.base.Image && input.Image.Image != fixture.runtimeImage.Image || len(input.Profiles) != 0 || len(input.Outputs) != 0 || !reflect.DeepEqual(input.RuntimePolicy, fixture.current.Lock.RuntimePolicy) { @@ -58,6 +59,8 @@ func TestVerifyLoadedCurrentBuildV1AuditsWithoutPublishing(t *testing.T) { switch candidate.ImageID { case fixture.base.Image.ConfigDigest: return fixture.base, nil + case fixture.runtimeImage.Image.ConfigDigest: + return fixture.runtimeImage, nil case fixture.final.Image.ConfigDigest: return fixture.final, nil default: @@ -75,12 +78,13 @@ func TestVerifyLoadedCurrentBuildV1AuditsWithoutPublishing(t *testing.T) { } wantInspected := []canonical.Digest{ fixture.base.Image.ConfigDigest, + fixture.runtimeImage.Image.ConfigDigest, fixture.final.Image.ConfigDigest, } if !reflect.DeepEqual(inspected, wantInspected) || - validationCalls != 1 || + validationCalls != 2 || result.StoreObjects != 1 || - result.Images != 2 || + result.Images != 3 || result.Commands != 0 { t.Fatalf( "inspected=%v validation=%d result=%#v", @@ -111,6 +115,9 @@ func TestVerifyLoadedCurrentBuildV1RejectsFinalLabelDrift(t *testing.T) { if candidate.ImageID == fixture.base.Image.ConfigDigest { return fixture.base, nil } + if candidate.ImageID == fixture.runtimeImage.Image.ConfigDigest { + return fixture.runtimeImage, nil + } return fixture.final, nil }, }, @@ -246,13 +253,23 @@ func TestVerifyLockedImagesV1RerunsCumulativeLayerValidation(t *testing.T) { t.Fatal(err) } lock.Nodes[0].Result = layerImage - finalDescriptor := layerDescriptor - finalDescriptor.AuthorReference = string(rendererDigest("c")) - finalDescriptor.ImmutableReference = string(rendererDigest("c")) - finalDescriptor.ConfigDigest = rendererDigest("c") + runtimeDescriptor := layerDescriptor + runtimeDescriptor.AuthorReference = string(rendererDigest("c")) + runtimeDescriptor.ImmutableReference = string(rendererDigest("c")) + runtimeDescriptor.ConfigDigest = rendererDigest("c") + runtimeDescriptor.RootFSDiffIDs = append(append([]canonical.Digest{}, layerDescriptor.RootFSDiffIDs...), rendererDigest("d")) + runtimeImage, err := realizedImageFromDescriptor(runtimeDescriptor) + if err != nil { + t.Fatal(err) + } + lock.RuntimeLayer = testApplicationRuntimeLayerV1(t, lock.Platform, layerImage, runtimeImage) + finalDescriptor := runtimeDescriptor + finalDescriptor.AuthorReference = string(rendererDigest("e")) + finalDescriptor.ImmutableReference = string(rendererDigest("e")) + finalDescriptor.ConfigDigest = rendererDigest("e") lock.FinalImage = providers.RealizedImageV1{ - Digest: rendererDigest("c"), ConfigDigest: rendererDigest("c"), - RootFSSubject: layerImage.RootFSSubject, + Digest: rendererDigest("e"), ConfigDigest: rendererDigest("e"), + RootFSSubject: runtimeImage.RootFSSubject, } profileDigest, err := providers.RequirementProfileDigest( lock.Nodes[0].RequirementProfile, @@ -265,13 +282,17 @@ func TestVerifyLockedImagesV1RerunsCumulativeLayerValidation(t *testing.T) { if err != nil { t.Fatal(err) } + runtimeEvidence, err := providers.NewValidationEvidence(runtimeImage.RootFSSubject, profileDigest) + if err != nil { + t.Fatal(err) + } policyDigest, err := deploy.RuntimePolicyDigestV1(lock.RuntimePolicy) if err != nil { t.Fatal(err) } record := deploy.PrefixValidationV1{ - Schema: deploy.PrefixValidationSchemaV1, SubjectRootFS: layerImage.RootFSSubject, - Profiles: []providers.ValidationEvidence{evidence}, RuntimePolicy: policyDigest, + Schema: deploy.PrefixValidationSchemaV1, SubjectRootFS: runtimeImage.RootFSSubject, + Profiles: []providers.ValidationEvidence{runtimeEvidence}, RuntimePolicy: policyDigest, ExposedOutputs: []providers.ExecutableEvidence{}, } referenceDigest, err := deploy.PrefixValidationDigest(record) @@ -285,6 +306,9 @@ func TestVerifyLockedImagesV1RerunsCumulativeLayerValidation(t *testing.T) { Descriptor: layerDescriptor, Config: config, Labels: map[string]string{}, Image: layerImage, } + runtimeLayer := InspectedImageCandidate{ + Descriptor: runtimeDescriptor, Config: config, Labels: map[string]string{}, Image: runtimeImage, + } finalLabels := map[string]string{} labels, err := deploy.PrefixValidationLabels( lock.FinalImage.RootFSSubject, @@ -308,11 +332,14 @@ func TestVerifyLockedImagesV1RerunsCumulativeLayerValidation(t *testing.T) { record, func(_ context.Context, input FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { validationCalls++ - if input.Image.Image != layerImage || + if input.Image.Image != layerImage && input.Image.Image != runtimeImage || len(input.Profiles) != 1 || len(input.Outputs) != 0 { t.Fatalf("layer validation input = %#v", input) } + if input.Image.Image == runtimeImage { + return []providers.ValidationEvidence{runtimeEvidence}, []providers.ExecutableEvidence{}, nil + } return []providers.ValidationEvidence{evidence}, []providers.ExecutableEvidence{}, nil }, func(_ context.Context, candidate BuiltImageCandidate, _ blueprint.Platform) (InspectedImageCandidate, error) { @@ -322,6 +349,8 @@ func TestVerifyLockedImagesV1RerunsCumulativeLayerValidation(t *testing.T) { return base, nil case layer.Image.ConfigDigest: return layer, nil + case runtimeLayer.Image.ConfigDigest: + return runtimeLayer, nil case final.Image.ConfigDigest: return final, nil default: @@ -335,10 +364,11 @@ func TestVerifyLockedImagesV1RerunsCumulativeLayerValidation(t *testing.T) { wantInspected := []canonical.Digest{ base.Image.ConfigDigest, layer.Image.ConfigDigest, + runtimeLayer.Image.ConfigDigest, final.Image.ConfigDigest, } - if images != 3 || - validationCalls != 1 || + if images != 4 || + validationCalls != 2 || !reflect.DeepEqual(inspected, wantInspected) { t.Fatalf( "images=%d validation=%d inspected=%v", @@ -447,7 +477,16 @@ func baseOnlyCurrentBuildVerificationFixtureV1(t *testing.T) currentBuildVerific Labels: map[string]string{"org.example.vendor": "inherited"}, Image: baseImage, } - finalDescriptor := lock.Base + runtimeDescriptor := lock.Base + runtimeDescriptor.RootFSDiffIDs = append(append([]canonical.Digest{}, lock.Base.RootFSDiffIDs...), rendererDigest("e")) + runtimeDescriptor.AuthorReference = string(lock.RuntimeLayer.Result.ConfigDigest) + runtimeDescriptor.ImmutableReference = string(lock.RuntimeLayer.Result.ConfigDigest) + runtimeDescriptor.ConfigDigest = lock.RuntimeLayer.Result.ConfigDigest + runtimeImage := InspectedImageCandidate{ + Descriptor: runtimeDescriptor, Config: config, + Labels: map[string]string{"org.example.vendor": "inherited"}, Image: lock.RuntimeLayer.Result, + } + finalDescriptor := runtimeDescriptor finalDescriptor.AuthorReference = string(lock.FinalImage.ConfigDigest) finalDescriptor.ImmutableReference = string(lock.FinalImage.ConfigDigest) finalDescriptor.ConfigDigest = lock.FinalImage.ConfigDigest @@ -475,9 +514,10 @@ func baseOnlyCurrentBuildVerificationFixtureV1(t *testing.T) currentBuildVerific current: CurrentBuild{ State: state, Generation: generation, Lock: lock, }, - runtime: runtime, - base: base, - final: final, - recordPath: recordPath, + runtime: runtime, + base: base, + runtimeImage: runtimeImage, + final: final, + recordPath: recordPath, } } diff --git a/internal/dockerdeploy/execution_render.go b/internal/dockerdeploy/execution_render.go index 73d7c7db..16c71277 100644 --- a/internal/dockerdeploy/execution_render.go +++ b/internal/dockerdeploy/execution_render.go @@ -110,7 +110,8 @@ func RenderDockerInputs(plan DockerExecutionPlan, controlScript string) (DockerR ReadOnly: plan.Sandbox.ReadOnlyRoot, Environment: temporaryEnvironmentForPlan(plan), Tmpfs: []string{temporaryHomeMountForPlan(plan)}, } if plan.Workload != nil { - service.Command = append([]string(nil), plan.Workload.Argv...) + service.Entrypoint = []string{plan.Sandbox.StartupVerifier.Path} + service.Command = verifiedApplicationArgvV1(plan.Workload.Argv) } if plan.PrivateEnvironment { if plan.Workload == nil { @@ -120,7 +121,9 @@ func RenderDockerInputs(plan DockerExecutionPlan, controlScript string) (DockerR return DockerRenderedInputs{}, fmt.Errorf("private workload environment injection does not support Docker restart policy %q", plan.Restart) } composeLauncher := strings.ReplaceAll(privateWorkloadEnvironmentLauncherV1, "$", "$$") - service.Entrypoint = []string{"/bin/sh", "-c", composeLauncher, "reploy-private-environment"} + launcher := []string{"/bin/sh", "-c", composeLauncher, "reploy-private-environment"} + launcher = append(launcher, plan.Workload.Argv...) + service.Command = verifiedApplicationArgvV1(launcher) service.StdinOpen = true } volumes := map[string]any{} @@ -210,6 +213,11 @@ func RenderDockerInputs(plan DockerExecutionPlan, controlScript string) (DockerR }, nil } +func verifiedApplicationArgvV1(argv []string) []string { + result := []string{"verify-exec", "--"} + return append(result, argv...) +} + func temporaryHomeForPlan(plan DockerExecutionPlan) string { return plan.Sandbox.TemporaryHome } diff --git a/internal/dockerdeploy/final_validation_pipeline.go b/internal/dockerdeploy/final_validation_pipeline.go index 4de5d65e..73b36c1c 100644 --- a/internal/dockerdeploy/final_validation_pipeline.go +++ b/internal/dockerdeploy/final_validation_pipeline.go @@ -6,19 +6,24 @@ import ( "fmt" "github.com/omry/reploy/internal/buildprofile" + "github.com/omry/reploy/internal/deploy" "github.com/omry/reploy/internal/providers" "github.com/omry/reploy/internal/providerstore" ) type FinalizedBuildValidationResult struct { - Validation BuildValidationResult - Image InspectedImageCandidate - Candidate BuiltImageCandidate + Validation BuildValidationResult + RuntimeLayer deploy.ApplicationRuntimeLayerV1 + Image InspectedImageCandidate + Candidate BuiltImageCandidate } type finalizationBuilder func(providerstore.Store, FinalizationBuildRequest, RunOptions) (BuiltImageCandidate, error) type finalizationInspector func(context.Context, BuiltImageCandidate, FinalizationBuildRequest) (InspectedImageCandidate, error) type finalizationCandidateRemover func(context.Context, BuiltImageCandidate) error +type applicationRuntimeLayerBuilder func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) +type applicationRuntimeLayerInspector func(context.Context, BuiltImageCandidate, ApplicationRuntimeLayerBuildRequest) (InspectedImageCandidate, error) +type applicationRuntimeLayerRetainer func(context.Context, BuiltImageCandidate, providers.RealizedImageV1) error func ValidateAndFinalizeBuild( ctx context.Context, @@ -27,10 +32,13 @@ func ValidateAndFinalizeBuild( final FullImageValidationInput, validateProfileOwner providers.RequirementProfileOwnerValidator, runValidation FullImageValidationRunner, + verifier deploy.ApplicationStartupVerifierV1, options RunOptions, ) (FinalizedBuildValidationResult, error) { return validateAndFinalizeBuild( - ctx, store, layers, final, validateProfileOwner, runValidation, options, + ctx, store, layers, final, validateProfileOwner, runValidation, verifier, options, + BuildApplicationRuntimeLayerCandidate, InspectApplicationRuntimeLayerCandidate, + RetainVerifiedApplicationRuntimeLayer, BuildFinalizedImageCandidate, InspectFinalizedImageCandidate, RemoveBuiltImageCandidate, ) } @@ -42,23 +50,61 @@ func validateAndFinalizeBuild( final FullImageValidationInput, validateProfileOwner providers.RequirementProfileOwnerValidator, runValidation FullImageValidationRunner, + verifier deploy.ApplicationStartupVerifierV1, options RunOptions, + buildRuntime applicationRuntimeLayerBuilder, + inspectRuntime applicationRuntimeLayerInspector, + retainRuntime applicationRuntimeLayerRetainer, build finalizationBuilder, inspect finalizationInspector, remove finalizationCandidateRemover, ) (FinalizedBuildValidationResult, error) { - if build == nil || inspect == nil || remove == nil { + if buildRuntime == nil || inspectRuntime == nil || retainRuntime == nil || build == nil || inspect == nil || remove == nil { return FinalizedBuildValidationResult{}, fmt.Errorf("final validation pipeline requires build and inspection backends") } + runtimeRequest := ApplicationRuntimeLayerBuildRequest{ + Source: final.Image, Verifier: verifier, Platform: final.Image.Descriptor.Platform, + } + runtimeBuildCtx, endRuntimeBuild := buildprofile.Start(ctx, "Package application startup verifier") + options.Context = runtimeBuildCtx + runtimeCandidate, err := buildRuntime(store, runtimeRequest, options) + endRuntimeBuild(err) + if err != nil { + return FinalizedBuildValidationResult{}, err + } + rejectRuntime := func(rejection error) (FinalizedBuildValidationResult, error) { + return FinalizedBuildValidationResult{}, errors.Join( + rejection, + remove(context.WithoutCancel(ctx), runtimeCandidate), + ) + } + runtimeInspectCtx, endRuntimeInspect := buildprofile.Start(ctx, "Inspect application runtime layer") + runtimeImage, err := inspectRuntime(runtimeInspectCtx, runtimeCandidate, runtimeRequest) + endRuntimeInspect(err) + if err != nil { + return rejectRuntime(err) + } + runtimeFinal := final + runtimeFinal.Image = runtimeImage validateCtx, endValidate := buildprofile.Start(ctx, "Validate final image") - validation, err := ValidateBuildImages(validateCtx, store, layers, final, validateProfileOwner, runValidation) + validation, err := ValidateBuildImages(validateCtx, store, layers, runtimeFinal, validateProfileOwner, runValidation) endValidate(err) + if err != nil { + return rejectRuntime(err) + } + retainCtx, endRetain := buildprofile.Start(ctx, "Retain application runtime layer") + err = retainRuntime(retainCtx, runtimeCandidate, runtimeImage.Image) + endRetain(err) + if err != nil { + return rejectRuntime(fmt.Errorf("retain application runtime layer: %w", err)) + } + transaction, err := deploy.ApplicationRuntimeLayerTransactionDigestV1(verifier, final.Image.Image, final.Image.Descriptor.Platform) if err != nil { return FinalizedBuildValidationResult{}, err } request := FinalizationBuildRequest{ - Source: final.Image, Validation: validation.Final.Record, - ValidationReference: validation.Final.Reference, Platform: final.Image.Descriptor.Platform, + Source: runtimeImage, Validation: validation.Final.Record, + ValidationReference: validation.Final.Reference, Platform: runtimeImage.Descriptor.Platform, } buildCtx, endBuild := buildprofile.Start(ctx, "Finalize validated image") options.Context = buildCtx @@ -78,6 +124,11 @@ func validateAndFinalizeBuild( ) } return FinalizedBuildValidationResult{ - Validation: validation, Image: image, Candidate: built, + Validation: validation, + RuntimeLayer: deploy.ApplicationRuntimeLayerV1{ + Schema: deploy.ApplicationRuntimeLayerSchemaV1, Verifier: verifier, + TransactionDigest: transaction, Upstream: final.Image.Image, Result: runtimeImage.Image, + }, + Image: image, Candidate: built, }, nil } diff --git a/internal/dockerdeploy/final_validation_pipeline_test.go b/internal/dockerdeploy/final_validation_pipeline_test.go index 2822e9f2..3a73dbfc 100644 --- a/internal/dockerdeploy/final_validation_pipeline_test.go +++ b/internal/dockerdeploy/final_validation_pipeline_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/deploy" "github.com/omry/reploy/internal/providers" "github.com/omry/reploy/internal/providerstore" ) @@ -16,6 +18,7 @@ func TestValidateAndFinalizeBuildUsesPublishedFinalEvidence(t *testing.T) { t.Fatal(err) } final := fullValidationInput(t, "d") + verifier, runtimeCandidate, runtimeImage := finalValidationRuntimeFixture(t, final) validated := false built := false result, err := validateAndFinalizeBuild( @@ -23,9 +26,16 @@ func TestValidateAndFinalizeBuildUsesPublishedFinalEvidence(t *testing.T) { func(context.Context, FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { validated = true return []providers.ValidationEvidence{}, []providers.ExecutableEvidence{}, nil - }, RunOptions{}, + }, verifier, RunOptions{}, + func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) { + return runtimeCandidate, nil + }, + func(context.Context, BuiltImageCandidate, ApplicationRuntimeLayerBuildRequest) (InspectedImageCandidate, error) { + return runtimeImage, nil + }, + func(context.Context, BuiltImageCandidate, providers.RealizedImageV1) error { return nil }, func(_ providerstore.Store, request FinalizationBuildRequest, _ RunOptions) (BuiltImageCandidate, error) { - if !validated || request.Source.Image != final.Image.Image || request.ValidationReference.Kind != providerstore.ValidationRecordKind { + if !validated || request.Source.Image != runtimeImage.Image || request.ValidationReference.Kind != providerstore.ValidationRecordKind { t.Fatalf("finalization request = %#v", request) } built = true @@ -35,7 +45,7 @@ func TestValidateAndFinalizeBuildUsesPublishedFinalEvidence(t *testing.T) { if !built || candidate.ImageID != rendererDigest("e") || request.ValidationReference.Kind != providerstore.ValidationRecordKind { t.Fatalf("candidate = %#v, request = %#v", candidate, request) } - image := final.Image + image := runtimeImage image.Image.Digest = candidate.ImageID image.Image.ConfigDigest = candidate.ImageID image.Descriptor.AuthorReference = string(candidate.ImageID) @@ -62,11 +72,21 @@ func TestValidateAndFinalizeBuildDoesNotBuildAfterValidationFailure(t *testing.T t.Fatal(err) } built := false + final := fullValidationInput(t, "f") + verifier, runtimeCandidate, runtimeImage := finalValidationRuntimeFixture(t, final) + removedRuntime := false _, err = validateAndFinalizeBuild( - context.Background(), store, []FullImageValidationInput{}, fullValidationInput(t, "f"), acceptFullValidationProfile, + context.Background(), store, []FullImageValidationInput{}, final, acceptFullValidationProfile, func(context.Context, FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { return nil, nil, errors.New("validation failed") - }, RunOptions{}, + }, verifier, RunOptions{}, + func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) { + return runtimeCandidate, nil + }, + func(context.Context, BuiltImageCandidate, ApplicationRuntimeLayerBuildRequest) (InspectedImageCandidate, error) { + return runtimeImage, nil + }, + func(context.Context, BuiltImageCandidate, providers.RealizedImageV1) error { return nil }, func(providerstore.Store, FinalizationBuildRequest, RunOptions) (BuiltImageCandidate, error) { built = true return BuiltImageCandidate{}, nil @@ -74,13 +94,102 @@ func TestValidateAndFinalizeBuildDoesNotBuildAfterValidationFailure(t *testing.T func(context.Context, BuiltImageCandidate, FinalizationBuildRequest) (InspectedImageCandidate, error) { return InspectedImageCandidate{}, nil }, - func(context.Context, BuiltImageCandidate) error { - t.Fatal("validation failure attempted candidate cleanup") + func(_ context.Context, got BuiltImageCandidate) error { + removedRuntime = got == runtimeCandidate + return nil + }, + ) + if err == nil || !strings.Contains(err.Error(), "validation failed") || built || !removedRuntime { + t.Fatalf("built = %v removed runtime=%v error = %v", built, removedRuntime, err) + } +} + +func TestValidateAndFinalizeBuildRemovesRuntimeCandidateAfterInspectionFailure(t *testing.T) { + store, err := providerstore.NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + final := fullValidationInput(t, "6") + verifier, runtimeCandidate, _ := finalValidationRuntimeFixture(t, final) + want := errors.New("runtime inspection failed") + validated := false + finalized := false + removed := false + _, err = validateAndFinalizeBuild( + t.Context(), store, []FullImageValidationInput{}, final, + acceptFullValidationProfile, + func(context.Context, FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { + validated = true + return nil, nil, nil + }, + verifier, RunOptions{}, + func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) { + return runtimeCandidate, nil + }, + func(context.Context, BuiltImageCandidate, ApplicationRuntimeLayerBuildRequest) (InspectedImageCandidate, error) { + return InspectedImageCandidate{}, want + }, + func(context.Context, BuiltImageCandidate, providers.RealizedImageV1) error { + t.Fatal("failed runtime inspection retained its candidate") + return nil + }, + func(providerstore.Store, FinalizationBuildRequest, RunOptions) (BuiltImageCandidate, error) { + finalized = true + return BuiltImageCandidate{}, nil + }, + func(context.Context, BuiltImageCandidate, FinalizationBuildRequest) (InspectedImageCandidate, error) { + return InspectedImageCandidate{}, nil + }, + func(cleanupContext context.Context, got BuiltImageCandidate) error { + removed = cleanupContext.Err() == nil && got == runtimeCandidate return nil }, ) - if err == nil || !strings.Contains(err.Error(), "validation failed") || built { - t.Fatalf("built = %v, error = %v", built, err) + if !errors.Is(err, want) || validated || finalized || !removed { + t.Fatalf("validated = %v finalized = %v removed = %v error = %v", validated, finalized, removed, err) + } +} + +func TestValidateAndFinalizeBuildRemovesRuntimeCandidateAfterRetentionFailure(t *testing.T) { + store, err := providerstore.NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + final := fullValidationInput(t, "5") + verifier, runtimeCandidate, runtimeImage := finalValidationRuntimeFixture(t, final) + want := errors.New("runtime retention failed") + finalized := false + removed := false + _, err = validateAndFinalizeBuild( + t.Context(), store, []FullImageValidationInput{}, final, + acceptFullValidationProfile, + func(context.Context, FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { + return []providers.ValidationEvidence{}, []providers.ExecutableEvidence{}, nil + }, + verifier, RunOptions{}, + func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) { + return runtimeCandidate, nil + }, + func(context.Context, BuiltImageCandidate, ApplicationRuntimeLayerBuildRequest) (InspectedImageCandidate, error) { + return runtimeImage, nil + }, + func(context.Context, BuiltImageCandidate, providers.RealizedImageV1) error { + return want + }, + func(providerstore.Store, FinalizationBuildRequest, RunOptions) (BuiltImageCandidate, error) { + finalized = true + return BuiltImageCandidate{}, nil + }, + func(context.Context, BuiltImageCandidate, FinalizationBuildRequest) (InspectedImageCandidate, error) { + return InspectedImageCandidate{}, nil + }, + func(cleanupContext context.Context, got BuiltImageCandidate) error { + removed = cleanupContext.Err() == nil && got == runtimeCandidate + return nil + }, + ) + if !errors.Is(err, want) || finalized || !removed { + t.Fatalf("finalized = %v removed = %v error = %v", finalized, removed, err) } } @@ -90,6 +199,7 @@ func TestValidateAndFinalizeBuildRemovesCandidateAfterInspectionFailure(t *testi t.Fatal(err) } final := fullValidationInput(t, "7") + verifier, runtimeCandidate, runtimeImage := finalValidationRuntimeFixture(t, final) candidate := BuiltImageCandidate{ ImageID: rendererDigest("8"), TemporaryReference: temporaryBuildReferencePrefix + "12345678:finalize-output", @@ -102,7 +212,14 @@ func TestValidateAndFinalizeBuildRemovesCandidateAfterInspectionFailure(t *testi func(context.Context, FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { return []providers.ValidationEvidence{}, []providers.ExecutableEvidence{}, nil }, - RunOptions{}, + verifier, RunOptions{}, + func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) { + return runtimeCandidate, nil + }, + func(context.Context, BuiltImageCandidate, ApplicationRuntimeLayerBuildRequest) (InspectedImageCandidate, error) { + return runtimeImage, nil + }, + func(context.Context, BuiltImageCandidate, providers.RealizedImageV1) error { return nil }, func(providerstore.Store, FinalizationBuildRequest, RunOptions) (BuiltImageCandidate, error) { return candidate, nil }, @@ -118,3 +235,25 @@ func TestValidateAndFinalizeBuildRemovesCandidateAfterInspectionFailure(t *testi t.Fatalf("error = %v; removed = %t", err, removed) } } + +func finalValidationRuntimeFixture( + t *testing.T, + final FullImageValidationInput, +) (deploy.ApplicationStartupVerifierV1, BuiltImageCandidate, InspectedImageCandidate) { + t.Helper() + verifier := deploy.ApplicationStartupVerifierContractV1() + verifier.Artifact = rendererDigest("a") + verifier.Size = "123" + candidate := BuiltImageCandidate{ImageID: rendererDigest("b")} + image := final.Image + image.Descriptor.RootFSDiffIDs = append(append([]canonical.Digest{}, image.Descriptor.RootFSDiffIDs...), rendererDigest("c")) + rootFS, err := deploy.RootFSSubject(image.Descriptor.RootFSDiffIDs) + if err != nil { + t.Fatal(err) + } + image.Descriptor.AuthorReference = string(candidate.ImageID) + image.Descriptor.ImmutableReference = string(candidate.ImageID) + image.Descriptor.ConfigDigest = candidate.ImageID + image.Image = providers.RealizedImageV1{Digest: candidate.ImageID, ConfigDigest: candidate.ImageID, RootFSSubject: rootFS} + return verifier, candidate, image +} diff --git a/internal/dockerdeploy/full_validation.go b/internal/dockerdeploy/full_validation.go index add5edcd..f2c68e4b 100644 --- a/internal/dockerdeploy/full_validation.go +++ b/internal/dockerdeploy/full_validation.go @@ -116,9 +116,6 @@ func ValidateBuildImages( run FullImageValidationRunner, ) (BuildValidationResult, error) { result := BuildValidationResult{Layers: []PublishedImageValidation{}} - if len(layers) != 0 && !reflect.DeepEqual(final, layers[len(layers)-1]) { - return BuildValidationResult{}, fmt.Errorf("final image validation does not match the last component layer") - } for index, layer := range layers { validated, err := ValidateAndPublishImage(ctx, store, layer, validateProfileOwner, run) if err != nil { @@ -126,7 +123,7 @@ func ValidateBuildImages( } result.Layers = append(result.Layers, validated) } - if len(layers) != 0 { + if len(layers) != 0 && reflect.DeepEqual(final, layers[len(layers)-1]) { result.Final = result.Layers[len(result.Layers)-1] return result, nil } diff --git a/internal/dockerdeploy/full_validation_test.go b/internal/dockerdeploy/full_validation_test.go index 0b5ce6f4..faf1f333 100644 --- a/internal/dockerdeploy/full_validation_test.go +++ b/internal/dockerdeploy/full_validation_test.go @@ -26,7 +26,8 @@ func fullValidationInput(t *testing.T, digestChar string) FullImageValidationInp return FullImageValidationInput{ Image: request.Source, Profiles: []providers.RequirementProfile{}, Outputs: []providers.RealizedOutput{}, RuntimePolicy: deploy.RuntimePolicyV1{ - Schema: deploy.RuntimePolicySchemaV1, ProtectedPaths: []deploy.ProtectedPathV1{}, Plans: []deploy.RuntimePlanV1{}, + Schema: deploy.RuntimePolicySchemaV1, StartupVerifier: deploy.ApplicationStartupVerifierContractV1(), + ProtectedPaths: []deploy.ProtectedPathV1{}, Plans: []deploy.RuntimePlanV1{}, }, } } @@ -73,7 +74,7 @@ func TestValidateBuildImagesStopsAtFailedLayerWithoutFinalValidation(t *testing. } } -func TestValidateBuildImagesRejectsFinalThatDoesNotMatchLastLayer(t *testing.T) { +func TestValidateBuildImagesValidatesDistinctApplicationRuntimeImage(t *testing.T) { store, err := providerstore.NewStore(t.TempDir()) if err != nil { t.Fatal(err) @@ -85,7 +86,7 @@ func TestValidateBuildImagesRejectsFinalThatDoesNotMatchLastLayer(t *testing.T) calls++ return []providers.ValidationEvidence{}, []providers.ExecutableEvidence{}, nil }) - if err == nil || !strings.Contains(err.Error(), "does not match the last component layer") || calls != 0 { + if err != nil || calls != 2 { t.Fatalf("calls = %d, error = %v", calls, err) } } diff --git a/internal/dockerdeploy/prepared_python_graph_reuse_test.go b/internal/dockerdeploy/prepared_python_graph_reuse_test.go index 48e970e3..24425241 100644 --- a/internal/dockerdeploy/prepared_python_graph_reuse_test.go +++ b/internal/dockerdeploy/prepared_python_graph_reuse_test.go @@ -462,12 +462,15 @@ func newPreparedPythonGraphReuseFixtureWithManifest(t *testing.T, sourceManifest }}, Catalog: append([]providers.RealizedOutput{}, request.EarlierCatalog...), RuntimePolicy: deploy.RuntimePolicyV1{ - Schema: deploy.RuntimePolicySchemaV1, + Schema: deploy.RuntimePolicySchemaV1, StartupVerifier: deploy.ApplicationStartupVerifierContractV1(), ProtectedPaths: []deploy.ProtectedPathV1{}, Plans: []deploy.RuntimePlanV1{}, }, + RuntimeLayer: testApplicationRuntimeLayerV1(t, request.Platform, resultImage, providers.RealizedImageV1{ + Digest: reuseTestDigest("b"), ConfigDigest: reuseTestDigest("b"), RootFSSubject: reuseTestDigest("c"), + }), ValidationRecord: providerstore.StoreObjectRef{Kind: providerstore.ValidationRecordKind, Digest: reuseTestDigest("6")}, - FinalImage: resultImage, } + lock.FinalImage = lock.RuntimeLayer.Result if err := deploy.ValidateBuildLockV1(lock, pythonprovider.ValidateRequirementProfileV1); err != nil { t.Fatal(err) } @@ -577,12 +580,15 @@ func newPreparedAPTGraphReuseFixture(t *testing.T) ( }}, Catalog: []providers.RealizedOutput{}, RuntimePolicy: deploy.RuntimePolicyV1{ - Schema: deploy.RuntimePolicySchemaV1, + Schema: deploy.RuntimePolicySchemaV1, StartupVerifier: deploy.ApplicationStartupVerifierContractV1(), ProtectedPaths: []deploy.ProtectedPathV1{}, Plans: []deploy.RuntimePlanV1{}, }, + RuntimeLayer: testApplicationRuntimeLayerV1(t, descriptor.Platform, resultImage, providers.RealizedImageV1{ + Digest: reuseTestDigest("b"), ConfigDigest: reuseTestDigest("b"), RootFSSubject: reuseTestDigest("c"), + }), ValidationRecord: providerstore.StoreObjectRef{Kind: providerstore.ValidationRecordKind, Digest: reuseTestDigest("6")}, - FinalImage: resultImage, } + lock.FinalImage = lock.RuntimeLayer.Result if err := deploy.ValidateBuildLockV1(lock, registry.ValidateRequirementProfileV1); err != nil { t.Fatal(err) } diff --git a/internal/dockerdeploy/private_workload_environment_integration_test.go b/internal/dockerdeploy/private_workload_environment_integration_test.go index 690ed4a2..d567808f 100644 --- a/internal/dockerdeploy/private_workload_environment_integration_test.go +++ b/internal/dockerdeploy/private_workload_environment_integration_test.go @@ -24,8 +24,7 @@ func TestPrivateWorkloadEnvironmentDockerIntegrationMasksFilesAndInjectsValues(t ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - const image = "debian:bookworm-slim" - runDockerIntegration(t, ctx, "pull", image) + image, _ := buildApplicationStartupVerifierIntegrationImage(t, ctx) hostRoot := dockerIntegrationSharedTempDir(t) if err := os.Chmod(hostRoot, 0o755); err != nil { @@ -155,8 +154,7 @@ func TestPrivateRuntimeMasksDockerIntegrationProtectTransientContainer(t *testin ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - const image = "debian:bookworm-slim" - runDockerIntegration(t, ctx, "pull", image) + image, _ := buildApplicationStartupVerifierIntegrationImage(t, ctx) deploymentDir := dockerIntegrationSharedTempDir(t) if err := os.Chmod(deploymentDir, 0o755); err != nil { t.Fatal(err) diff --git a/internal/dockerdeploy/private_workload_environment_test.go b/internal/dockerdeploy/private_workload_environment_test.go index 58342f2d..4678e304 100644 --- a/internal/dockerdeploy/private_workload_environment_test.go +++ b/internal/dockerdeploy/private_workload_environment_test.go @@ -225,6 +225,7 @@ func TestRenderDockerInputsUsesSecretFreePrivateLauncher(t *testing.T) { compose := string(rendered.Compose) for _, want := range []string{ "stdin_open: true", "reploy_private_environment_ready", "/opt/demo", "serve", + "entrypoint: [/reploy-probe]", "command: [verify-exec, --, /bin/sh, -c", "source: /dev/null", "target: /deployment/.env", "read_only: true", "/deployment/.reploy:" + privateRuntimeDirectoryMaskOptionsV1, } { diff --git a/internal/dockerdeploy/provider_build_completion.go b/internal/dockerdeploy/provider_build_completion.go index 98091fca..8c2f4b19 100644 --- a/internal/dockerdeploy/provider_build_completion.go +++ b/internal/dockerdeploy/provider_build_completion.go @@ -26,6 +26,7 @@ type ProviderBuildCompletionInput struct { BaseCatalog []providers.RealizedOutput Graph providers.GraphExecutionResult Validation ProviderGraphValidationPlan + StartupVerifier deploy.ApplicationStartupVerifierV1 RunValidation FullImageValidationRunner RunOptions RunOptions ValidateChoices bool @@ -48,6 +49,7 @@ type providerBuildCompletionBackend struct { FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, + deploy.ApplicationStartupVerifierV1, RunOptions, ) (FinalizedBuildValidationResult, error) assemble func(context.Context, providerstore.Store, BuildLockAssemblyInput) (deploy.BuildLockV1, error) @@ -116,7 +118,8 @@ func completeProviderBuild( finalizeOptions.Context = finalizeCtx finalized, err := backend.validateAndFinalize( finalizeCtx, store, input.Validation.Layers, input.Validation.Final, - registry.ValidateRequirementProfileV1, input.RunValidation, finalizeOptions, + registry.ValidateRequirementProfileV1, input.RunValidation, + input.StartupVerifier, finalizeOptions, ) endFinalize(err) if err != nil { @@ -153,6 +156,7 @@ func completeProviderBuild( Overlay: input.Overlay, PackageOverrides: input.PackageOverrides, Base: input.Base, Graph: input.Graph, RuntimePolicy: policy, + RuntimeLayer: finalized.RuntimeLayer, ValidationRecord: finalized.Validation.Final.Reference, FinalImage: finalized.Image.Image, }) endAssemble(err) @@ -200,6 +204,9 @@ func providerBuildRuntimePolicyV1(input ProviderBuildCompletionInput) (deploy.Ru } func validateProviderBuildCompletionInput(input ProviderBuildCompletionInput, policy deploy.RuntimePolicyV1) error { + if err := deploy.ValidateApplicationStartupVerifierV1(input.StartupVerifier, true); err != nil { + return fmt.Errorf("provider build startup verifier: %w", err) + } if err := providers.ValidateResolvedRequestV1(input.ResolvedRequest, registry.ValidateResolvedRequestOwnersV1); err != nil { return err } diff --git a/internal/dockerdeploy/provider_build_completion_test.go b/internal/dockerdeploy/provider_build_completion_test.go index 5375ad1d..09193290 100644 --- a/internal/dockerdeploy/provider_build_completion_test.go +++ b/internal/dockerdeploy/provider_build_completion_test.go @@ -27,6 +27,7 @@ func TestCompleteProviderBuildOrdersValidationAssemblyAndPublication(t *testing. input.NoCache = true validationReference := providerstore.StoreObjectRef{Kind: providerstore.ValidationRecordKind, Digest: rendererDigest("a")} finalImage := providers.RealizedImageV1{Digest: rendererDigest("b"), ConfigDigest: rendererDigest("b"), RootFSSubject: input.Validation.Final.Image.Image.RootFSSubject} + runtimeLayer := testApplicationRuntimeLayerV1(t, input.ResolvedRequest.Platform, input.Validation.Final.Image.Image, finalImage) finalCandidate := BuiltImageCandidate{ImageID: finalImage.ConfigDigest} wantLock := deploy.BuildLockV1{Schema: deploy.BuildLockSchemaV1} wantState := deploy.StateV1{ @@ -36,20 +37,21 @@ func TestCompleteProviderBuildOrdersValidationAssemblyAndPublication(t *testing. order := []string{} blueprintDigest := testResolvedBlueprintDigestV1(t, input.Document) backend := providerBuildCompletionBackend{ - validateAndFinalize: func(_ context.Context, gotStore providerstore.Store, layers []FullImageValidationInput, final FullImageValidationInput, validateOwner providers.RequirementProfileOwnerValidator, run FullImageValidationRunner, options RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(_ context.Context, gotStore providerstore.Store, layers []FullImageValidationInput, final FullImageValidationInput, validateOwner providers.RequirementProfileOwnerValidator, run FullImageValidationRunner, verifier deploy.ApplicationStartupVerifierV1, options RunOptions) (FinalizedBuildValidationResult, error) { order = append(order, "validate") - if gotStore.Root() != store.Root() || !reflect.DeepEqual(layers, input.Validation.Layers) || !reflect.DeepEqual(final, input.Validation.Final) || validateOwner == nil || run == nil || options.Context == nil { + if gotStore.Root() != store.Root() || !reflect.DeepEqual(layers, input.Validation.Layers) || !reflect.DeepEqual(final, input.Validation.Final) || validateOwner == nil || run == nil || verifier != input.StartupVerifier || options.Context == nil { t.Fatalf("validation arguments were not preserved") } return FinalizedBuildValidationResult{ - Validation: BuildValidationResult{Layers: []PublishedImageValidation{}, Final: PublishedImageValidation{Reference: validationReference}}, - Image: InspectedImageCandidate{Image: finalImage}, - Candidate: finalCandidate, + Validation: BuildValidationResult{Layers: []PublishedImageValidation{}, Final: PublishedImageValidation{Reference: validationReference}}, + RuntimeLayer: runtimeLayer, + Image: InspectedImageCandidate{Image: finalImage}, + Candidate: finalCandidate, }, nil }, assemble: func(_ context.Context, gotStore providerstore.Store, got BuildLockAssemblyInput) (deploy.BuildLockV1, error) { order = append(order, "assemble") - if gotStore.Root() != store.Root() || got.BlueprintDigest != blueprintDigest || got.ValidationRecord != validationReference || got.FinalImage != finalImage || !reflect.DeepEqual(got.Graph, input.Graph) || !reflect.DeepEqual(got.RuntimePolicy, input.Validation.Final.RuntimePolicy) { + if gotStore.Root() != store.Root() || got.BlueprintDigest != blueprintDigest || got.ValidationRecord != validationReference || got.FinalImage != finalImage || got.RuntimeLayer != runtimeLayer || !reflect.DeepEqual(got.Graph, input.Graph) || !reflect.DeepEqual(got.RuntimePolicy, input.Validation.Final.RuntimePolicy) { t.Fatalf("assembly input = %#v", got) } return wantLock, nil @@ -96,7 +98,7 @@ func TestCompleteProviderBuildWarnsWhenPublishedFinalCandidateCleanupFails(t *te store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { return FinalizedBuildValidationResult{ Image: InspectedImageCandidate{Image: finalImage}, Candidate: BuiltImageCandidate{ImageID: finalImage.ConfigDigest}, @@ -161,7 +163,7 @@ func TestCompleteProviderBuildValidationPublishesCandidateWithoutChangingCurrent cleanupCause := errors.New("injected validation candidate cleanup failure") publishedCurrent := false result, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { return FinalizedBuildValidationResult{ Validation: BuildValidationResult{ Layers: []PublishedImageValidation{}, @@ -207,7 +209,7 @@ func TestCompleteProviderBuildDoesNotAssembleOrPublishAfterValidationFailure(t * assembled := false published := false _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { return FinalizedBuildValidationResult{}, want }, assemble: func(context.Context, providerstore.Store, BuildLockAssemblyInput) (deploy.BuildLockV1, error) { @@ -236,7 +238,7 @@ func TestCompleteProviderBuildDoesNotPublishAfterAssemblyFailure(t *testing.T) { } removed := false _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { return FinalizedBuildValidationResult{Candidate: candidate}, nil }, assemble: func(context.Context, providerstore.Store, BuildLockAssemblyInput) (deploy.BuildLockV1, error) { @@ -262,7 +264,7 @@ func TestCompleteProviderBuildRejectsValidationPlanDriftBeforeBackendWork(t *tes input.Validation.Layers[0].Outputs = nil calls := 0 _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { calls++ return FinalizedBuildValidationResult{}, nil }, @@ -289,7 +291,7 @@ func TestCompleteProviderBuildRejectsRuntimePlanDriftBeforeBackendWork(t *testin }} calls := 0 _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { calls++ return FinalizedBuildValidationResult{}, nil }, @@ -319,7 +321,7 @@ func TestCompleteProviderBuildRejectsDocumentRequestDriftBeforeBackendWork(t *te } calls := 0 _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { calls++ return FinalizedBuildValidationResult{}, nil }, @@ -489,6 +491,7 @@ func providerBuildCompletionFixture(t *testing.T) (ProviderBuildCompletionInput, PackageOverrides: fixture.lock.PackageOverrides, Base: fixture.lock.Base, BaseCatalog: append([]providers.RealizedOutput{}, fixture.request.EarlierCatalog...), Graph: graph, Validation: validation, + StartupVerifier: fixture.lock.RuntimeLayer.Verifier, RunValidation: func(context.Context, FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { return nil, nil, nil }, diff --git a/internal/dockerdeploy/provider_build_execute.go b/internal/dockerdeploy/provider_build_execute.go index 3b374611..37a01277 100644 --- a/internal/dockerdeploy/provider_build_execute.go +++ b/internal/dockerdeploy/provider_build_execute.go @@ -311,6 +311,7 @@ func executeLockedProviderBuildV1( PackageOverrides: relevantPackageOverrides, Base: preparedBase.Descriptor, BaseCatalog: preparedBase.Catalog, Graph: graph, Validation: validation, + StartupVerifier: preparation.StartupVerifier, ValidateChoices: input.ValidateChoices, ValidatedInputs: preparation.ValidatedInputs, NoCache: preparation.NoCache, RunValidation: input.RunValidation, RunOptions: completeOptions, diff --git a/internal/dockerdeploy/provider_build_prepare.go b/internal/dockerdeploy/provider_build_prepare.go index 57047686..8358adc6 100644 --- a/internal/dockerdeploy/provider_build_prepare.go +++ b/internal/dockerdeploy/provider_build_prepare.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" + "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/buildprofile" "github.com/omry/reploy/internal/deploy" "github.com/omry/reploy/internal/providers" @@ -35,6 +36,7 @@ type LockedProviderBuildPreparationV1 struct { SelectedBase SelectedProviderBase PreparedBase *PreparedProviderBase FinalImageConfig providers.ImageConfigPolicy + StartupVerifier deploy.ApplicationStartupVerifierV1 // Current is the verified previously published generation, including when // it is stale. ReusableLock is the only cache input for later provider work // and is nil under NoCache. PublicationLock is the lock bound to the desired @@ -63,6 +65,7 @@ type providerBuildPreparationBackend struct { providers.ResolvedBundleOwnerValidator, ) (bool, error) load func(*deploy.OperationLock, deploy.PackageOverrideIntentV1, string, []providers.ResolvedSourceInput) (LoadedBuildRequestV1, error) + loadVerifier func(blueprint.Platform) (deploy.ApplicationStartupVerifierV1, error) selectCachedBase func(context.Context, providers.ResolvedRequestV1) (SelectedProviderBase, bool, error) selectBase func(context.Context, providers.ResolvedRequestV1) (SelectedProviderBase, error) validateCurrent currentBuildLoader @@ -84,6 +87,7 @@ func PrepareLockedProviderBuildV1( return prepareLockedProviderBuildV1(ctx, input, providerBuildPreparationBackend{ recover: RecoverPendingPublication, load: LoadBuildRequestWithPackageOverridesV1, + loadVerifier: LoadApplicationStartupVerifierV1, selectCachedBase: SelectCachedProviderBase, selectBase: SelectProviderBase, validateCurrent: ValidateCurrentBuild, @@ -111,7 +115,7 @@ func prepareLockedProviderBuildV1( if input.Sources == nil { return LockedProviderBuildPreparationV1{}, fmt.Errorf("prepare locked provider build sources must use an array") } - if backend.recover == nil || backend.load == nil || backend.selectCachedBase == nil || backend.selectBase == nil || backend.validateCurrent == nil || backend.lockedSources == nil || backend.matches == nil || backend.cacheAvailable == nil || backend.realizeBase == nil { + if backend.recover == nil || backend.load == nil || backend.loadVerifier == nil || backend.selectCachedBase == nil || backend.selectBase == nil || backend.validateCurrent == nil || backend.lockedSources == nil || backend.matches == nil || backend.cacheAvailable == nil || backend.realizeBase == nil { return LockedProviderBuildPreparationV1{}, fmt.Errorf("prepare locked provider build requires a complete backend") } @@ -148,12 +152,19 @@ func prepareLockedProviderBuildV1( if _, err := RuntimePlansV1(loaded.Document, input.DockerPlan); err != nil { return LockedProviderBuildPreparationV1{}, fmt.Errorf("prepare locked provider build runtime plan: %w", err) } + startupVerifier, err := backend.loadVerifier(loaded.Request.Platform) + if err != nil { + return LockedProviderBuildPreparationV1{}, fmt.Errorf("load application startup verifier: %w", err) + } + if err := deploy.ValidateApplicationStartupVerifierV1(startupVerifier, true); err != nil { + return LockedProviderBuildPreparationV1{}, err + } result := LockedProviderBuildPreparationV1{ Operation: input.Operation, Store: input.Store, Environment: input.Environment, DeploymentDir: input.DeploymentDir, DockerPlan: input.DockerPlan, Loaded: loaded, Recovered: recovered, ValidatedCandidate: input.ValidatedCandidate, ValidatedInputs: input.ValidatedInputs, - NoCache: input.NoCache, + NoCache: input.NoCache, StartupVerifier: startupVerifier, } type reuseCandidate struct { @@ -205,7 +216,7 @@ func prepareLockedProviderBuildV1( matches, err := backend.matches(candidate.current, CurrentBuildReuseInput{ ResolvedRequest: candidate.request, Overlay: loaded.State.Overlay, PackageOverrides: candidate.packageOverrides, Base: selected.Descriptor, - Document: loaded.Document, DockerPlan: input.DockerPlan, + Document: loaded.Document, DockerPlan: input.DockerPlan, StartupVerifier: startupVerifier, }) if err != nil || !matches { return false, err diff --git a/internal/dockerdeploy/provider_build_prepare_test.go b/internal/dockerdeploy/provider_build_prepare_test.go index 8e79292a..88803fad 100644 --- a/internal/dockerdeploy/provider_build_prepare_test.go +++ b/internal/dockerdeploy/provider_build_prepare_test.go @@ -364,6 +364,15 @@ func providerBuildPreparationTestBackend( ) providerBuildPreparationBackend { t.Helper() return providerBuildPreparationBackend{ + loadVerifier: func(platform blueprint.Platform) (deploy.ApplicationStartupVerifierV1, error) { + if platform != loaded.Request.Platform { + t.Fatal("startup verifier loaded for different platform") + } + verifier := deploy.ApplicationStartupVerifierContractV1() + verifier.Artifact = rendererDigest("f") + verifier.Size = "123" + return verifier, nil + }, recover: func(_ context.Context, _ *deploy.OperationLock, _ providerstore.Store, generation *deploy.EnvironmentGenerationState, _ string, _ string, validateProfile providers.RequirementProfileOwnerValidator, validateBundle providers.ResolvedBundleOwnerValidator) (bool, error) { *order = append(*order, "recover") if generation == nil || validateProfile == nil || validateBundle == nil { diff --git a/internal/dockerdeploy/provider_graph_validation_test.go b/internal/dockerdeploy/provider_graph_validation_test.go index 3d375a00..df8a2344 100644 --- a/internal/dockerdeploy/provider_graph_validation_test.go +++ b/internal/dockerdeploy/provider_graph_validation_test.go @@ -79,7 +79,7 @@ func TestPrepareProviderGraphValidationInspectsBaseOnlyGraph(t *testing.T) { t.Fatal(err) } policy := deploy.RuntimePolicyV1{ - Schema: deploy.RuntimePolicySchemaV1, + Schema: deploy.RuntimePolicySchemaV1, StartupVerifier: deploy.ApplicationStartupVerifierContractV1(), ProtectedPaths: []deploy.ProtectedPathV1{}, Plans: []deploy.RuntimePlanV1{}, } result, err := prepareProviderGraphValidation( diff --git a/internal/dockerdeploy/runtime_host_preflight_test.go b/internal/dockerdeploy/runtime_host_preflight_test.go index 33ac28d3..2bfe67ca 100644 --- a/internal/dockerdeploy/runtime_host_preflight_test.go +++ b/internal/dockerdeploy/runtime_host_preflight_test.go @@ -91,7 +91,7 @@ func TestRuntimeHostSourcesV1IncludesOnlyBindAndExplicitOutputMounts(t *testing. func runtimeHostPolicy(mounts []deploy.RuntimeMountV1) deploy.RuntimePolicyV1 { return deploy.RuntimePolicyV1{ - Schema: deploy.RuntimePolicySchemaV1, + Schema: deploy.RuntimePolicySchemaV1, StartupVerifier: deploy.ApplicationStartupVerifierContractV1(), ProtectedPaths: []deploy.ProtectedPathV1{}, Plans: []deploy.RuntimePlanV1{{ ID: "command/check", Mounts: mounts, Executables: []providers.QualifiedOutput{}, }}, diff --git a/internal/dockerdeploy/runtime_layer_test_helpers_test.go b/internal/dockerdeploy/runtime_layer_test_helpers_test.go new file mode 100644 index 00000000..8336d95e --- /dev/null +++ b/internal/dockerdeploy/runtime_layer_test_helpers_test.go @@ -0,0 +1,29 @@ +package dockerdeploy + +import ( + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/providers" +) + +func testApplicationRuntimeLayerV1( + t *testing.T, + platform blueprint.Platform, + upstream providers.RealizedImageV1, + result providers.RealizedImageV1, +) deploy.ApplicationRuntimeLayerV1 { + t.Helper() + verifier := deploy.ApplicationStartupVerifierContractV1() + verifier.Artifact = rendererDigest("f") + verifier.Size = "123" + transaction, err := deploy.ApplicationRuntimeLayerTransactionDigestV1(verifier, upstream, platform) + if err != nil { + t.Fatal(err) + } + return deploy.ApplicationRuntimeLayerV1{ + Schema: deploy.ApplicationRuntimeLayerSchemaV1, Verifier: verifier, + TransactionDigest: transaction, Upstream: upstream, Result: result, + } +} diff --git a/internal/dockerdeploy/runtime_policy_compile.go b/internal/dockerdeploy/runtime_policy_compile.go index 36e5e3d0..2cc10bcd 100644 --- a/internal/dockerdeploy/runtime_policy_compile.go +++ b/internal/dockerdeploy/runtime_policy_compile.go @@ -78,7 +78,8 @@ func compileRuntimePolicyV1( } sort.Slice(canonicalPlans, func(left int, right int) bool { return canonicalPlans[left].ID < canonicalPlans[right].ID }) policy := deploy.RuntimePolicyV1{ - Schema: deploy.RuntimePolicySchemaV1, ProtectedPaths: protected, Plans: canonicalPlans, + Schema: deploy.RuntimePolicySchemaV1, StartupVerifier: deploy.ApplicationStartupVerifierContractV1(), + ProtectedPaths: protected, Plans: canonicalPlans, } if err := deploy.ValidateRuntimePolicyV1(policy); err != nil { return deploy.RuntimePolicyV1{}, err @@ -124,6 +125,9 @@ func runtimeProtectedPaths( referenced map[providers.QualifiedOutput]bool, ) ([]deploy.ProtectedPathV1, error) { paths := map[string]deploy.ProtectedPathV1{ + deploy.ApplicationStartupVerifierPathV1: { + Path: deploy.ApplicationStartupVerifierPathV1, Kind: deploy.ProtectedPathExecutablePath, Owner: "reploy", + }, deploy.ReployImageRoot: {Path: deploy.ReployImageRoot, Kind: deploy.ProtectedPathReployRoot, Owner: "reploy"}, deploy.ReployProviderRoot: {Path: deploy.ReployProviderRoot, Kind: deploy.ProtectedPathProviderRoot, Owner: "reploy"}, } @@ -147,6 +151,9 @@ func runtimeProtectedPaths( for _, generated := range materialized.GeneratedExecutables { declaration := generated.Declaration owner := string(materialized.NodeID) + "." + declaration.ID + if err := rejectStartupVerifierExecutableCollision(owner, generated.Evidence.InvocationPath, generated.Evidence.LinkChain, generated.Evidence.Terminal.Path); err != nil { + return nil, err + } add(deploy.ProtectedPathV1{Path: declaration.ExclusiveRoot, Kind: deploy.ProtectedPathProviderLeaf, Owner: owner}) } } @@ -155,10 +162,13 @@ func runtimeProtectedPaths( return nil, err } qualified := providers.QualifiedOutput{Component: output.SupplierComponent, Name: output.Name} + owner := qualified.Component + "." + qualified.Name + if err := rejectStartupVerifierExecutableCollision(owner, output.Evidence.InvocationPath, output.Evidence.LinkChain, output.Evidence.Terminal.Path); err != nil { + return nil, err + } if !referenced[qualified] { continue } - owner := qualified.Component + "." + qualified.Name addExecutable(owner, output.Evidence.InvocationPath, output.Evidence.LinkChain, output.Evidence.Terminal.Path) } result := make([]deploy.ProtectedPathV1, 0, len(paths)) @@ -169,6 +179,24 @@ func runtimeProtectedPaths( return result, nil } +func rejectStartupVerifierExecutableCollision(owner string, invocation string, links []providers.LinkEvidence, terminal string) error { + paths := make([]string, 0, len(links)+2) + paths = append(paths, invocation) + for _, link := range links { + paths = append(paths, link.Path) + } + paths = append(paths, terminal) + for _, path := range paths { + if runtimePolicyPathsOverlap(path, deploy.ApplicationStartupVerifierPathV1) { + return fmt.Errorf( + "runtime executable %q path %q overlaps reserved startup verifier %q", + owner, path, deploy.ApplicationStartupVerifierPathV1, + ) + } + } + return nil +} + func validateRuntimePolicyExecutables(policy deploy.RuntimePolicyV1, catalog []providers.RealizedOutput) error { available := make(map[providers.QualifiedOutput]bool, len(catalog)) for _, output := range catalog { diff --git a/internal/dockerdeploy/runtime_policy_compile_test.go b/internal/dockerdeploy/runtime_policy_compile_test.go index 9996138b..2043692b 100644 --- a/internal/dockerdeploy/runtime_policy_compile_test.go +++ b/internal/dockerdeploy/runtime_policy_compile_test.go @@ -24,7 +24,10 @@ func TestCompileRuntimePolicyCanonicalizesPlans(t *testing.T) { if err != nil { t.Fatal(err) } - if len(policy.ProtectedPaths) != 2 || policy.ProtectedPaths[0].Path != deploy.ReployImageRoot || policy.ProtectedPaths[1].Path != deploy.ReployProviderRoot { + if len(policy.ProtectedPaths) != 3 || + policy.ProtectedPaths[0].Path != deploy.ReployImageRoot || + policy.ProtectedPaths[1].Path != deploy.ReployProviderRoot || + policy.ProtectedPaths[2].Path != deploy.ApplicationStartupVerifierPathV1 { t.Fatalf("protected paths = %#v", policy.ProtectedPaths) } if len(policy.Plans) != 2 || policy.Plans[0].ID != "shell" || policy.Plans[1].Mounts[0].Destination != "/data" { @@ -46,6 +49,7 @@ func TestCompileRuntimePolicyAllowsAbsoluteTargetsAndRejectsOverlap(t *testing.T }{ {name: "filesystem root", mounts: []deploy.RuntimeMountV1{{Destination: "/", SourceKind: deploy.RuntimeMountSourceDirectory}}, want: "filesystem root"}, {name: "kernel subtree", mounts: []deploy.RuntimeMountV1{{Destination: "/sys/fs", SourceKind: deploy.RuntimeMountSourceDirectory}}, want: "reserved container path"}, + {name: "startup verifier", mounts: []deploy.RuntimeMountV1{{Destination: deploy.ApplicationStartupVerifierPathV1, SourceKind: deploy.RuntimeMountSourceFile}}, want: "protected"}, {name: "overlap", mounts: []deploy.RuntimeMountV1{ {Destination: "/mnt/data", SourceKind: deploy.RuntimeMountSourceDirectory}, {Destination: "/mnt/data/cache", SourceKind: deploy.RuntimeMountSourceDirectory}, @@ -113,6 +117,52 @@ func TestCompileRuntimePolicyRejectsExecutableAbsentFromFinalGraph(t *testing.T) } } +func TestCompileRuntimePolicyRejectsStartupVerifierExecutableCollisions(t *testing.T) { + fixture := newPreparedPythonGraphReuseFixture(t) + output := fixture.request.EarlierCatalog[0] + observation := pythonConsumerObservation(output.Name, deploy.ApplicationStartupVerifierPathV1) + observation.Access = append(observation.Access[:1:1], observation.Access[len(observation.Access)-1]) + evidence, err := ExecutableEvidenceFromProbe(observation, ProbeExecutableBinding{ + Output: providers.QualifiedOutput{Component: output.SupplierComponent, Name: output.Name}, + Facts: output.Candidate.Provenance, + }) + if err != nil { + t.Fatal(err) + } + output.Candidate.InvocationPath = deploy.ApplicationStartupVerifierPathV1 + output.Evidence = evidence + _, err = CompileRuntimePolicyV1(runtimePolicyDocument(t), providers.GraphExecutionResult{ + Bundles: []providers.ResolvedBundle{}, Materializations: []providers.GraphNodeMaterializeResult{}, + Catalog: []providers.RealizedOutput{output}, + }, []deploy.RuntimePlanV1{{ID: "shell", Mounts: []deploy.RuntimeMountV1{}, Executables: []providers.QualifiedOutput{}}}) + if err == nil || !strings.Contains(err.Error(), "overlaps reserved startup verifier") { + t.Fatalf("catalog collision error = %v", err) + } + + transaction := rendererTransaction() + generated := acceptedGeneratedExecutable(transaction) + generated.Evidence.LinkChain = []providers.LinkEvidence{{ + Path: generated.Declaration.Path, Target: deploy.ApplicationStartupVerifierPathV1, + ResolvedPath: deploy.ApplicationStartupVerifierPathV1, Kind: "ordinary", + }} + generated.Evidence.Terminal.Path = deploy.ApplicationStartupVerifierPathV1 + generated.Evidence.Access.Paths[0].Path = deploy.ApplicationStartupVerifierPathV1 + platform, err := blueprint.ParsePlatform("linux/amd64") + if err != nil { + t.Fatal(err) + } + _, err = CompileRuntimePolicyV1(runtimePolicyDocument(t), providers.GraphExecutionResult{ + Bundles: []providers.ResolvedBundle{acceptanceBundle(transaction, platform)}, + Materializations: []providers.GraphNodeMaterializeResult{{ + GeneratedExecutables: []providers.RealizedGeneratedExecutable{generated}, Outputs: []providers.RealizedOutput{}, + }}, + Catalog: []providers.RealizedOutput{}, + }, []deploy.RuntimePlanV1{{ID: "shell", Mounts: []deploy.RuntimeMountV1{}, Executables: []providers.QualifiedOutput{}}}) + if err == nil || !strings.Contains(err.Error(), "overlaps reserved startup verifier") { + t.Fatalf("generated collision error = %v", err) + } +} + func TestCompileRuntimePolicyFromLockMatchesGraphCompilation(t *testing.T) { fixture := newPreparedPythonGraphReuseFixture(t) bundle, err := providers.LoadResolvedBundleManifest(fixture.store, fixture.lock.Nodes[0].BundleManifest, pythonprovider.ValidateResolvedBundlePayloadV1) diff --git a/internal/dockerdeploy/testdata/resolved_compose.yaml b/internal/dockerdeploy/testdata/resolved_compose.yaml index 1a60d57c..e31af6bf 100644 --- a/internal/dockerdeploy/testdata/resolved_compose.yaml +++ b/internal/dockerdeploy/testdata/resolved_compose.yaml @@ -11,7 +11,8 @@ services: security_opt: - no-new-privileges:true - seccomp=builtin - command: [/opt/reploy/python/bin/demo, serve] + entrypoint: [/reploy-probe] + command: [verify-exec, --, /opt/reploy/python/bin/demo, serve] volumes: - type: bind source: /tmp/demo/conf diff --git a/internal/probe/main.go b/internal/probe/main.go index 38d81993..c598550b 100644 --- a/internal/probe/main.go +++ b/internal/probe/main.go @@ -11,11 +11,26 @@ import ( ) func Main(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { - return mainWithActions(args, stdin, stdout, stderr, waitForHoldSignal, copyFixedVolumeTree) + if len(args) == 2 && args[0] == "install-runtime-verifier" { + if err := installApplicationRuntimeVerifier(args[1]); err != nil { + _, _ = fmt.Fprintf(stderr, "reploy-probe: install application runtime verifier: %v\n", err) + return 1 + } + return 0 + } + return mainWithActions( + args, stdin, stdout, stderr, + waitForHoldSignal, copyFixedVolumeTree, + readApplicationKernelStatus, execApplication, + ) } func mainWithHold(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, hold func() error) int { - return mainWithActions(args, stdin, stdout, stderr, hold, copyFixedVolumeTree) + return mainWithActions( + args, stdin, stdout, stderr, + hold, copyFixedVolumeTree, + readApplicationKernelStatus, execApplication, + ) } func mainWithActions( @@ -25,7 +40,20 @@ func mainWithActions( stderr io.Writer, hold func() error, copyVolumeTree func() error, + readKernelStatus func() ([]byte, error), + execApplication func([]string) error, ) int { + if len(args) >= 1 && args[0] == "verify-exec" { + if len(args) < 3 || args[1] != "--" { + _, _ = fmt.Fprintln(stderr, "reploy-probe: verify-exec requires -- followed by an absolute application command") + return 2 + } + if err := verifyAndExecApplication(args[2:], readKernelStatus, execApplication); err != nil { + _, _ = fmt.Fprintf(stderr, "reploy-probe: application startup verification failed: %v\n", err) + return 1 + } + return 0 + } if len(args) == 1 && args[0] == "hold" { if err := hold(); err != nil { _, _ = fmt.Fprintf(stderr, "reploy-probe: hold validation container: %v\n", err) @@ -41,7 +69,7 @@ func mainWithActions( return 0 } if len(args) != 0 { - _, _ = fmt.Fprintln(stderr, "reploy-probe accepts no arguments for one canonical stdin request, fixed hold mode, or fixed copy-volume-tree mode") + _, _ = fmt.Fprintln(stderr, "reploy-probe accepts no arguments for one canonical stdin request, fixed hold mode, fixed copy-volume-tree mode, or fixed verify-exec mode") return 2 } content, err := io.ReadAll(stdin) diff --git a/internal/probe/protocol_test.go b/internal/probe/protocol_test.go index 82c71be5..4e1ff0ad 100644 --- a/internal/probe/protocol_test.go +++ b/internal/probe/protocol_test.go @@ -163,6 +163,8 @@ func TestMainCopyVolumeTreeIsFixedLifecycleOnly(t *testing.T) { []string{"copy-volume-tree"}, strings.NewReader("ignored"), &stdout, &stderr, func() error { t.Fatal("copy mode reached hold"); return nil }, func() error { copies++; return nil }, + func() ([]byte, error) { t.Fatal("copy mode read kernel status"); return nil, nil }, + func([]string) error { t.Fatal("copy mode executed application"); return nil }, ) if code != 0 || copies != 1 || stdout.Len() != 0 || stderr.Len() != 0 { t.Fatalf("copy code=%d copies=%d stdout=%q stderr=%q", code, copies, stdout.String(), stderr.String()) @@ -170,6 +172,7 @@ func TestMainCopyVolumeTreeIsFixedLifecycleOnly(t *testing.T) { code = mainWithActions( []string{"copy-volume-tree", "anything"}, strings.NewReader(""), &stdout, &stderr, func() error { return nil }, func() error { t.Fatal("invalid copy arguments reached copier"); return nil }, + func() ([]byte, error) { return nil, nil }, func([]string) error { return nil }, ) if code != 2 || !strings.Contains(stderr.String(), "fixed copy-volume-tree mode") { t.Fatalf("invalid copy code=%d stderr=%q", code, stderr.String()) diff --git a/internal/probe/runtime_installer.go b/internal/probe/runtime_installer.go new file mode 100644 index 00000000..58c3575b --- /dev/null +++ b/internal/probe/runtime_installer.go @@ -0,0 +1,79 @@ +package probe + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +func installApplicationRuntimeVerifier(destinationPath string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("installer requires container root") + } + if destinationPath == string(filepath.Separator) || filepath.Dir(destinationPath) != string(filepath.Separator) { + return fmt.Errorf("runtime verifier must be installed as a root-level file") + } + source, err := os.Executable() + if err != nil { + return fmt.Errorf("locate installer executable: %w", err) + } + return installRuntimeVerifier(source, destinationPath) +} + +func installRuntimeVerifier(sourcePath string, destinationPath string) (resultErr error) { + if !filepath.IsAbs(sourcePath) || !filepath.IsAbs(destinationPath) || filepath.Clean(destinationPath) != destinationPath { + return fmt.Errorf("runtime verifier installation requires normalized absolute paths") + } + source, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("open runtime verifier source: %w", err) + } + defer func() { + resultErr = errors.Join(resultErr, source.Close()) + }() + + if err := os.RemoveAll(destinationPath); err != nil { + return fmt.Errorf("remove inherited runtime verifier path: %w", err) + } + temporaryPath := destinationPath + ".tmp" + if err := os.RemoveAll(temporaryPath); err != nil { + return fmt.Errorf("remove inherited runtime verifier temporary path: %w", err) + } + committed := false + defer func() { + if !committed { + resultErr = errors.Join(resultErr, os.Remove(temporaryPath)) + } + }() + + temporary, err := os.OpenFile(temporaryPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o555) + if err != nil { + return fmt.Errorf("create runtime verifier: %w", err) + } + closed := false + defer func() { + if !closed { + resultErr = errors.Join(resultErr, temporary.Close()) + } + }() + if _, err := io.Copy(temporary, source); err != nil { + return fmt.Errorf("copy runtime verifier: %w", err) + } + if err := temporary.Chmod(0o555); err != nil { + return fmt.Errorf("protect runtime verifier: %w", err) + } + if err := temporary.Sync(); err != nil { + return fmt.Errorf("sync runtime verifier: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close runtime verifier: %w", err) + } + closed = true + if err := os.Rename(temporaryPath, destinationPath); err != nil { + return fmt.Errorf("commit runtime verifier: %w", err) + } + committed = true + return nil +} diff --git a/internal/probe/runtime_installer_test.go b/internal/probe/runtime_installer_test.go new file mode 100644 index 00000000..c9130027 --- /dev/null +++ b/internal/probe/runtime_installer_test.go @@ -0,0 +1,52 @@ +package probe + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestInstallRuntimeVerifierReplacesInheritedSymlink(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source") + content := []byte("trusted runtime verifier") + if err := os.WriteFile(source, content, 0o555); err != nil { + t.Fatal(err) + } + attackerDirectory := filepath.Join(root, "attacker") + if err := os.Mkdir(attackerDirectory, 0o700); err != nil { + t.Fatal(err) + } + attackerPath := filepath.Join(attackerDirectory, "reploy-probe") + if err := os.WriteFile(attackerPath, []byte("attacker"), 0o755); err != nil { + t.Fatal(err) + } + destination := filepath.Join(root, "reploy-probe") + if err := os.Symlink(attackerPath, destination); err != nil { + t.Fatal(err) + } + + if err := installRuntimeVerifier(source, destination); err != nil { + t.Fatal(err) + } + info, err := os.Lstat(destination) + if err != nil { + t.Fatal(err) + } + installed, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + attacker, err := os.ReadFile(attackerPath) + if err != nil { + t.Fatal(err) + } + expectedMode := os.FileMode(0o555) + if runtime.GOOS == "windows" { + expectedMode = 0o444 + } + if !info.Mode().IsRegular() || info.Mode().Perm() != expectedMode || string(installed) != string(content) || string(attacker) != "attacker" { + t.Fatalf("installed mode=%v content=%q attacker=%q", info.Mode(), installed, attacker) + } +} diff --git a/internal/probe/startup_exec_linux.go b/internal/probe/startup_exec_linux.go new file mode 100644 index 00000000..65c65840 --- /dev/null +++ b/internal/probe/startup_exec_linux.go @@ -0,0 +1,12 @@ +//go:build linux + +package probe + +import ( + "os" + "syscall" +) + +func execApplication(argv []string) error { + return syscall.Exec(argv[0], argv, os.Environ()) +} diff --git a/internal/probe/startup_exec_other.go b/internal/probe/startup_exec_other.go new file mode 100644 index 00000000..0dcb8394 --- /dev/null +++ b/internal/probe/startup_exec_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package probe + +import "fmt" + +func execApplication([]string) error { + return fmt.Errorf("application startup verification is supported only on Linux") +} diff --git a/internal/probe/startup_verifier.go b/internal/probe/startup_verifier.go new file mode 100644 index 00000000..2f3f069e --- /dev/null +++ b/internal/probe/startup_verifier.go @@ -0,0 +1,103 @@ +package probe + +import ( + "fmt" + "math/big" + "os" + "path" + "strconv" + "strings" +) + +const applicationKernelStatusPath = "/proc/self/status" + +var requiredApplicationKernelStatusV1 = []struct { + name string + want string + hex bool +}{ + {name: "CapBnd", want: "0", hex: true}, + {name: "CapEff", want: "0", hex: true}, + {name: "CapPrm", want: "0", hex: true}, + {name: "NoNewPrivs", want: "1"}, + {name: "Seccomp", want: "2"}, +} + +func readApplicationKernelStatus() ([]byte, error) { + content, err := os.ReadFile(applicationKernelStatusPath) + if err != nil { + return nil, fmt.Errorf("read %s: %w", applicationKernelStatusPath, err) + } + return content, nil +} + +func verifyAndExecApplication( + argv []string, + readStatus func() ([]byte, error), + exec func([]string) error, +) error { + if len(argv) == 0 || argv[0] == "" || !path.IsAbs(argv[0]) || path.Clean(argv[0]) != argv[0] { + return fmt.Errorf("application command must begin with a normalized absolute path") + } + if readStatus == nil || exec == nil { + return fmt.Errorf("startup verifier is incomplete") + } + content, err := readStatus() + if err != nil { + return err + } + if err := verifyApplicationKernelStatus(content); err != nil { + return err + } + if err := exec(append([]string(nil), argv...)); err != nil { + return fmt.Errorf("execute application %q: %w", argv[0], err) + } + return nil +} + +func verifyApplicationKernelStatus(content []byte) error { + values := make(map[string]string, len(requiredApplicationKernelStatusV1)) + required := make(map[string]bool, len(requiredApplicationKernelStatusV1)) + for _, field := range requiredApplicationKernelStatusV1 { + required[field.name] = true + } + for _, line := range strings.Split(string(content), "\n") { + name, raw, found := strings.Cut(line, ":") + if !found || !required[name] { + continue + } + if _, exists := values[name]; exists { + return fmt.Errorf("%s appears more than once in %s", name, applicationKernelStatusPath) + } + fields := strings.Fields(raw) + if len(fields) != 1 { + return fmt.Errorf("%s is malformed in %s", name, applicationKernelStatusPath) + } + values[name] = fields[0] + } + for _, field := range requiredApplicationKernelStatusV1 { + value, found := values[field.name] + if !found { + return fmt.Errorf("%s is missing from %s", field.name, applicationKernelStatusPath) + } + if field.hex { + parsed := new(big.Int) + if _, ok := parsed.SetString(value, 16); !ok { + return fmt.Errorf("%s value %q is not hexadecimal", field.name, value) + } + if parsed.Sign() != 0 { + return fmt.Errorf("%s is %s, want an empty capability set", field.name, value) + } + continue + } + parsed, err := strconv.ParseUint(value, 10, 8) + if err != nil { + return fmt.Errorf("%s value %q is not a decimal integer", field.name, value) + } + want, _ := strconv.ParseUint(field.want, 10, 8) + if parsed != want { + return fmt.Errorf("%s is %d, want %d", field.name, parsed, want) + } + } + return nil +} diff --git a/internal/probe/startup_verifier_test.go b/internal/probe/startup_verifier_test.go new file mode 100644 index 00000000..9c6a169e --- /dev/null +++ b/internal/probe/startup_verifier_test.go @@ -0,0 +1,106 @@ +package probe + +import ( + "bytes" + "errors" + "reflect" + "strings" + "testing" +) + +const validApplicationKernelStatus = `Name: reploy-probe +CapPrm: 0000000000000000 +CapEff: 0000000000000000 +CapBnd: 0000000000000000 +NoNewPrivs: 1 +Seccomp: 2 +` + +func TestVerifyApplicationKernelStatusAcceptsRequiredSandbox(t *testing.T) { + if err := verifyApplicationKernelStatus([]byte(validApplicationKernelStatus)); err != nil { + t.Fatal(err) + } +} + +func TestVerifyApplicationKernelStatusFailsClosed(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + {name: "missing", content: strings.ReplaceAll(validApplicationKernelStatus, "Seccomp: 2\n", ""), want: "Seccomp is missing"}, + {name: "duplicate", content: validApplicationKernelStatus + "CapEff: 0\n", want: "CapEff appears more than once"}, + {name: "malformed", content: strings.ReplaceAll(validApplicationKernelStatus, "NoNewPrivs: 1", "NoNewPrivs: 1 2"), want: "NoNewPrivs is malformed"}, + {name: "seccomp", content: strings.ReplaceAll(validApplicationKernelStatus, "Seccomp: 2", "Seccomp: 0"), want: "Seccomp is 0, want 2"}, + {name: "no new privileges", content: strings.ReplaceAll(validApplicationKernelStatus, "NoNewPrivs: 1", "NoNewPrivs: 0"), want: "NoNewPrivs is 0, want 1"}, + {name: "effective capabilities", content: strings.ReplaceAll(validApplicationKernelStatus, "CapEff: 0000000000000000", "CapEff: 0000000000000001"), want: "CapEff is 0000000000000001"}, + {name: "permitted capabilities", content: strings.ReplaceAll(validApplicationKernelStatus, "CapPrm: 0000000000000000", "CapPrm: 0000000000000400"), want: "CapPrm is 0000000000000400"}, + {name: "bounding capabilities", content: strings.ReplaceAll(validApplicationKernelStatus, "CapBnd: 0000000000000000", "CapBnd: 000001ffffffffff"), want: "CapBnd is 000001ffffffffff"}, + {name: "invalid capability", content: strings.ReplaceAll(validApplicationKernelStatus, "CapBnd: 0000000000000000", "CapBnd: not-hex"), want: "not hexadecimal"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := verifyApplicationKernelStatus([]byte(test.content)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + +func TestMainVerifyExecPreservesExactArgv(t *testing.T) { + var got []string + var stdout bytes.Buffer + var stderr bytes.Buffer + code := mainWithActions( + []string{"verify-exec", "--", "/opt/app", "", "$(not-shell)"}, + strings.NewReader("ignored"), &stdout, &stderr, + func() error { t.Fatal("verify mode reached hold"); return nil }, + func() error { t.Fatal("verify mode reached copy"); return nil }, + func() ([]byte, error) { return []byte(validApplicationKernelStatus), nil }, + func(argv []string) error { got = append([]string(nil), argv...); return nil }, + ) + if code != 0 || stdout.Len() != 0 || stderr.Len() != 0 || !reflect.DeepEqual(got, []string{"/opt/app", "", "$(not-shell)"}) { + t.Fatalf("code=%d stdout=%q stderr=%q argv=%#v", code, stdout.String(), stderr.String(), got) + } +} + +func TestMainVerifyExecNeverExecutesAfterVerificationFailure(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := mainWithActions( + []string{"verify-exec", "--", "/opt/app"}, strings.NewReader(""), &stdout, &stderr, + func() error { return nil }, func() error { return nil }, + func() ([]byte, error) { + return []byte(strings.ReplaceAll(validApplicationKernelStatus, "Seccomp: 2", "Seccomp: 0")), nil + }, + func([]string) error { t.Fatal("application executed after failed verification"); return nil }, + ) + if code != 1 || !strings.Contains(stderr.String(), "Seccomp is 0, want 2") { + t.Fatalf("code=%d stderr=%q", code, stderr.String()) + } +} + +func TestMainVerifyExecRejectsMalformedContractAndExecutionFailure(t *testing.T) { + for _, args := range [][]string{{"verify-exec"}, {"verify-exec", "/opt/app"}, {"verify-exec", "--", "relative"}} { + var stderr bytes.Buffer + code := mainWithActions(args, strings.NewReader(""), &bytes.Buffer{}, &stderr, + func() error { return nil }, func() error { return nil }, + func() ([]byte, error) { return []byte(validApplicationKernelStatus), nil }, + func([]string) error { t.Fatal("invalid verify request executed"); return nil }) + if code == 0 { + t.Fatalf("args %#v succeeded", args) + } + } + + var stderr bytes.Buffer + code := mainWithActions( + []string{"verify-exec", "--", "/missing"}, strings.NewReader(""), &bytes.Buffer{}, &stderr, + func() error { return nil }, func() error { return nil }, + func() ([]byte, error) { return []byte(validApplicationKernelStatus), nil }, + func([]string) error { return errors.New("no such file") }, + ) + if code != 1 || !strings.Contains(stderr.String(), `execute application "/missing": no such file`) { + t.Fatalf("code=%d stderr=%q", code, stderr.String()) + } +}