From 26e8985cdabd9ed17e74cc30afb92123935dd642 Mon Sep 17 00:00:00 2001 From: Max Mehl Date: Fri, 18 Sep 2026 22:37:40 +0200 Subject: [PATCH] add security assessment --- POC-RESULTS.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/POC-RESULTS.md b/POC-RESULTS.md index a572b9a..e09d745 100644 --- a/POC-RESULTS.md +++ b/POC-RESULTS.md @@ -52,3 +52,32 @@ Unchanged from `plan.md`'s original list, still not needed to validate the core - Per-repository allow-list for which host aliases a service repository may deploy to. - Age key rotation process and procedure for onboarding/offboarding DevOps team members. - A dedicated bot Gitea/Woodpecker account for the cross-repo trigger token (a personal token was used for the PoC). + +## Security analysis + +This section evaluates the trust model actually implemented in the PoC, not just the one described in `concept.md`. Several gaps below are PoC-specific shortcuts; others are structural properties of the design that would need attention before onboarding real services. Severity is relative to a self-hosted homelab/small-team context, not an enterprise threat model. + +### Secrets exposure + +- **`ZOT_USERNAME`/`ZOT_PASSWORD` are available to every step of `dummy-service`'s pipeline, including `build-db`/`build-app`.** These steps run `woodpeckerci/plugin-docker-buildx`, a third-party (if official) plugin, with full access to the registry-push credential. Any change to the Dockerfile or build context that exfiltrates environment variables (e.g. a build stage that `curl`s them to an external host) would leak the shared org-level registry credential. Since this credential is an **org-level** Woodpecker secret, compromising it from *any* one service repo's build step exposes push access to *every* repo under that org's Zot namespace, not just the compromised one. +- **The SOPS age private key and the production SSH private key are both Woodpecker repo-level secrets on `poc-deployment`, gated to `deployment`/`manual` events only** (not `push`), which is the correct application of concept.md's trust boundary — a service repo's build pipeline never sees these two credentials directly. This isolation held up correctly throughout the PoC. +- **The Zot registry credential is also needed inside `poc-deployment`'s deploy step**, for both `oras login` and `docker login` on the target host. It is currently the *same* org-level secret used by every service's build pipeline, meaning the deploy pipeline and every service's build pipeline share one registry credential. A compromise of any single service repo's pipeline configuration is therefore sufficient to obtain a credential that is also trusted by the central deployment pipeline's registry access, even though it does not grant SSH or SOPS access directly. +- **The production SSH private key is transiently written to a plaintext file (`/tmp/ssh/id_deploy`) inside the `show-deployment-request` step's container.** It is never written to the target host's disk — only the corresponding *public* key lives there. The private key file inside the ephemeral build container is deleted along with the container when the step finishes; it does not persist on the Woodpecker agent host beyond the step's lifetime, but it is written to disk (not held only in memory) during that window. This is a reasonable PoC-level trade-off, not a severe issue, but is worth naming explicitly since "plaintext should exist only transiently" is exactly the principle applied to `secrets.decrypted.env`, and the same standard now also applies to the SSH key. +- **The registry credential now correctly does not persist on the target host** between deployments (`docker logout` added after `docker compose up`), and the decrypted application secrets file is deleted from the target host after each deployment. Both were gaps found and fixed during the PoC (see bug list above) rather than being correct from the start. + +### Malicious pull requests and untrusted contributions + +- **Neither pipeline restricts secrets from pull-request events**, and Woodpecker's project-level "Require approval" setting was left at its default for both repos rather than being explicitly reviewed or configured. Woodpecker's own documentation states secrets are *not* exposed to `pull_request` events by default unless explicitly enabled per secret — none of this PoC's secrets have that opt-in set, so this specific PoC is not currently exposed to the classic "malicious PR reads secrets" attack as implemented. However, this protection was never deliberately verified or exercised during the PoC (no pull request was opened against either repo), so it should be treated as an assumption inherited from Woodpecker's defaults, not as a tested control. +- **This PoC has a single contributor (the repository owner) with push access to both repos.** The realistic multi-contributor threat model — an external or lower-trust contributor opening a pull request against `dummy-service` — was not exercised at all. Before onboarding a real service repository with more than one contributor, the "Require approval for" and "Allow pull requests" settings on that repository should be deliberately reviewed, and any secret that must be available to a `pull_request` build (there are none in this PoC) should be treated as a specific, individually justified exception. + +### Cross-repository attack surface + +- **The Woodpecker personal access token used for `woodpecker_trigger_token` is unscoped and grants exactly the same permissions as the user account it belongs to** (Woodpecker tokens have no scope/permission model at all — confirmed directly against the API specification during this PoC). Since a personal token was used rather than the dedicated bot account recommended in `concept.md`, any pipeline that can read this secret can trigger a pipeline run on *any* repository the token's owner (here, the instance's admin account) can access — not just `poc-deployment`. This is a materially larger blast radius than intended and is the single highest-priority item to fix before this design is used for anything beyond a PoC. +- **Enabling "Allow deployments" on `poc-deployment` was required to make the trigger work, and this setting carries Woodpecker's own explicit warning**: any user with push access to that repository can use `deploy` events to reach that repository's deploy-scoped secrets (the SOPS key and the production SSH key). In this PoC that risk is theoretical, since only the repository owner has push access — but it means the security boundary protecting the two most sensitive credentials in the whole system ultimately rests on Gitea's push-access control for one repository, not on anything Woodpecker-specific. +- **`poc-deployment`'s `deploy.yaml` accepts `TARGET` from the triggering pipeline and resolves it only against a fixed set of files under `hosts/`,** correctly preventing an arbitrary or attacker-controlled SSH destination from being reached even if `ARTIFACT`/`TARGET` were manipulated — this part of concept.md's design (fail closed on an unknown alias) worked exactly as intended and was verified with the deliberate `hosts/$TARGET` existence check. +- **There is currently no allow-list restricting which service repository may request which `TARGET` alias.** Per `plan.md`'s explicit scope decision, this was deferred deliberately; any repository that can trigger `poc-deployment` can currently request deployment to `target1`. With only one service repo and one target host in the PoC this has no practical effect, but it is a real gap the moment a second, less-trusted service repository or a second production host is introduced. +- **`ARTIFACT` is passed by the triggering pipeline and used directly in an `oras pull` command on the deployment pipeline's side, with no verification that the referenced artifact actually originated from the repository that triggered the deployment.** Any pipeline capable of triggering `poc-deployment` at all (which, given the token scoping issue above, is currently broader than just `dummy-service`) could in principle request the pull and deployment of an artifact pushed by a different, possibly less-trusted repository, as long as that artifact resolves on the shared Zot registry. This was not exploitable within the PoC's single-tenant setup, but is a structural gap worth closing (e.g. binding `ARTIFACT` to the expected repository/namespace) before this design serves multiple independently-trusted services. + +### Summary assessment + +The core trust boundary from `concept.md` — production SSH credentials and the SOPS decryption key never reaching a service repository's build pipeline — held up correctly throughout the PoC and was the one design property never compromised or worked around, even under significant debugging pressure. The gaps found are concentrated in three places: the breadth of the org-level registry credential (shared across all service builds and the deploy pipeline), the unscoped personal trigger token (broader blast radius than the bot-account design called for), and the absence of any binding between a triggering repository and the artifact/target it is allowed to request. None of these blocked the PoC's goal of proving the mechanism works, but all three should be addressed — the token scoping first, since it is both the cheapest fix and the largest blast-radius reduction — before any real service is onboarded to this pipeline.