diff --git a/README.md b/README.md index 211d872d..ad0f0791 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ OrcaCD is a simple GitOps tool for Docker. It allows you to deploy your applicat You can get started with OrcaCD by following the guide in the [documentation](https://orcacd.dev). +### Restricted deployment directories + +The agent checks deployment paths for symlinks before deploying. A container can create a symlink in a writable bind mount and use it as a mount source on a later deployment to access paths outside its application directory. If directory permissions prevent the agent from inspecting a path, the deployment is rejected. + +Prefer granting the agent search permission on the affected parent directories. If that is impractical, you can uncomment the `cap_add` block for `DAC_READ_SEARCH` in the agent's Compose configuration and recreate the agent container. This optional Linux capability bypasses file read and directory read/search permissions for **all files accessible to the agent**, not just the deployments directory. It does not bypass write permissions. It is disabled by default; symlink checks remain enabled. + ## Contribute You're very welcome to contribute to OrcaCD! Please follow the [contribution guide](https://github.com/OrcaCD/orca-cd/blob/main/CONTRIBUTING.md) to get started. If you wish to contribute to the documentation head over to the [repository](https://github.com/OrcaCD/docs). diff --git a/backend/internal/agent/docker/deployment_paths.go b/backend/internal/agent/docker/deployment_paths.go index 94bfe2e5..f5c410de 100644 --- a/backend/internal/agent/docker/deployment_paths.go +++ b/backend/internal/agent/docker/deployment_paths.go @@ -28,6 +28,9 @@ func deploymentPathViolation(path, deploymentsDir, applicationDir string) string } link, err := firstSymlink(deploymentsDir, path) if err != nil { + if errors.Is(err, os.ErrPermission) { + return fmt.Sprintf("could not be checked for symlinks: %v; allow the agent to search the parent directories, or opt in to the agent's DAC_READ_SEARCH capability (bypasses read/search permissions for all files accessible to the agent)", err) + } return fmt.Sprintf("could not be checked for symlinks: %v", err) } if link != "" { diff --git a/backend/internal/agent/docker/deployment_paths_test.go b/backend/internal/agent/docker/deployment_paths_test.go index acb2b29c..5c5b09c0 100644 --- a/backend/internal/agent/docker/deployment_paths_test.go +++ b/backend/internal/agent/docker/deployment_paths_test.go @@ -1,6 +1,7 @@ package docker import ( + "errors" "os" "path/filepath" "strings" @@ -9,6 +10,36 @@ import ( composetypes "github.com/compose-spec/compose-go/v2/types" ) +func TestDeploymentPaths_RejectsInaccessiblePathsWithGuidance(t *testing.T) { + rules := setupDeploymentDirs(t) + parent := filepath.Join(rules.applicationDir, "data") + source := filepath.Join(parent, "private") + if err := os.Mkdir(source, 0o750); err != nil { + t.Fatal(err) + } + if err := os.Chmod(parent, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + //nolint:gosec // Restore directory traversal so t.TempDir can clean up. + if err := os.Chmod(parent, 0o750); err != nil { + t.Error(err) + } + }) + if _, err := os.Lstat(source); !errors.Is(err, os.ErrPermission) { + if err == nil { + t.Skip("process can bypass directory permissions") + } + t.Fatal(err) + } + + err := checkDeployPolicy(bindMountProject(source), rules) + requireErrorContains(t, err, "could not be checked for symlinks") + requireErrorContains(t, err, "allow the agent to search the parent directories") + requireErrorContains(t, err, "opt in to the agent's DAC_READ_SEARCH capability") + requireErrorContains(t, err, "bypasses read/search permissions for all files accessible to the agent") +} + func setupDeploymentDirs(t *testing.T) pathRules { t.Helper() deploymentsDir := t.TempDir() diff --git a/docker-compose.yml b/docker-compose.yml index f0f8b953..5c7b11bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,10 @@ services: - no-new-privileges:true cap_drop: - ALL + # Optional: inspect deployment paths inside directories restricted by application containers. + # This bypasses read/search permissions for all files accessible to the agent. + # cap_add: + # - DAC_READ_SEARCH use_api_socket: true # Allows container to access Docker API on host volumes: - ./deployments:/deployments diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 1057c116..2788f016 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -29,6 +29,13 @@ services: profiles: - agent container_name: orca-agent-e2e-test + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + # Exercise the optional capability for restricted deployment directories. + - DAC_READ_SEARCH use_api_socket: true volumes: - ./data/agent/deployments:/deployments diff --git a/e2e/tests/agent.test.ts b/e2e/tests/agent.test.ts index 6c2fcf09..0e477a36 100644 --- a/e2e/tests/agent.test.ts +++ b/e2e/tests/agent.test.ts @@ -136,6 +136,9 @@ test.describe("Agent setup", () => { const compose = await setup.locator("pre").innerText(); expect(compose).toContain(`HUB_URL: ${APP_URL}`); expect(compose).toMatch(/image: ghcr\.io\/orcacd\/agent:\S+/); + expect(compose).toMatch(/cap_drop:\s+- ALL/); + expect(compose).not.toMatch(/^\s*cap_add:/m); + expect(compose).toMatch(/# cap_add:\s+#\s+- DAC_READ_SEARCH/); authToken = compose.match(/AUTH_TOKEN: (\S+)/)?.[1] ?? ""; expect(authToken).not.toBe(""); await setup.getByRole("button", { name: "Close" }).click(); diff --git a/e2e/tests/applications.test.ts b/e2e/tests/applications.test.ts index 3ec3437d..78820ee0 100644 --- a/e2e/tests/applications.test.ts +++ b/e2e/tests/applications.test.ts @@ -15,6 +15,7 @@ import { pullImage, removeApplicationContainers, removeVolumes, + runWithAgentVolumes, startContainer, stopContainer, } from "./helpers/docker.ts"; @@ -130,7 +131,7 @@ const SAFE_VOLUME = `${SAFE_PROJECT}_data`; const safeCompose = policyComposeFile( ` volumes: - data:/named - - ./bound:/bound + - ./bound/data:/bound `, `volumes: data: @@ -944,7 +945,7 @@ test.describe("Compose security policy", () => { const bind = mounts.find((mount) => mount.Destination === "/bound"); expect(bind?.Type).toBe("bind"); // The agent translates the path inside its container to the one on the Docker host - expect(bind?.Source).toMatch(new RegExp(`/${SAFE_PROJECT}/bound$`)); + expect(bind?.Source).toMatch(new RegExp(`/${SAFE_PROJECT}/bound/data$`)); await expect .poll(() => execInContainer(container.id, ["cat", "/named/file"]), { timeout: 15_000 }) @@ -955,6 +956,35 @@ test.describe("Compose security policy", () => { expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ type: "deployment", status: "succeeded" }); }); + + test("redeploys through a directory restricted to an application user", async ({ page }) => { + const id = policyAppIds.get(SAFE_NAME)!; + const parent = `/deployments/${SAFE_PROJECT}/bound`; + const restrict = runWithAgentVolumes([ + "sh", + "-ec", + `chown 1234:1234 ${parent}; chmod 700 ${parent}`, + ]); + expect(restrict.status, restrict.stderr).toBe(0); + try { + const before = (await applicationEvents(page, id)).length; + expect((await api(page, "POST", `/applications/${id}/deploy`)).status).toBe(202); + await expect + .poll(async () => (await applicationEvents(page, id)).length, { timeout: 60_000 }) + .toBe(before + 1); + await expect + .poll(async () => (await applicationEvents(page, id))[0].status, { timeout: 60_000 }) + .toBe("succeeded"); + expect((await getApplication(page, id)).lastSyncError).toBeUndefined(); + } finally { + const restore = runWithAgentVolumes([ + "sh", + "-ec", + `chmod 755 ${parent}; chown 0:0 ${parent}`, + ]); + expect(restore.status, restore.stderr).toBe(0); + } + }); }); test.describe("Deployment notifications", () => { diff --git a/e2e/tests/helpers/docker.ts b/e2e/tests/helpers/docker.ts index 45bed630..41125abc 100644 --- a/e2e/tests/helpers/docker.ts +++ b/e2e/tests/helpers/docker.ts @@ -125,7 +125,7 @@ export function inspectContainer(id: string) { // The deployments directory is owned by root on Linux hosts and the agent image has no shell, // so a throwaway container with the agent's volumes reads it -function runWithAgentVolumes(command: string[]) { +export function runWithAgentVolumes(command: string[]) { return spawnSync( "docker", ["run", "--rm", "--volumes-from", AGENT_CONTAINER, BUSYBOX_IMAGE, ...command], diff --git a/frontend/src/components/dialogs/agent-setup-dialog.tsx b/frontend/src/components/dialogs/agent-setup-dialog.tsx index ebdcf65d..1d85bbe9 100644 --- a/frontend/src/components/dialogs/agent-setup-dialog.tsx +++ b/frontend/src/components/dialogs/agent-setup-dialog.tsx @@ -49,6 +49,10 @@ export function buildAgentComposeFile({ - no-new-privileges:true cap_drop: - ALL + # Optional: inspect deployment paths inside directories restricted by application containers. + # This bypasses read/search permissions for all files accessible to the agent. + # cap_add: + # - DAC_READ_SEARCH volumes: - ./deployments:/deployments `;