diff --git a/apps/dokploy/__test__/admission/cosign-args.test.ts b/apps/dokploy/__test__/admission/cosign-args.test.ts new file mode 100644 index 0000000000..c8aa2fbff1 --- /dev/null +++ b/apps/dokploy/__test__/admission/cosign-args.test.ts @@ -0,0 +1,124 @@ +import { + buildCosignArgs, + buildCosignDockerArgv, + DEFAULT_COSIGN_IMAGE, +} from "@dokploy/server/utils/admission/verify-signature"; +import { describe, expect, it } from "vitest"; + +const REF = `ghcr.io/org/app@sha256:${"a".repeat(64)}`; +const MIRROR = `mirror.example.com/cosign@sha256:${"b".repeat(64)}`; + +describe("buildCosignArgs", () => { + it("keyed mode uses --key env://COSIGN_KEY and ends with the ref", () => { + const args = buildCosignArgs(REF, { + mode: "keyed", + publicKey: "-----BEGIN PUBLIC KEY-----\n…", + }); + expect(args).toEqual(["verify", "--key", "env://COSIGN_KEY", REF]); + }); + + it("keyless mode uses identity + issuer flags", () => { + const args = buildCosignArgs(REF, { + mode: "keyless", + certificateIdentityRegexp: "^https://github.com/org/.+$", + certificateOidcIssuer: "https://token.actions.githubusercontent.com", + }); + expect(args).toEqual([ + "verify", + "--certificate-identity-regexp", + "^https://github.com/org/.+$", + "--certificate-oidc-issuer", + "https://token.actions.githubusercontent.com", + REF, + ]); + }); + + it("appends --insecure-ignore-tlog when ignoreTlog is set", () => { + const args = buildCosignArgs(REF, { + mode: "keyed", + publicKey: "k", + ignoreTlog: true, + }); + expect(args).toContain("--insecure-ignore-tlog"); + expect(args[args.length - 1]).toBe(REF); + }); + + it("throws when keyed mode has no public key", () => { + expect(() => buildCosignArgs(REF, { mode: "keyed" })).toThrow(); + }); + + it("throws when keyless mode is missing identity or issuer", () => { + expect(() => + buildCosignArgs(REF, { mode: "keyless", certificateOidcIssuer: "x" }), + ).toThrow(); + }); + + it("rejects a non-digest pinned ref (argv flag-smuggling guard)", () => { + expect(() => + buildCosignArgs("nginx:latest", { mode: "keyed", publicKey: "k" }), + ).toThrow(); + }); +}); + +describe("buildCosignDockerArgv", () => { + it("mounts the docker config read-only, passes the key via env, never uses a shell", () => { + const argv = buildCosignDockerArgv( + REF, + { mode: "keyed", publicKey: "PEMDATA" }, + { dockerConfigDir: "/etc/dokploy/cosign-auth/x" }, + ); + expect(argv[0]).toBe("run"); + expect(argv).toContain("--rm"); + expect(argv).toContain("-v"); + expect(argv).toContain("/etc/dokploy/cosign-auth/x:/root/.docker:ro"); + expect(argv).toContain("-e"); + expect(argv).toContain("COSIGN_KEY=PEMDATA"); + expect(argv).toContain(DEFAULT_COSIGN_IMAGE); + // the cosign sub-args follow the image, ending with the ref + expect(argv[argv.length - 1]).toBe(REF); + // no shell wrapper + expect(argv).not.toContain("sh"); + expect(argv).not.toContain("-c"); + }); + + it("uses a custom cosignImage override when provided", () => { + const argv = buildCosignDockerArgv( + REF, + { + mode: "keyless", + certificateIdentityRegexp: "a", + certificateOidcIssuer: "b", + }, + { + dockerConfigDir: "/d", + cosignImage: MIRROR, + }, + ); + expect(argv).toContain(MIRROR); + expect(argv).not.toContain("-e"); // keyless: no COSIGN_KEY env + }); + + it("rejects a flag-like cosignImage override", () => { + expect(() => + buildCosignDockerArgv( + REF, + { + mode: "keyless", + certificateIdentityRegexp: "a", + certificateOidcIssuer: "b", + }, + { dockerConfigDir: "/d", cosignImage: "-v/etc/passwd:/x" }, + ), + ).toThrow(); + }); + + it("rejects a dockerConfigDir containing a colon", () => { + expect(() => + buildCosignDockerArgv( + REF, + { mode: "keyed", publicKey: "k" }, + { dockerConfigDir: "/etc/dokploy:evil" }, + ), + ).toThrow(); + }); +}); diff --git a/apps/dokploy/__test__/admission/image-ref.test.ts b/apps/dokploy/__test__/admission/image-ref.test.ts new file mode 100644 index 0000000000..f96f8d581f --- /dev/null +++ b/apps/dokploy/__test__/admission/image-ref.test.ts @@ -0,0 +1,82 @@ +import { + buildPinnedRef, + extractImageName, + extractImageTag, + isDigestRef, + normalizeRepo, + parseImageRef, +} from "@dokploy/server/utils/admission/image-ref"; +import { describe, expect, it } from "vitest"; + +describe("image-ref parsing", () => { + it("extracts name without tag (and handles registry port)", () => { + expect(extractImageName("nginx:1.27")).toBe("nginx"); + expect(extractImageName("nginx")).toBe("nginx"); + expect(extractImageName("registry:5000/app")).toBe("registry:5000/app"); + expect(extractImageName("registry:5000/app:v1")).toBe("registry:5000/app"); + }); + + it("extracts name for a digest ref (splits on @, not :)", () => { + expect(extractImageName("nginx@sha256:abc")).toBe("nginx"); + expect(extractImageName("registry:5000/app@sha256:abc")).toBe( + "registry:5000/app", + ); + }); + + it("extracts tag, defaulting to latest", () => { + expect(extractImageTag("nginx")).toBe("latest"); + expect(extractImageTag("nginx:1.27")).toBe("1.27"); + expect(extractImageTag("myhost:5000/fedora/httpd:v1")).toBe("v1"); + }); + + it("detects digest refs", () => { + expect(isDigestRef("nginx@sha256:abc")).toBe(true); + expect(isDigestRef("nginx:1.27")).toBe(false); + }); + + it("parses into name/tag/digest", () => { + expect(parseImageRef("nginx:1.27")).toEqual({ + name: "nginx", + tag: "1.27", + digest: null, + }); + expect(parseImageRef("nginx@sha256:abc")).toEqual({ + name: "nginx", + tag: null, + digest: "sha256:abc", + }); + expect(parseImageRef("r:5000/app:v1@sha256:def")).toEqual({ + name: "r:5000/app", + tag: "v1", + digest: "sha256:def", + }); + }); + + it("normalizes Docker Hub shorthand to a canonical repo", () => { + expect(normalizeRepo("alpine")).toBe("docker.io/library/alpine"); + expect(normalizeRepo("library/alpine")).toBe("docker.io/library/alpine"); + expect(normalizeRepo("user/repo")).toBe("docker.io/user/repo"); + expect(normalizeRepo("ghcr.io/org/app")).toBe("ghcr.io/org/app"); + expect(normalizeRepo("localhost:5000/app")).toBe("localhost:5000/app"); + }); + + it("builds a pinned ref preserving the operator's registry/repo", () => { + expect(buildPinnedRef("ghcr.io/org/app:v1", "sha256:abc")).toBe( + "ghcr.io/org/app@sha256:abc", + ); + expect(buildPinnedRef("alpine", "sha256:def")).toBe("alpine@sha256:def"); + }); + + it("treats a bare numeric segment as a tag, not a port", () => { + expect(extractImageName("nginx:123")).toBe("nginx"); + expect(extractImageTag("nginx:123")).toBe("123"); + expect(extractImageName("ghcr.io/org/app:8080")).toBe("ghcr.io/org/app"); + expect(parseImageRef("ghcr.io/org/app:8080")).toEqual({ + name: "ghcr.io/org/app", + tag: "8080", + digest: null, + }); + // host:port/path with no tag is still NOT split (true port case) + expect(extractImageName("registry:5000/app")).toBe("registry:5000/app"); + }); +}); diff --git a/apps/dokploy/__test__/admission/select-repo-digest.test.ts b/apps/dokploy/__test__/admission/select-repo-digest.test.ts new file mode 100644 index 0000000000..300907171f --- /dev/null +++ b/apps/dokploy/__test__/admission/select-repo-digest.test.ts @@ -0,0 +1,44 @@ +import { selectRepoDigest } from "@dokploy/server/utils/admission/resolve-digest"; +import { describe, expect, it } from "vitest"; + +describe("selectRepoDigest", () => { + it("returns the operator's repo with the matched digest", () => { + const repoDigests = ["nginx@sha256:aaa"]; + expect(selectRepoDigest(repoDigests, "nginx:1.27")).toBe( + "nginx@sha256:aaa", + ); + }); + + it("picks the entry whose repository matches the deployed ref (not index 0)", () => { + const repoDigests = [ + "alpine@sha256:hub", + "myreg.example.com/alpine@sha256:priv", + ]; + expect(selectRepoDigest(repoDigests, "myreg.example.com/alpine:3.19")).toBe( + "myreg.example.com/alpine@sha256:priv", + ); + }); + + it("normalizes Docker Hub shorthand when matching", () => { + const repoDigests = ["alpine@sha256:aaa"]; + expect(selectRepoDigest(repoDigests, "docker.io/library/alpine:3.19")).toBe( + "docker.io/library/alpine@sha256:aaa", + ); + }); + + it("throws when RepoDigests is empty (unpushed/local-only)", () => { + expect(() => selectRepoDigest([], "nginx:1.27")).toThrow(); + }); + + it("throws when no entry matches the deployed repository", () => { + expect(() => + selectRepoDigest(["other/image@sha256:zzz"], "nginx:1.27"), + ).toThrow(); + }); + + it("pins a bare matched ref — local-only protection lives in resolveDigest's docker pull, not in this pure function", () => { + expect(selectRepoDigest(["nginx@sha256:aaa"], "nginx")).toBe( + "nginx@sha256:aaa", + ); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/trust-policy/handle-trust-policy.tsx b/apps/dokploy/components/dashboard/settings/trust-policy/handle-trust-policy.tsx new file mode 100644 index 0000000000..853d356521 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/trust-policy/handle-trust-policy.tsx @@ -0,0 +1,353 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { PenBoxIcon, PlusIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +const schema = z + .object({ + name: z.string().min(1, { message: "Name is required" }), + mode: z.enum(["keyed", "keyless"]), + publicKey: z.string().optional(), + certificateIdentityRegexp: z.string().optional(), + certificateOidcIssuer: z.string().optional(), + ignoreTlog: z.boolean(), + cosignImage: z.string().optional(), + }) + .refine((v) => v.mode !== "keyed" || !!v.publicKey, { + path: ["publicKey"], + message: "Public key is required for keyed mode", + }) + .refine( + (v) => + v.mode !== "keyless" || + (!!v.certificateIdentityRegexp && !!v.certificateOidcIssuer), + { + path: ["certificateIdentityRegexp"], + message: "Identity regexp and issuer are required for keyless mode", + }, + ); + +type FormValues = z.infer; + +interface Props { + trustPolicyId?: string; +} + +export const HandleTrustPolicy = ({ trustPolicyId }: Props) => { + const utils = api.useUtils(); + const [isOpen, setIsOpen] = useState(false); + + const { data: policy } = api.trustPolicy.one.useQuery( + { trustPolicyId: trustPolicyId || "" }, + { enabled: !!trustPolicyId }, + ); + + const { mutateAsync } = trustPolicyId + ? api.trustPolicy.update.useMutation() + : api.trustPolicy.create.useMutation(); + + const form = useForm({ + defaultValues: { + name: "", + mode: "keyless", + publicKey: "", + certificateIdentityRegexp: "", + certificateOidcIssuer: "", + ignoreTlog: false, + cosignImage: "", + }, + resolver: zodResolver(schema), + }); + + const mode = form.watch("mode"); + + useEffect(() => { + if (policy) { + form.reset({ + name: policy.name, + mode: policy.mode, + publicKey: policy.publicKey ?? "", + certificateIdentityRegexp: policy.certificateIdentityRegexp ?? "", + certificateOidcIssuer: policy.certificateOidcIssuer ?? "", + ignoreTlog: policy.ignoreTlog, + cosignImage: policy.cosignImage ?? "", + }); + } else { + form.reset({ + name: "", + mode: "keyless", + publicKey: "", + certificateIdentityRegexp: "", + certificateOidcIssuer: "", + ignoreTlog: false, + cosignImage: "", + }); + } + }, [form, form.reset, form.formState.isSubmitSuccessful, policy]); + + const onSubmit = async (data: FormValues) => { + const payload: any = { + name: data.name, + mode: data.mode, + publicKey: data.publicKey || undefined, + certificateIdentityRegexp: data.certificateIdentityRegexp || undefined, + certificateOidcIssuer: data.certificateOidcIssuer || undefined, + ignoreTlog: data.ignoreTlog, + cosignImage: data.cosignImage || undefined, + ...(trustPolicyId ? { trustPolicyId } : {}), + }; + + await mutateAsync(payload) + .then(async () => { + await utils.trustPolicy.all.invalidate(); + toast.success( + trustPolicyId + ? "Trust policy updated successfully" + : "Trust policy created successfully", + ); + setIsOpen(false); + }) + .catch(() => { + toast.error( + trustPolicyId + ? "Error updating trust policy" + : "Error creating trust policy", + ); + }); + }; + + return ( + + + {trustPolicyId ? ( + + ) : ( + + )} + + + + + {trustPolicyId ? "Edit Trust Policy" : "Add Trust Policy"} + + + Configure a cosign verification policy for admitted images. + + +
+ +
+ ( + + Name + + + + + + )} + /> +
+ +
+ ( + + Mode + + + + + + )} + /> +
+ + {mode === "keyed" && ( +
+ ( + + Public Key (PEM) + +