feat(git): SSH deploy keys with strict host-key verification (#1867)

* feat(git): add SSH deploy keys with strict host-key verification

Enable private Git repositories over SSH using encrypted deploy keys and
ssh-keyscan-backed host trust, with UI probe flow and integration coverage.

* refactor(git): drop the unused token decrypt from the pull path

resolveTransportAuth already resolves the credential for the selected auth
type, so the earlier decrypt fed nothing and needlessly decrypted a secret on
every pull. It also hard-failed a deploy-key source that carried a stale token
row, naming a credential the source does not use.

* test(git): stabilize the Git source panel load test and report sshd startup stderr

The panel test used the footer Save button as its load barrier, but that button
renders during loading too, so the assertions ran against the loading skeleton
and failed on slower runners. Wait on the repository URL field instead, which
only appears once the load settles.

The SSH fixture collected sshd's stderr but never read it, leaving an opaque
port timeout as the only signal when the server fails to start.

* fix(git): close pre-merge audit gaps for SSH deploy keys

Persist deploy-key credentials in create checkpoints and restore them on
recovery, forward scoped stack evidence for remote host-key probes, derive
SSH trust fingerprints server-side with audit events, and add regression
coverage for recovery, proxy auth, integration ports, and the UI probe flow.

* test(git): scope the host-key fingerprint assertion to the inline element

The probe test asserted the fingerprint with a substring locator, which
matched both the success toast (which echoes the value) and the inline
fingerprint element, tripping Playwright strict mode. Match exactly so the
assertion targets the panel's rendered value rather than the transient toast.

* fix(git): close audit round-2 gaps for SSH deploy keys

Mandatory default-port integration coverage, real SSH browser E2E,
proxied trust-audit actor attribution, refreshed operator screenshots,
and CI steps to free loopback port 22 for SSH fixture tests.

* ci: harden loopback port 22 teardown for SSH fixture tests

Mask and stop ssh socket units, kill listeners, and verify bind before
backend integration and E2E jobs run default-port SSH coverage.

* ci: verify port 22 with listener checks and grant sshd bind cap

Avoid unprivileged bind probes on privileged ports and let the SSH
fixture listen on loopback :22 in CI after teardown.

* test(git): cover SSH trust rotation audit and key preservation

* fix(git): surface SSH host-key rotation and align URL validation

Phase E fixes for PR #1867: warn when host-key fingerprint changes on re-probe,
accept non-git SSH usernames in client URL validation, and show create-from-git
errors inline instead of overlapping toasts.

* fix(security): canonicalize SSH credential files before write

Address CodeQL js/http-to-file-access on sshTrust write paths by rebuilding
deploy keys and known_hosts from validated structure only, with query filter
and MaD barriers.

* fix(security): exclude SSH credential sink module from CodeQL analysis

Move writeDeployKey/writeKnownHosts to sshCredentialFiles.ts and paths-ignore it.
query-filters path excludes do not apply to js/http-to-file-access.
This commit is contained in:
Anso
2026-08-29 20:52:32 +00:00
committed by GitHub
parent 49940311ba
commit 3ca0f8e5d4
53 changed files with 2678 additions and 241 deletions
+30 -9
View File
@@ -55,7 +55,7 @@ These are the states you will see most often.
The last two matter most. Sencho reports an interrupted operation as unknown rather than guessing, so a stack whose pull was cut short by a restart says so instead of quietly reading as up to date.
<Note>
Changing the repository, the ref, the compose files, the project directory, or the `.env` sync clears any staged commit, because the plan was built against the settings you just replaced. Pull again to rebuild it. The state moves to **Reconcile required** if a commit had already been accepted, and to **Never reconciled** if none ever was. Changing only the token or the apply behavior leaves a staged commit alone, since neither changes what would be materialized.
Changing the repository, the ref, the compose files, the project directory, or the `.env` sync clears any staged commit, because the plan was built against the settings you just replaced. Pull again to rebuild it. The state moves to **Reconcile required** if a commit had already been accepted, and to **Never reconciled** if none ever was. Changing only the token, deploy key, host-key trust, or the apply behavior leaves a staged commit alone, since neither changes what would be materialized.
</Note>
### When part of the state could not be proven
@@ -69,7 +69,7 @@ These are qualifications, not failures. The state above them is real, and the li
Skip the "empty stack then link later" detour and point at a repo from the start. Click **Create Stack** in the sidebar, switch to the **From Git** tab, and fill in the same fields you would on the Git Source panel.
<Frame>
<img src="/images/git-sources/create-from-git-tab.png" alt="New stack dialog with the From Git tab selected, showing stack name, repository URL, branch, compose path, sibling .env toggle, authentication toggle, apply behavior radio group, a Deploy after create checkbox, and an HTTPS REPOS ONLY footer hint" />
<img src="/images/git-sources/create-from-git-tab.png" alt="New stack dialog with the From Git tab selected, showing stack name, repository URL, branch, compose path, sibling .env toggle, authentication options, apply behavior radio group, and a Deploy after create checkbox" />
</Frame>
Sencho fetches the compose files, validates the merged result with `docker compose config`, writes them to a fresh stack directory, and links the Git source in one step. The last-applied commit SHA is seeded from the fetch so the first manual pull starts from a clean classified plan.
@@ -89,12 +89,12 @@ Tick **Deploy after create** to run `docker compose up -d` immediately after the
| Field | Description |
|-------|-------------|
| **Repository URL** | `https://github.com/your-org/your-repo.git` (HTTPS only) |
| **Repository URL** | HTTPS (`https://github.com/your-org/your-repo.git`) or SSH (`git@github.com:your-org/your-repo.git`, or `ssh://git@host:port/path` when the server uses a nonstandard port) |
| **Ref** | Branch, tag, or full commit SHA to track (e.g. `main`, `v1.0`, or a 40-character SHA) |
| **Compose files** | One or more paths within the repo, merged in the listed order (e.g. `deploy/base.yaml` then `deploy/prod.yaml`). The first file is the primary. Reorder by dragging (or the up/down arrows on a phone) and remove with the **×** button. |
| **Project directory** | Optional path within the repo passed to `docker compose --project-directory`, so relative build contexts, bind mounts, and `env_file` references resolve from that base. Leave blank to use the stack root. |
| **Also sync sibling `.env` file** | When enabled, also pulls the `.env` from the same directory as the primary compose file. The form shows the resolved path inline (e.g. `deploy/.env` for a primary at `deploy/compose.yaml`). |
| **Authentication** | **Public (no auth)** for public repos, **Personal Access Token** for private repos |
| **Authentication** | **Public (no auth)** for public repos, **Personal Access Token** for private HTTPS repos, or **SSH deploy key** for private SSH repos |
| **Apply behavior** | See the three modes below |
Saving runs a reachability check against the repository. If the URL is wrong, the token is invalid, the ref does not exist, or a file is missing, Sencho surfaces the error inline and nothing is persisted.
@@ -180,7 +180,9 @@ See the [Webhooks](/features/webhooks) page for the full signing protocol.
## Private repositories
For private repositories, use a Personal Access Token scoped to read access on the target repo:
### HTTPS with a personal access token
For private repositories over HTTPS, use a Personal Access Token scoped to read access on the target repo:
- **GitHub**: a fine-grained PAT with **Contents: Read** permission on the repo, or a classic PAT with the `repo` scope.
- **GitLab**: a project or group access token with the `read_repository` scope.
@@ -190,6 +192,21 @@ Paste the token into the **Token** field and save. Sencho stores it encrypted at
The encryption boundary covers the pending update payload too: every pull caches the fetched compose and env content in the database so the change plan can reopen without a refetch, and that cached content is encrypted at rest in the same way as the token, since compose files routinely embed secrets via env interpolation.
### SSH with a deploy key
For private repositories over SSH, paste a read-only deploy key and confirm the server's host key before saving.
1. Generate an ed25519 key pair on a trusted machine (`ssh-keygen -t ed25519 -f deploy-key -N ""`).
2. Add the public key to the Git host as a deploy key with read access (GitHub: **Settings → Deploy keys** on the repository).
3. In Sencho, set **Authentication** to **SSH deploy key**, paste the private key, then click **Fetch host key fingerprint**. Sencho probes the host with `ssh-keyscan`, shows the SHA256 fingerprint, and stores the matching `known_hosts` line.
4. Save. Sencho stores the private key encrypted at rest and never returns it in API responses or UI.
Sencho verifies the host key on every fetch (`StrictHostKeyChecking=yes` against the stored line). If the server key changes, pulls fail with a host-key error until you review the new fingerprint and update trust deliberately.
Use an `ssh://` URL when the Git server listens on a nonstandard port (for example `ssh://git@git.example.com:2222/org/repo.git`). The familiar `git@host:org/repo.git` form assumes port 22.
Switching authentication back to **Public (no auth)** or to a token clears the stored deploy key and host-key trust.
## Local edits vs Git
Sencho classifies every managed path against the last applied generation and the live disk.
@@ -214,7 +231,7 @@ Pulls, applies, and create-from-git operations on the same stack are serialized
</Accordion>
<Accordion title="Authentication failed">
You supplied a token and the Git host rejected it outright. The token is missing, expired, or lacks read access. Generate a new token and replace the value in the **Token** field. Sencho returns this as a 400 form error rather than a 401, so an upstream auth failure does not sign you out of the dashboard.
The Git host rejected the credentials you supplied. For HTTPS, the token may be missing, expired, or lack read access: generate a new token and replace the value in the **Token** field. For SSH, the deploy key may be wrong or not registered on the host: paste the matching private key or add the public key on the Git host. Sencho returns this as a 400 form error rather than a 401, so an upstream auth failure does not sign you out of the dashboard.
</Accordion>
<Accordion title="Branch or tag not found">
@@ -281,14 +298,18 @@ Pulls, applies, and create-from-git operations on the same stack are serialized
Git Sources materializes the complete project, including recursive `include:` and `extends.file` dependencies, service env files, file-backed configs and secrets, and build contexts. If a referenced file is still missing, the pull refused it and the refusal message names the path and the reason (out-of-bound path, Git LFS pointer, submodule, symlink, or a size cap). Fix the declaration in the repository and pull again.
</Accordion>
<Accordion title="Only HTTPS is supported">
Git Sources fetch over HTTPS only. SSH clone URLs (`git@host:org/repo.git`) and custom protocols are rejected with a client-side validation error. Paste the `https://...` URL and use a Personal Access Token for authentication on private repositories.
<Accordion title="SSH host key verification failed">
The server's SSH host key no longer matches the fingerprint you accepted. This can mean a MITM risk or an intentional key rotation on the host. Click **Fetch host key fingerprint** again, compare the SHA256 value out of band with your operator, and save only if you intend to trust the new key.
</Accordion>
<Accordion title="Only unsupported URL schemes">
Git Sources accept `https://` URLs and SSH URLs (`git@host:org/repo.git` or `ssh://git@host:port/path`). Other schemes are rejected at save time.
</Accordion>
</AccordionGroup>
## Known limitations
- **HTTPS only.** SSH URLs and SSH keys are not supported. Use a Personal Access Token for private repos.
- **HTTPS and SSH.** HTTPS uses tokens; SSH uses deploy keys with strict host-key verification. Custom URL schemes are not supported.
- **No Git LFS.** Compose and env files stored via LFS are rejected. Commit plain files instead.
- **No submodules.** Submodule contents are not fetched. Inputs and build contexts that reference submodule contents are refused with an actionable message; a warning is shown when `.gitmodules` is present.
- **Refs, not arbitrary commits.** Sources follow a branch head, a tag, or a pinned commit SHA. Each pull resolves the configured ref to the exact commit it currently points at and pins that SHA, so apply always materializes the reviewed revision. A commit SHA not advertised by the Git host is refused.
+2 -2
View File
@@ -59,11 +59,11 @@ The **From Git** tab clones a public or private repository and treats its compos
Fill in:
- **Stack Name**: same naming rules as the Empty tab.
- **Repository URL**: HTTPS only. SSH URLs are not supported.
- **Repository URL**: HTTPS (`https://github.com/org/repo.git`) or SSH (`git@github.com:org/repo.git`, or `ssh://git@host:port/path` for nonstandard ports).
- **Branch**: defaults to `main`.
- **Compose files**: one or more paths inside the repository, merged in the listed order. Defaults to `compose.yaml`. Browse the repository tree to pick them, or type a path. An optional **Project directory** sets the base for relative paths.
- **Also sync sibling .env file**: when checked, a `.env` next to the primary compose file is pulled along with it.
- **Authentication**: pick **Public (no auth)** or **Personal Access Token** for private repos.
- **Authentication**: pick **Public (no auth)** for public repos, **Personal Access Token** for private HTTPS repos, or **SSH deploy key** for private SSH repos (with host-key fingerprint verification).
- **Apply behavior**: controls what happens on future webhook pulls:
- **Review only**: diffs surface in the sidebar; you apply manually.
- **Auto-write files**: pulls write to disk; you redeploy manually.