mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 14:18:02 +00:00
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:
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Request } from 'express';
|
||||
import { auditActorUsername } from '../helpers/auditActor';
|
||||
|
||||
describe('auditActorUsername', () => {
|
||||
it('prefers deployContext actor over machine identity username', () => {
|
||||
const req = {
|
||||
user: { username: 'node-proxy', role: 'admin' as const, userId: 0 },
|
||||
deployContext: { source: 'from_git' as const, actor: 'fleet-operator' },
|
||||
} satisfies Pick<Request, 'user' | 'deployContext'>;
|
||||
expect(auditActorUsername(req)).toBe('fleet-operator');
|
||||
});
|
||||
|
||||
it('falls back to session username when deployContext actor is absent', () => {
|
||||
const req = {
|
||||
user: { username: 'admin', role: 'admin' as const, userId: 1 },
|
||||
} satisfies Pick<Request, 'user' | 'deployContext'>;
|
||||
expect(auditActorUsername(req)).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns unknown when no actor or user is present', () => {
|
||||
const req = {} as Pick<Request, 'user' | 'deployContext'>;
|
||||
expect(auditActorUsername(req)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ function seedSource(stackName: string, composePaths: string[]): void {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
|
||||
@@ -358,7 +358,7 @@ describe('GET /api/stacks/statuses caching', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
|
||||
@@ -104,7 +104,7 @@ function seedGitSource(stackName: string): void {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -270,7 +270,7 @@ describe('promoteGeneration', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -780,7 +780,7 @@ describe('sweepManagedArea (crash recovery)', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -1286,7 +1286,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
|
||||
@@ -28,6 +28,10 @@ describe('gitSourceStatus', () => {
|
||||
expect(gitSourceStatus('UNSUPPORTED_REF')).toBe(400);
|
||||
});
|
||||
|
||||
it('maps SSH_HOST_KEY_FAILED to 400', () => {
|
||||
expect(gitSourceStatus('SSH_HOST_KEY_FAILED')).toBe(400);
|
||||
});
|
||||
|
||||
it('maps NETWORK_TIMEOUT to 504', () => {
|
||||
expect(gitSourceStatus('NETWORK_TIMEOUT')).toBe(504);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* handlers (the URL rules themselves live in services/gitops/repoIdentity.ts,
|
||||
* not in GitSourceService), specifically:
|
||||
* - HTTPS-only repo URL enforcement, including userinfo/query/fragment rejection
|
||||
* - SSH deploy-key auth_type, deploy_key length caps, ssh-host-key probe route
|
||||
* - Max-length caps on repo_url / branch / compose_path / env_path / token
|
||||
* - Stack-existence 404 guard on PUT
|
||||
* - 400 on invalid stack names
|
||||
@@ -20,12 +21,14 @@ import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { REF_MAX_LEN } from '../services/git/nativeGitTransport';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { CryptoService } from '../services/CryptoService';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
import { GitSourceService, GitSourceError } from '../services/GitSourceService';
|
||||
import { GitOpsStore } from '../services/gitops/store';
|
||||
import { GitOpsTransitions } from '../services/gitops/transitions';
|
||||
import { insertHistory } from '../services/gitops/history';
|
||||
import type { GitOpsApplicationRow } from '../services/gitops/types';
|
||||
import { PROXY_DEPLOY_ACTOR_HEADER, PROXY_DEPLOY_SOURCE_HEADER } from '../services/license-headers';
|
||||
|
||||
/** A minimal live Direct application row for GitOps read-path fixtures. */
|
||||
function directApplicationFixture(id: string, stackName: string): GitOpsApplicationRow {
|
||||
@@ -110,7 +113,7 @@ function seedGitSource(stackName: string): void {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -1158,7 +1161,7 @@ describe('stack_git_sources manifest cache columns', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -1723,3 +1726,252 @@ describe('GitOps additive fields and history routes', () => {
|
||||
expect(res.body.nextCursor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/git-sources/ssh-host-key', () => {
|
||||
it('rejects missing repo_url with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/repo_url/i);
|
||||
});
|
||||
|
||||
it('rejects unsupported URL schemes before probing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ repo_url: 'http://github.com/example/repo.git' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/https:\/\/ URL or an SSH URL/i);
|
||||
});
|
||||
|
||||
it('rejects HTTPS URLs that are storable but not SSH probe targets', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ repo_url: 'https://github.com/example/repo.git' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/SSH repository URL/i);
|
||||
});
|
||||
|
||||
it('returns scanned host keys for an SSH repository URL', async () => {
|
||||
const scanHostKeys = vi.spyOn(
|
||||
await import('../services/git/sshTrust'),
|
||||
'scanHostKeys',
|
||||
).mockResolvedValue([
|
||||
{
|
||||
keyType: 'ssh-ed25519',
|
||||
fingerprint: 'SHA256:fixtureFingerprint',
|
||||
line: '|1|fixture|fixture ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixtureKeyMaterial',
|
||||
},
|
||||
]);
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ repo_url: 'git@github.com:example/repo.git' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.host).toBe('github.com');
|
||||
expect(res.body.port).toBe(22);
|
||||
expect(res.body.keys).toHaveLength(1);
|
||||
expect(res.body.keys[0].fingerprint).toBe('SHA256:fixtureFingerprint');
|
||||
scanHostKeys.mockRestore();
|
||||
});
|
||||
|
||||
it('allows host-key probing for an existing stack when stack_name is supplied', async () => {
|
||||
const scanHostKeys = vi.spyOn(
|
||||
await import('../services/git/sshTrust'),
|
||||
'scanHostKeys',
|
||||
).mockResolvedValue([
|
||||
{
|
||||
keyType: 'ssh-ed25519',
|
||||
fingerprint: 'SHA256:fixtureFingerprint',
|
||||
line: '|1|fixture|fixture ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixtureKeyMaterial',
|
||||
},
|
||||
]);
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ repo_url: 'git@github.com:example/repo.git', stack_name: 'existing-stack' });
|
||||
expect(res.status).toBe(200);
|
||||
scanHostKeys.mockRestore();
|
||||
});
|
||||
|
||||
it('returns 403 when the caller lacks stack:create and does not name a stack', async () => {
|
||||
const deployerName = 'ssh-host-key-deployer';
|
||||
const db = DatabaseService.getInstance();
|
||||
if (!db.getUserByUsername(deployerName)) {
|
||||
db.addUser({ username: deployerName, password_hash: 'test', role: 'deployer' });
|
||||
}
|
||||
const deployer = db.getUserByUsername(deployerName);
|
||||
if (!deployer) throw new Error('expected deployer user');
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${jwt.sign({ username: deployer.username, role: deployer.role, userId: deployer.id }, TEST_JWT_SECRET, { expiresIn: '1m' })}`)
|
||||
.send({ repo_url: 'git@github.com:example/repo.git' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 500 when host-key probing throws an unexpected error', async () => {
|
||||
const scanHostKeys = vi.spyOn(
|
||||
await import('../services/git/sshTrust'),
|
||||
'scanHostKeys',
|
||||
).mockRejectedValue(new Error('ssh-keyscan failed'));
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ repo_url: 'ssh://git@git.example.com:2222/org/repo.git' });
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toMatch(/Git source operation failed/i);
|
||||
scanHostKeys.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSH deploy-key route validation', () => {
|
||||
const sshRepoUrl = 'git@github.com:example/deploy-repo.git';
|
||||
const deployKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nfixture\n-----END OPENSSH PRIVATE KEY-----\n';
|
||||
const knownHosts = '|1|fixture|fixture ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixtureKeyMaterial';
|
||||
|
||||
it('rejects an unknown auth_type on PUT', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/existing-stack/git-source')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
repo_url: sshRepoUrl,
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
auth_type: 'oauth',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/auth_type/i);
|
||||
});
|
||||
|
||||
it('rejects an oversized deploy_key on PUT', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/existing-stack/git-source')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
repo_url: sshRepoUrl,
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
auth_type: 'deploy_key',
|
||||
deploy_key: 'k'.repeat(16385),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/deploy_key is too long/i);
|
||||
});
|
||||
|
||||
it('forwards deploy_key fields to upsert for SSH repository URLs', async () => {
|
||||
const upsertSpy = vi.spyOn(GitSourceService.getInstance(), 'upsert')
|
||||
.mockResolvedValue({} as Awaited<ReturnType<typeof GitSourceService.prototype.upsert>>);
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/existing-stack/git-source')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
repo_url: sshRepoUrl,
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
auth_type: 'deploy_key',
|
||||
deploy_key: deployKey,
|
||||
ssh_known_hosts_entry: knownHosts,
|
||||
ssh_host_key_fingerprint: 'SHA256:fixtureFingerprint',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(upsertSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
repoUrl: sshRepoUrl,
|
||||
authType: 'deploy_key',
|
||||
deployKey,
|
||||
sshKnownHostsEntry: knownHosts,
|
||||
sshHostKeyFingerprint: 'SHA256:fixtureFingerprint',
|
||||
}));
|
||||
upsertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('forwards proxied deploy actor into upsert auditContext', async () => {
|
||||
const upsertSpy = vi.spyOn(GitSourceService.getInstance(), 'upsert')
|
||||
.mockResolvedValue({} as Awaited<ReturnType<typeof GitSourceService.prototype.upsert>>);
|
||||
const nodeToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/existing-stack/git-source')
|
||||
.set('Authorization', `Bearer ${nodeToken}`)
|
||||
.set(PROXY_DEPLOY_ACTOR_HEADER, 'fleet-operator')
|
||||
.set(PROXY_DEPLOY_SOURCE_HEADER, 'from_git')
|
||||
.send({
|
||||
repo_url: sshRepoUrl,
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
auth_type: 'deploy_key',
|
||||
deploy_key: deployKey,
|
||||
ssh_known_hosts_entry: knownHosts,
|
||||
ssh_host_key_fingerprint: 'SHA256:fixtureFingerprint',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(upsertSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
auditContext: expect.objectContaining({ username: 'fleet-operator' }),
|
||||
}));
|
||||
upsertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('forwards sshAuth to listRepoTree when browsing with deploy_key auth', async () => {
|
||||
const listRepoTree = vi.spyOn(GitSourceService.getInstance(), 'listRepoTree')
|
||||
.mockResolvedValue({ files: [], truncated: false, commitSha: 'a'.repeat(40), warnings: [] });
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/browse')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
repo_url: sshRepoUrl,
|
||||
branch: 'main',
|
||||
auth_type: 'deploy_key',
|
||||
deploy_key: deployKey,
|
||||
ssh_known_hosts_entry: knownHosts,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(listRepoTree).toHaveBeenCalledWith(expect.objectContaining({
|
||||
repoUrl: sshRepoUrl,
|
||||
sshAuth: { privateKey: deployKey, knownHostsEntry: knownHosts },
|
||||
}));
|
||||
listRepoTree.mockRestore();
|
||||
});
|
||||
|
||||
it('GET exposes deploy-key metadata without returning the private key', async () => {
|
||||
const stackName = 'ssh-deploy-get';
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
fs.mkdirSync(path.join(composeDir, stackName), { recursive: true });
|
||||
fs.writeFileSync(path.join(composeDir, stackName, 'compose.yaml'), 'services:\n x:\n image: nginx\n');
|
||||
DatabaseService.getInstance().upsertGitSource({
|
||||
stack_name: stackName,
|
||||
repo_url: sshRepoUrl,
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
compose_paths: ['compose.yaml'],
|
||||
context_dir: null,
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'deploy_key',
|
||||
encrypted_token: null,
|
||||
encrypted_deploy_key: CryptoService.getInstance().encrypt(deployKey),
|
||||
ssh_known_hosts_entry: knownHosts,
|
||||
ssh_host_key_fingerprint: 'SHA256:fixtureFingerprint',
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
last_applied_content_hash: null,
|
||||
pending_commit_sha: null,
|
||||
pending_compose_content: null,
|
||||
pending_env_content: null,
|
||||
pending_fetched_at: null,
|
||||
last_debounce_at: null,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${stackName}/git-source`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.auth_type).toBe('deploy_key');
|
||||
expect(res.body.has_deploy_key).toBe(true);
|
||||
expect(res.body.ssh_host_key_fingerprint).toBe('SHA256:fixtureFingerprint');
|
||||
const serialized = JSON.stringify(res.body);
|
||||
expect(serialized).not.toContain(deployKey);
|
||||
expect(serialized).not.toContain('encrypted_deploy_key');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -475,6 +475,164 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
|
||||
expect(row?.auth_type).toBe('none');
|
||||
});
|
||||
|
||||
it('derives SSH host key fingerprint server-side on deploy_key upsert', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
const keyBase64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q=';
|
||||
const knownHosts = `127.0.0.1 ssh-ed25519 ${keyBase64}`;
|
||||
const derived = `SHA256:${crypto.createHash('sha256').update(Buffer.from(keyBase64, 'base64')).digest('base64').replace(/=+$/, '')}`;
|
||||
await svc.upsert({
|
||||
stackName: 'ssh-trust-stack',
|
||||
repoUrl: 'git@github.com:example/repo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'deploy_key',
|
||||
deployKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nfixture\n-----END OPENSSH PRIVATE KEY-----\n',
|
||||
sshKnownHostsEntry: knownHosts,
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
const row = DatabaseService.getInstance().getGitSource('ssh-trust-stack');
|
||||
expect(row?.ssh_host_key_fingerprint).toBe(derived);
|
||||
});
|
||||
|
||||
it('rejects a client fingerprint that does not match the trusted host key entry', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
const keyBase64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q=';
|
||||
const knownHosts = `127.0.0.1 ssh-ed25519 ${keyBase64}`;
|
||||
await expect(svc.upsert({
|
||||
stackName: 'ssh-trust-mismatch',
|
||||
repoUrl: 'git@github.com:example/repo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'deploy_key',
|
||||
deployKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nfixture\n-----END OPENSSH PRIVATE KEY-----\n',
|
||||
sshKnownHostsEntry: knownHosts,
|
||||
sshHostKeyFingerprint: 'SHA256:wrongFingerprintValue',
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
})).rejects.toMatchObject({ code: 'GIT_ERROR' });
|
||||
});
|
||||
|
||||
it('records SSH trust audit with the supplied actor and no key material', async () => {
|
||||
mockSuccessfulClone();
|
||||
const insertSpy = vi.spyOn(DatabaseService.getInstance(), 'insertAuditLog');
|
||||
const svc = GitSourceService.getInstance();
|
||||
const deployKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nfixture-audit\n-----END OPENSSH PRIVATE KEY-----\n';
|
||||
const keyBase64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q=';
|
||||
const knownHosts = `127.0.0.1 ssh-ed25519 ${keyBase64}`;
|
||||
const derived = `SHA256:${crypto.createHash('sha256').update(Buffer.from(keyBase64, 'base64')).digest('base64').replace(/=+$/, '')}`;
|
||||
await svc.upsert({
|
||||
stackName: 'ssh-trust-audit',
|
||||
repoUrl: 'git@github.com:example/repo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'deploy_key',
|
||||
deployKey,
|
||||
sshKnownHostsEntry: knownHosts,
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
auditContext: {
|
||||
username: 'fleet-operator',
|
||||
method: 'PUT',
|
||||
path: '/api/stacks/ssh-trust-audit/git-source',
|
||||
ipAddress: '127.0.0.1',
|
||||
},
|
||||
});
|
||||
expect(insertSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
username: 'fleet-operator',
|
||||
summary: expect.stringContaining('git_source.ssh_trust_created'),
|
||||
}));
|
||||
const entry = insertSpy.mock.calls[0]?.[0];
|
||||
expect(entry?.summary).toContain(derived);
|
||||
expect(entry?.summary).not.toContain(deployKey);
|
||||
expect(entry?.summary).not.toContain(knownHosts);
|
||||
insertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('records SSH trust rotation when replacing known_hosts without resending the deploy key', async () => {
|
||||
mockSuccessfulClone();
|
||||
const { CryptoService } = await import('../services/CryptoService');
|
||||
const insertSpy = vi.spyOn(DatabaseService.getInstance(), 'insertAuditLog');
|
||||
const svc = GitSourceService.getInstance();
|
||||
const deployKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nrotation-fixture\n-----END OPENSSH PRIVATE KEY-----\n';
|
||||
const keyBase64A = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q=';
|
||||
const keyBase64B = 'AAAAC3NzaC1lZDI1NTE5AAAAIHRvdGF0ZWtleWZpeHR1cmVtYXRlcmlhbA==';
|
||||
const knownHostsA = `127.0.0.1 ssh-ed25519 ${keyBase64A}`;
|
||||
const knownHostsB = `github.com ssh-ed25519 ${keyBase64B}`;
|
||||
const derivedB = `SHA256:${crypto.createHash('sha256').update(Buffer.from(keyBase64B, 'base64')).digest('base64').replace(/=+$/, '')}`;
|
||||
const auditContext = {
|
||||
username: 'trust-rotator',
|
||||
method: 'PUT',
|
||||
path: '/api/stacks/ssh-trust-rotate/git-source',
|
||||
ipAddress: '127.0.0.1',
|
||||
};
|
||||
const baseUpsert = {
|
||||
repoUrl: 'git@github.com:example/repo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'deploy_key' as const,
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
auditContext,
|
||||
};
|
||||
|
||||
await svc.upsert({
|
||||
...baseUpsert,
|
||||
stackName: 'ssh-trust-rotate',
|
||||
deployKey,
|
||||
sshKnownHostsEntry: knownHostsA,
|
||||
});
|
||||
|
||||
insertSpy.mockClear();
|
||||
|
||||
await svc.upsert({
|
||||
...baseUpsert,
|
||||
stackName: 'ssh-trust-rotate',
|
||||
sshKnownHostsEntry: knownHostsB,
|
||||
});
|
||||
|
||||
const row = DatabaseService.getInstance().getGitSource('ssh-trust-rotate');
|
||||
expect(row?.ssh_host_key_fingerprint).toBe(derivedB);
|
||||
expect(row?.ssh_known_hosts_entry).toBe(knownHostsB);
|
||||
expect(CryptoService.getInstance().decrypt(row!.encrypted_deploy_key!)).toBe(deployKey);
|
||||
|
||||
expect(insertSpy).toHaveBeenCalledTimes(1);
|
||||
expect(insertSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
username: 'trust-rotator',
|
||||
summary: expect.stringContaining('git_source.ssh_trust_rotated'),
|
||||
}));
|
||||
const rotatedEntry = insertSpy.mock.calls[0]?.[0];
|
||||
expect(rotatedEntry?.summary).toContain(derivedB);
|
||||
expect(rotatedEntry?.summary).not.toContain(deployKey);
|
||||
expect(rotatedEntry?.summary).not.toContain(knownHostsB);
|
||||
expect(JSON.stringify(insertSpy.mock.calls)).not.toContain('git_source.ssh_trust_created');
|
||||
|
||||
insertSpy.mockClear();
|
||||
|
||||
await svc.upsert({
|
||||
...baseUpsert,
|
||||
stackName: 'ssh-trust-rotate',
|
||||
sshKnownHostsEntry: knownHostsB,
|
||||
});
|
||||
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
insertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects auto_deploy_on_apply without auto_apply_on_webhook', async () => {
|
||||
const svc = GitSourceService.getInstance();
|
||||
await expect(svc.upsert({
|
||||
@@ -2456,7 +2614,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -2511,7 +2669,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -2651,7 +2809,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -2682,7 +2840,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -2715,7 +2873,7 @@ describe('GitSourceService legacy pending apply (migration path)', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -2993,7 +3151,7 @@ describe('GitSourceService classified plan fingerprint', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* End-to-end SSH deploy-key transport with strict known_hosts verification.
|
||||
*
|
||||
* Spins up a local openssh-server with a forced `git-upload-pack` command and
|
||||
* drives the real `nativeGitTransport` through GIT_SSH_COMMAND (no mocks).
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { promises as fs, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { classifyGitFailure, isTransportFailure } from '../services/git/errors';
|
||||
import { nativeGitTransport } from '../services/git/nativeGitTransport';
|
||||
import { scanHostKeys } from '../services/git/sshTrust';
|
||||
|
||||
function gitAvailable(): boolean {
|
||||
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
|
||||
function sshdAvailable(): boolean {
|
||||
return spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
|
||||
const FILE_CONTENT = 'hello from the ssh fixture repo\n';
|
||||
|
||||
function runGit(cwd: string, args: string[]): string {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
function generateKeyPair(dir: string, name: string): { privatePath: string; publicPath: string; privatePem: string; publicLine: string } {
|
||||
const privatePath = path.join(dir, name);
|
||||
const publicPath = `${privatePath}.pub`;
|
||||
const gen = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', privatePath, '-q'], { encoding: 'utf8' });
|
||||
if (gen.status !== 0) throw new Error(`ssh-keygen failed: ${gen.stderr}`);
|
||||
const privatePem = readFileSync(privatePath, 'utf8');
|
||||
const publicLine = readFileSync(publicPath, 'utf8').trim();
|
||||
return { privatePath, publicPath, privatePem, publicLine };
|
||||
}
|
||||
|
||||
function buildBareRepo(): { bareDir: string; mainSha: string; scratchDirs: string[] } {
|
||||
const srcDir = mkdtempSync(path.join(os.tmpdir(), 'sencho-ssh-src-'));
|
||||
writeFileSync(path.join(srcDir, 'hello.txt'), FILE_CONTENT);
|
||||
runGit(srcDir, ['init', '-b', 'main']);
|
||||
runGit(srcDir, ['config', 'user.email', 'integration-test@sencho.test']);
|
||||
runGit(srcDir, ['config', 'user.name', 'Sencho SSH Integration Test']);
|
||||
runGit(srcDir, ['add', '-A']);
|
||||
runGit(srcDir, ['-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture']);
|
||||
const mainSha = runGit(srcDir, ['rev-parse', 'HEAD']);
|
||||
|
||||
const bareRoot = mkdtempSync(path.join(os.tmpdir(), 'sencho-ssh-bare-'));
|
||||
const bareDir = path.join(bareRoot, 'repo.git');
|
||||
const clone = spawnSync('git', ['clone', '--bare', '--quiet', srcDir, bareDir], { encoding: 'utf8' });
|
||||
if (clone.status !== 0) throw new Error(`git clone --bare failed: ${clone.stderr}`);
|
||||
return { bareDir, mainSha, scratchDirs: [srcDir, bareRoot] };
|
||||
}
|
||||
|
||||
async function waitForPort(host: string, port: number, timeoutMs = 10_000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect({ host, port }, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
}
|
||||
throw new Error(`port ${port} did not open within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
interface SshGitFixture {
|
||||
port: number;
|
||||
bareDir: string;
|
||||
mainSha: string;
|
||||
repoUrlScp: string;
|
||||
repoUrlSsh: string;
|
||||
deployPrivateKey: string;
|
||||
knownHostsEntry: string;
|
||||
wrongPrivateKey: string;
|
||||
close: () => void;
|
||||
scratchDirs: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_SSH_PORT = 22;
|
||||
|
||||
async function loopbackPortHasListeners(port: number): Promise<boolean> {
|
||||
const ss = spawnSync('ss', ['-ltn', `sport = :${port}`], { encoding: 'utf8' });
|
||||
if (ss.status === 0) {
|
||||
const lines = ss.stdout.trim().split(/\r?\n/).slice(1);
|
||||
if (lines.some((line) => line.includes('LISTEN'))) return true;
|
||||
}
|
||||
const lsof = spawnSync('lsof', ['-tiTCP:' + String(port), '-sTCP:LISTEN'], { encoding: 'utf8' });
|
||||
return lsof.status === 0 && lsof.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
function keyMaterialFromPublicLine(publicLine: string): string | null {
|
||||
const parts = publicLine.trim().split(/\s+/);
|
||||
return parts.length >= 2 ? parts[1] : null;
|
||||
}
|
||||
|
||||
function assertFixtureHostKey(port: number, hostKeyPublicLine: string, scanned: Awaited<ReturnType<typeof scanHostKeys>>): void {
|
||||
const expectedMaterial = keyMaterialFromPublicLine(hostKeyPublicLine);
|
||||
if (!expectedMaterial) {
|
||||
throw new Error('fixture host key is malformed');
|
||||
}
|
||||
const matched = scanned.some((key) => key.line.includes(expectedMaterial));
|
||||
if (!matched) {
|
||||
throw new Error(`Port ${port} is not served by the test sshd fixture; another SSH listener may be bound on loopback.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function startSshGitServer(bareDir: string, port: number): Promise<Omit<SshGitFixture, 'repoUrlScp' | 'repoUrlSsh' | 'mainSha'>> {
|
||||
const sshRoot = mkdtempSync(path.join(os.tmpdir(), 'sencho-ssh-sshd-'));
|
||||
const deploy = generateKeyPair(sshRoot, 'deploy');
|
||||
const wrong = generateKeyPair(sshRoot, 'wrong');
|
||||
const hostKey = generateKeyPair(sshRoot, 'host');
|
||||
|
||||
const authorizedKeysPath = path.join(sshRoot, 'authorized_keys');
|
||||
const forced = `command="git-upload-pack '${bareDir}'",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ${deploy.publicLine}`;
|
||||
writeFileSync(authorizedKeysPath, `${forced}\n`, { mode: 0o600 });
|
||||
|
||||
const configPath = path.join(sshRoot, 'sshd_config');
|
||||
const pidPath = path.join(sshRoot, 'sshd.pid');
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
`Port ${port}`,
|
||||
'ListenAddress 127.0.0.1',
|
||||
`HostKey ${hostKey.privatePath}`,
|
||||
`AuthorizedKeysFile ${authorizedKeysPath}`,
|
||||
`PidFile ${pidPath}`,
|
||||
'UsePAM no',
|
||||
'PasswordAuthentication no',
|
||||
'PubkeyAuthentication yes',
|
||||
'X11Forwarding no',
|
||||
'StrictModes no',
|
||||
'LogLevel ERROR',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const child = spawn('/usr/sbin/sshd', ['-D', '-f', configPath, '-e'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stderr = '';
|
||||
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
|
||||
|
||||
try {
|
||||
await waitForPort('127.0.0.1', port);
|
||||
} catch (e) {
|
||||
child.kill('SIGKILL');
|
||||
try {
|
||||
rmSync(sshRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
throw new Error(`sshd did not come up on port ${port}: ${stderr.trim() || '(no stderr)'}`, { cause: e });
|
||||
}
|
||||
|
||||
const scanned = await scanHostKeys('127.0.0.1', port);
|
||||
assertFixtureHostKey(port, hostKey.publicLine, scanned);
|
||||
const knownHostsEntry = scanned.map((k) => k.line).join('\n');
|
||||
|
||||
return {
|
||||
port,
|
||||
bareDir,
|
||||
deployPrivateKey: deploy.privatePem,
|
||||
knownHostsEntry,
|
||||
wrongPrivateKey: wrong.privatePem,
|
||||
close: () => {
|
||||
child.kill('SIGTERM');
|
||||
},
|
||||
scratchDirs: [sshRoot],
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key native git transport (real git, real sshd, strict host keys)', () => {
|
||||
let fixture: SshGitFixture;
|
||||
const workspaces: string[] = [];
|
||||
let scratchDirs: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const repo = buildBareRepo();
|
||||
scratchDirs = repo.scratchDirs;
|
||||
const nonstandardPort = 22222;
|
||||
const sshUser = os.userInfo().username;
|
||||
const sshd = await startSshGitServer(repo.bareDir, nonstandardPort);
|
||||
fixture = {
|
||||
...sshd,
|
||||
mainSha: repo.mainSha,
|
||||
repoUrlScp: `${sshUser}@127.0.0.1:${nonstandardPort}:${repo.bareDir}`,
|
||||
repoUrlSsh: `ssh://${sshUser}@127.0.0.1:${nonstandardPort}${repo.bareDir}`,
|
||||
scratchDirs: [...scratchDirs, ...sshd.scratchDirs],
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
fixture?.close?.();
|
||||
if (fixture?.scratchDirs) {
|
||||
await Promise.all(fixture.scratchDirs.map((d) => fs.rm(d, { recursive: true, force: true })));
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(workspaces.splice(0).map((w) => fs.rm(w, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function makeWorkspace(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-ssh-ws-'));
|
||||
workspaces.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
it('resolves and fetches over SSH with a deploy key and trusted host key', async () => {
|
||||
const workspaceRoot = await makeWorkspace();
|
||||
const sshAuth = { privateKey: fixture.deployPrivateKey, knownHostsEntry: fixture.knownHostsEntry };
|
||||
const resolved = await nativeGitTransport.resolveRef({
|
||||
repoUrl: fixture.repoUrlSsh,
|
||||
ref: 'main',
|
||||
sshAuth,
|
||||
timeoutMs: 20_000,
|
||||
workspaceRoot,
|
||||
});
|
||||
expect(resolved.commitSha).toBe(fixture.mainSha);
|
||||
|
||||
const fetchWorkspace = await makeWorkspace();
|
||||
const fetched = await nativeGitTransport.fetchAtCommit({
|
||||
repoUrl: fixture.repoUrlSsh,
|
||||
ref: 'main',
|
||||
sshAuth,
|
||||
refKind: 'branch',
|
||||
commitSha: resolved.commitSha,
|
||||
timeoutMs: 20_000,
|
||||
workspaceRoot: fetchWorkspace,
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
expect(fetched.commitSha).toBe(fixture.mainSha);
|
||||
const content = await fs.readFile(path.join(fetched.dir, 'hello.txt'), 'utf8');
|
||||
expect(content).toBe(FILE_CONTENT);
|
||||
});
|
||||
|
||||
it('resolves over ssh:// with a nonstandard port', async () => {
|
||||
const workspaceRoot = await makeWorkspace();
|
||||
const sshAuth = { privateKey: fixture.deployPrivateKey, knownHostsEntry: fixture.knownHostsEntry };
|
||||
const resolved = await nativeGitTransport.resolveRef({
|
||||
repoUrl: fixture.repoUrlSsh,
|
||||
ref: 'main',
|
||||
sshAuth,
|
||||
timeoutMs: 20_000,
|
||||
workspaceRoot,
|
||||
});
|
||||
expect(resolved.commitSha).toBe(fixture.mainSha);
|
||||
});
|
||||
|
||||
it('classifies a wrong deploy key as AUTH_FAILED', async () => {
|
||||
const workspaceRoot = await makeWorkspace();
|
||||
const failure = await nativeGitTransport
|
||||
.resolveRef({
|
||||
repoUrl: fixture.repoUrlSsh,
|
||||
ref: 'main',
|
||||
sshAuth: { privateKey: fixture.wrongPrivateKey, knownHostsEntry: fixture.knownHostsEntry },
|
||||
timeoutMs: 20_000,
|
||||
workspaceRoot,
|
||||
})
|
||||
.then(() => null, (e: unknown) => e);
|
||||
|
||||
expect(isTransportFailure(failure)).toBe(true);
|
||||
if (!isTransportFailure(failure)) throw new Error('unreachable');
|
||||
expect(classifyGitFailure(failure).code).toBe('AUTH_FAILED');
|
||||
});
|
||||
|
||||
it('classifies a mismatched known_hosts entry as SSH_HOST_KEY_FAILED', async () => {
|
||||
const workspaceRoot = await makeWorkspace();
|
||||
const bogusLine = '|1|abcdef1234567890abcdef1234567890|abcdef1234567890abcdef1234567890 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIInvalidKeyMaterialForTestOnly';
|
||||
const failure = await nativeGitTransport
|
||||
.resolveRef({
|
||||
repoUrl: fixture.repoUrlSsh,
|
||||
ref: 'main',
|
||||
sshAuth: { privateKey: fixture.deployPrivateKey, knownHostsEntry: bogusLine },
|
||||
timeoutMs: 20_000,
|
||||
workspaceRoot,
|
||||
})
|
||||
.then(() => null, (e: unknown) => e);
|
||||
|
||||
expect(isTransportFailure(failure)).toBe(true);
|
||||
if (!isTransportFailure(failure)) throw new Error('unreachable');
|
||||
expect(classifyGitFailure(failure).code).toBe('SSH_HOST_KEY_FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key transport on the default SSH port', () => {
|
||||
let fixture: SshGitFixture;
|
||||
const workspaces: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
if (await loopbackPortHasListeners(DEFAULT_SSH_PORT)) {
|
||||
throw new Error(`Port ${DEFAULT_SSH_PORT} is already in use; the default-port SSH integration requires loopback port ${DEFAULT_SSH_PORT} to be free.`);
|
||||
}
|
||||
const repo = buildBareRepo();
|
||||
const sshUser = os.userInfo().username;
|
||||
const sshd = await startSshGitServer(repo.bareDir, DEFAULT_SSH_PORT);
|
||||
fixture = {
|
||||
...sshd,
|
||||
mainSha: repo.mainSha,
|
||||
repoUrlScp: `${sshUser}@127.0.0.1:${repo.bareDir}`,
|
||||
repoUrlSsh: `ssh://${sshUser}@127.0.0.1${repo.bareDir}`,
|
||||
scratchDirs: [...repo.scratchDirs, ...sshd.scratchDirs],
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
fixture?.close?.();
|
||||
if (fixture?.scratchDirs) {
|
||||
await Promise.all(fixture.scratchDirs.map((d) => fs.rm(d, { recursive: true, force: true })));
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(workspaces.splice(0).map((w) => fs.rm(w, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function makeWorkspace(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-ssh-ws-'));
|
||||
workspaces.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
it('resolves over scp-style URL on the default SSH port', async () => {
|
||||
const workspaceRoot = await makeWorkspace();
|
||||
const sshAuth = { privateKey: fixture.deployPrivateKey, knownHostsEntry: fixture.knownHostsEntry };
|
||||
const resolved = await nativeGitTransport.resolveRef({
|
||||
repoUrl: fixture.repoUrlScp,
|
||||
ref: 'main',
|
||||
sshAuth,
|
||||
timeoutMs: 20_000,
|
||||
workspaceRoot,
|
||||
});
|
||||
expect(resolved.commitSha).toBe(fixture.mainSha);
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,9 @@ import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { GitOpsStore } from '../services/gitops/store';
|
||||
import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions';
|
||||
import { assertCreatesSettled, resolveInterruptedCreates } from '../services/gitops/createRecovery';
|
||||
import { CryptoService } from '../services/CryptoService';
|
||||
import { candidateRelPathForSha, CREATE_STAGING_MARKER_FILENAME } from '../services/gitops/createStagingMarker';
|
||||
import { assertCreatesSettled, resolveInterruptedCreates } from '../services/gitops/createRecovery';
|
||||
import { stackManagedRoot } from '../services/gitops/directApplication';
|
||||
import type {
|
||||
GitOpsApplicationRow,
|
||||
@@ -123,6 +124,33 @@ describe('gitops interrupted create recovery', () => {
|
||||
expect(store.getCreateCheckpoint('app-finish')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores deploy-key credentials when finishing a manifest_committed create', async () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const db = DatabaseService.getInstance();
|
||||
const deployKey = '-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n';
|
||||
const knownHosts = '127.0.0.1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGitRecoveryDeployKeyTestOnly';
|
||||
const fingerprint = 'SHA256:gitops-recovery-deploy-key-test';
|
||||
const encryptedDeployKey = CryptoService.getInstance().encrypt(deployKey);
|
||||
seedCreate('app-ssh-finish', 'ssh-finish-web', 'manifest_committed', {
|
||||
authType: 'deploy_key',
|
||||
encryptedDeployKey,
|
||||
sshKnownHostsEntry: knownHosts,
|
||||
sshHostKeyFingerprint: fingerprint,
|
||||
});
|
||||
fs.mkdirSync(path.join(process.env.COMPOSE_DIR!, 'ssh-finish-web'), { recursive: true });
|
||||
|
||||
const settled = await resolveInterruptedCreates();
|
||||
|
||||
expect(settled[0].outcome).toBe('completed');
|
||||
const source = db.getGitSource('ssh-finish-web');
|
||||
expect(source?.auth_type).toBe('deploy_key');
|
||||
expect(source?.encrypted_deploy_key).toBe(encryptedDeployKey);
|
||||
expect(CryptoService.getInstance().decrypt(source!.encrypted_deploy_key!)).toBe(deployKey);
|
||||
expect(source?.ssh_known_hosts_entry).toBe(knownHosts);
|
||||
expect(source?.ssh_host_key_fingerprint).toBe(fingerprint);
|
||||
expect(store.getCreateCheckpoint('app-ssh-finish')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the checkpoint of a create that already reached its boundary', async () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
seedCreate('app-done', 'done-web', 'pointers_committed');
|
||||
@@ -153,6 +181,9 @@ describe('gitops interrupted create recovery', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_deploy_key: null,
|
||||
ssh_known_hosts_entry: null,
|
||||
ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: SHA,
|
||||
@@ -315,7 +346,13 @@ function seedCreate(
|
||||
applicationId: string,
|
||||
stackName: string,
|
||||
phase: GitOpsCreateCheckpointRow['phase'],
|
||||
options: { createdManagedRoot?: number } = {},
|
||||
options: {
|
||||
createdManagedRoot?: number;
|
||||
authType?: string;
|
||||
encryptedDeployKey?: string | null;
|
||||
sshKnownHostsEntry?: string | null;
|
||||
sshHostKeyFingerprint?: string | null;
|
||||
} = {},
|
||||
): void {
|
||||
const store = GitOpsStore.getInstance();
|
||||
const generationId = `gen-${applicationId}`;
|
||||
@@ -337,8 +374,11 @@ function seedCreate(
|
||||
context_dir: null,
|
||||
sync_env: 0,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
auth_type: options.authType ?? 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_deploy_key: options.encryptedDeployKey ?? null,
|
||||
ssh_known_hosts_entry: options.sshKnownHostsEntry ?? null,
|
||||
ssh_host_key_fingerprint: options.sshHostKeyFingerprint ?? null,
|
||||
auto_apply_on_webhook: 0,
|
||||
auto_deploy_on_apply: 0,
|
||||
commit_sha: SHA,
|
||||
|
||||
@@ -652,6 +652,9 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_deploy_key: null,
|
||||
ssh_known_hosts_entry: null,
|
||||
ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: 0,
|
||||
auto_deploy_on_apply: 0,
|
||||
commit_sha: SHA,
|
||||
|
||||
@@ -666,7 +666,7 @@ describe('Direct Git producers drive the revision state', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: 'eeeeeee5',
|
||||
|
||||
@@ -112,6 +112,9 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_deploy_key: null,
|
||||
ssh_known_hosts_entry: null,
|
||||
ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: 0,
|
||||
auto_deploy_on_apply: 0,
|
||||
commit_sha: SHA,
|
||||
|
||||
@@ -357,7 +357,7 @@ function seedStack(
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: options.lastApplied,
|
||||
|
||||
@@ -369,6 +369,41 @@ describe('remote proxy node-wide image refresh elevation gate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote proxy ssh-host-key scoped-evidence gate', () => {
|
||||
it('forwards scoped stack:edit evidence for scoped deployer on POST /git-sources/ssh-host-key', async () => {
|
||||
grantScopedStackEdit(deployerId, grantedNodeId, 'web');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||
.set('x-node-id', String(grantedNodeId))
|
||||
.send({ repo_url: 'git@github.com:example/repo.git', stack_name: 'web' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const hop = grantedHops.find((h) => h.url?.includes('/api/git-sources/ssh-host-key'));
|
||||
expect(hop).toBeDefined();
|
||||
expect(hop!.stackNameHeader).toBe('web');
|
||||
expect(hop!.stackActionsHeader).toContain('stack:edit');
|
||||
|
||||
clearAssignments(deployerId);
|
||||
});
|
||||
|
||||
it('denies scoped deployer probing host keys for an unrelated stack', async () => {
|
||||
grantScopedStackEdit(deployerId, grantedNodeId, 'web');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${deployerBearer}`)
|
||||
.set('x-node-id', String(grantedNodeId))
|
||||
.send({ repo_url: 'git@github.com:example/repo.git', stack_name: 'other-stack' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
|
||||
clearAssignments(deployerId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyStackApiPath per-stack image refresh', () => {
|
||||
it('classifies POST /image-updates/refresh/web as named-stack with stack:deploy', () => {
|
||||
const result = classifyStackApiPath('POST', '/image-updates/refresh/web');
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('captured invocation on recovery Compose args', () => {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: 'abc',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildSshCommand,
|
||||
canonicalizeDeployKeyPem,
|
||||
canonicalizeKnownHostsEntry,
|
||||
fingerprintFromKnownHostsLine,
|
||||
parseRepoTransportUrl,
|
||||
parseSshScpUrl,
|
||||
parseSshUrl,
|
||||
} from '../services/git/sshTrust';
|
||||
import { classifyGitFailure } from '../services/git/errors';
|
||||
|
||||
describe('sshTrust URL parsing', () => {
|
||||
it('parses scp-style URLs', () => {
|
||||
const parsed = parseSshScpUrl('git@github.com:org/repo.git');
|
||||
expect(parsed?.host).toBe('github.com');
|
||||
expect(parsed?.port).toBe(22);
|
||||
expect(parsed?.href).toBe('git@github.com:org/repo.git');
|
||||
});
|
||||
|
||||
it('parses scp-style URLs with nonstandard port via ssh://', () => {
|
||||
const parsed = parseSshUrl('ssh://git@git.example.com:2222/org/repo.git');
|
||||
expect(parsed?.port).toBe(2222);
|
||||
expect(parsed?.href).toBe('ssh://git@git.example.com:2222/org/repo.git');
|
||||
});
|
||||
|
||||
it('parses ssh:// URLs', () => {
|
||||
const parsed = parseSshUrl('ssh://git@host.example:2222/org/repo.git');
|
||||
expect(parsed?.host).toBe('host.example');
|
||||
expect(parsed?.port).toBe(2222);
|
||||
});
|
||||
|
||||
it('classifies transport kind from mixed inputs', () => {
|
||||
expect(parseRepoTransportUrl('https://github.com/org/repo.git')?.kind).toBe('https');
|
||||
expect(parseRepoTransportUrl('git@host:org/repo.git')?.kind).toBe('ssh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh host key fingerprint', () => {
|
||||
it('computes SHA256 fingerprint from the key material, not the key type', () => {
|
||||
const keyBase64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q=';
|
||||
const line = `|1|abc|abc ssh-ed25519 ${keyBase64}`;
|
||||
const fp = fingerprintFromKnownHostsLine(line);
|
||||
const expected = `SHA256:${createHash('sha256').update(Buffer.from(keyBase64, 'base64')).digest('base64').replace(/=+$/, '')}`;
|
||||
expect(fp).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh credential canonicalization', () => {
|
||||
it('rebuilds known_hosts lines from parsed key material only', () => {
|
||||
const keyBase64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q=';
|
||||
const raw = `git.example.com ssh-ed25519 ${keyBase64} trailing-garbage`;
|
||||
const canonical = canonicalizeKnownHostsEntry(raw);
|
||||
expect(canonical).toBe(`git.example.com ssh-ed25519 ${keyBase64}\n`);
|
||||
expect(canonical).not.toContain('trailing-garbage');
|
||||
});
|
||||
|
||||
it('rejects invalid known_hosts lines', () => {
|
||||
expect(() => canonicalizeKnownHostsEntry('not-a-host-key')).toThrow(/invalid|empty/i);
|
||||
});
|
||||
|
||||
it('rebuilds deploy key PEM from validated envelope and body', () => {
|
||||
const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nYWJj\n-----END OPENSSH PRIVATE KEY-----\n';
|
||||
expect(canonicalizeDeployKeyPem(pem)).toBe(pem);
|
||||
});
|
||||
|
||||
it('rejects malformed deploy keys', () => {
|
||||
expect(() => canonicalizeDeployKeyPem('not a pem')).toThrow(/invalid/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh command builder', () => {
|
||||
it('enforces strict host key checking', () => {
|
||||
const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts');
|
||||
expect(cmd).toContain('StrictHostKeyChecking=yes');
|
||||
expect(cmd).toContain('UserKnownHostsFile=/tmp/known_hosts');
|
||||
expect(cmd).toContain('IdentitiesOnly=yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSH stderr classification', () => {
|
||||
it('maps host key verification failure to SSH_HOST_KEY_FAILED', () => {
|
||||
const result = classifyGitFailure({
|
||||
transportFailure: true,
|
||||
reason: 'exit',
|
||||
host: 'git.example.com',
|
||||
hasToken: true,
|
||||
stderr: 'Host key verification failed.',
|
||||
});
|
||||
expect(result.code).toBe('SSH_HOST_KEY_FAILED');
|
||||
});
|
||||
|
||||
it('maps publickey denial with credential to AUTH_FAILED', () => {
|
||||
const result = classifyGitFailure({
|
||||
transportFailure: true,
|
||||
reason: 'exit',
|
||||
host: 'git.example.com',
|
||||
hasToken: true,
|
||||
stderr: 'git@git.example.com: Permission denied (publickey).',
|
||||
});
|
||||
expect(result.code).toBe('AUTH_FAILED');
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ function seedGitSource(stackName: string): void {
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null,
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
|
||||
Reference in New Issue
Block a user