Skip to content

Commit 27314f8

Browse files
committed
refactor(ssh): extract SSH helpers and expand pullRemote tests
1 parent 5223dc5 commit 27314f8

File tree

3 files changed

+565
-144
lines changed

3 files changed

+565
-144
lines changed

src/proxy/processors/push-action/PullRemoteSSH.ts

Lines changed: 9 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Action, Step } from '../../actions';
22
import { PullRemoteBase, CloneResult } from './PullRemoteBase';
33
import { ClientWithUser } from '../../ssh/types';
4-
import { DEFAULT_KNOWN_HOSTS } from '../../ssh/knownHosts';
4+
import {
5+
validateAgentSocketPath,
6+
convertToSSHUrl,
7+
createKnownHostsFile,
8+
} from '../../ssh/sshHelpers';
59
import { spawn } from 'child_process';
6-
import { execSync } from 'child_process';
7-
import * as crypto from 'crypto';
810
import fs from 'fs';
911
import path from 'path';
1012
import os from 'os';
@@ -14,136 +16,6 @@ import os from 'os';
1416
* Uses system git with SSH agent forwarding for cloning
1517
*/
1618
export class PullRemoteSSH extends PullRemoteBase {
17-
/**
18-
* Validate agent socket path to prevent command injection
19-
* Only allows safe characters in Unix socket paths
20-
*/
21-
private validateAgentSocketPath(socketPath: string | undefined): string {
22-
if (!socketPath) {
23-
throw new Error(
24-
'SSH agent socket path not found. ' +
25-
'Ensure SSH_AUTH_SOCK is set or agent forwarding is enabled.',
26-
);
27-
}
28-
29-
// Unix socket paths should only contain alphanumeric, dots, slashes, underscores, hyphens
30-
// and allow common socket path patterns like /tmp/ssh-*/agent.*
31-
const safePathRegex = /^[a-zA-Z0-9/_.\-*]+$/;
32-
if (!safePathRegex.test(socketPath)) {
33-
throw new Error(
34-
`Invalid SSH agent socket path: contains unsafe characters. Path: ${socketPath}`,
35-
);
36-
}
37-
38-
// Additional validation: path should start with / (absolute path)
39-
if (!socketPath.startsWith('/')) {
40-
throw new Error(
41-
`Invalid SSH agent socket path: must be an absolute path. Path: ${socketPath}`,
42-
);
43-
}
44-
45-
return socketPath;
46-
}
47-
48-
/**
49-
* Create a secure known_hosts file with hardcoded verified host keys
50-
* This prevents MITM attacks by using pre-verified fingerprints
51-
*
52-
* NOTE: We use hardcoded fingerprints from DEFAULT_KNOWN_HOSTS, NOT ssh-keyscan,
53-
* because ssh-keyscan itself is vulnerable to MITM attacks.
54-
*/
55-
private async createKnownHostsFile(tempDir: string, sshUrl: string): Promise<string> {
56-
const knownHostsPath = path.join(tempDir, 'known_hosts');
57-
58-
// Extract hostname from SSH URL (git@github.com:org/repo.git -> github.com)
59-
const hostMatch = sshUrl.match(/git@([^:]+):/);
60-
if (!hostMatch) {
61-
throw new Error(`Cannot extract hostname from SSH URL: ${sshUrl}`);
62-
}
63-
64-
const hostname = hostMatch[1];
65-
66-
// Get the known host key for this hostname from hardcoded fingerprints
67-
const knownFingerprint = DEFAULT_KNOWN_HOSTS[hostname];
68-
if (!knownFingerprint) {
69-
throw new Error(
70-
`No known host key for ${hostname}. ` +
71-
`Supported hosts: ${Object.keys(DEFAULT_KNOWN_HOSTS).join(', ')}. ` +
72-
`To add support for ${hostname}, add its ed25519 key fingerprint to DEFAULT_KNOWN_HOSTS.`,
73-
);
74-
}
75-
76-
// Fetch the actual host key from the remote server to get the public key
77-
// We'll verify its fingerprint matches our hardcoded one
78-
let actualHostKey: string;
79-
try {
80-
const output = execSync(`ssh-keyscan -t ed25519 ${hostname} 2>/dev/null`, {
81-
encoding: 'utf-8',
82-
timeout: 5000,
83-
});
84-
85-
// Parse ssh-keyscan output: "hostname ssh-ed25519 AAAAC3Nz..."
86-
const keyLine = output.split('\n').find((line) => line.includes('ssh-ed25519'));
87-
if (!keyLine) {
88-
throw new Error('No ed25519 key found in ssh-keyscan output');
89-
}
90-
91-
actualHostKey = keyLine.trim();
92-
93-
// Verify the fingerprint matches our hardcoded trusted fingerprint
94-
// Extract the public key portion
95-
const keyParts = actualHostKey.split(' ');
96-
if (keyParts.length < 3) {
97-
throw new Error('Invalid ssh-keyscan output format');
98-
}
99-
100-
const publicKeyBase64 = keyParts[2];
101-
const publicKeyBuffer = Buffer.from(publicKeyBase64, 'base64');
102-
103-
// Calculate SHA256 fingerprint
104-
const hash = crypto.createHash('sha256').update(publicKeyBuffer).digest('base64');
105-
// Remove base64 padding (=) to match standard SSH fingerprint format
106-
const calculatedFingerprint = `SHA256:${hash.replace(/=+$/, '')}`;
107-
108-
// Verify against hardcoded fingerprint
109-
if (calculatedFingerprint !== knownFingerprint) {
110-
throw new Error(
111-
`Host key verification failed for ${hostname}!\n` +
112-
`Expected fingerprint: ${knownFingerprint}\n` +
113-
`Received fingerprint: ${calculatedFingerprint}\n` +
114-
`WARNING: This could indicate a man-in-the-middle attack!\n` +
115-
`If the host key has legitimately changed, update DEFAULT_KNOWN_HOSTS.`,
116-
);
117-
}
118-
119-
console.log(`[SSH] ✓ Host key verification successful for ${hostname}`);
120-
console.log(`[SSH] Fingerprint: ${calculatedFingerprint}`);
121-
} catch (error) {
122-
throw new Error(
123-
`Failed to verify host key for ${hostname}: ${error instanceof Error ? error.message : String(error)}`,
124-
);
125-
}
126-
127-
// Write the verified known_hosts file
128-
await fs.promises.writeFile(knownHostsPath, actualHostKey + '\n', { mode: 0o600 });
129-
130-
return knownHostsPath;
131-
}
132-
133-
/**
134-
* Convert HTTPS URL to SSH URL
135-
*/
136-
private convertToSSHUrl(httpsUrl: string): string {
137-
// Convert https://github.com/org/repo.git to git@github.com:org/repo.git
138-
const match = httpsUrl.match(/https:\/\/([^/]+)\/(.+)/);
139-
if (!match) {
140-
throw new Error(`Invalid repository URL: ${httpsUrl}`);
141-
}
142-
143-
const [, host, repoPath] = match;
144-
return `git@${host}:${repoPath}`;
145-
}
146-
14719
/**
14820
* Clone repository using system git with SSH agent forwarding
14921
* Implements secure SSH configuration with host key verification
@@ -153,7 +25,7 @@ export class PullRemoteSSH extends PullRemoteBase {
15325
action: Action,
15426
step: Step,
15527
): Promise<void> {
156-
const sshUrl = this.convertToSSHUrl(action.url);
28+
const sshUrl = convertToSSHUrl(action.url);
15729

15830
// Create parent directory
15931
await fs.promises.mkdir(action.proxyGitPath!, { recursive: true });
@@ -167,12 +39,12 @@ export class PullRemoteSSH extends PullRemoteBase {
16739
try {
16840
// Validate and get the agent socket path
16941
const rawAgentSocketPath = (client as any)._agent?._sock?.path || process.env.SSH_AUTH_SOCK;
170-
const agentSocketPath = this.validateAgentSocketPath(rawAgentSocketPath);
42+
const agentSocketPath = validateAgentSocketPath(rawAgentSocketPath);
17143

17244
step.log(`Using SSH agent socket: ${agentSocketPath}`);
17345

17446
// Create secure known_hosts file with verified host keys
175-
const knownHostsPath = await this.createKnownHostsFile(tempDir, sshUrl);
47+
const knownHostsPath = await createKnownHostsFile(tempDir, sshUrl);
17648
step.log(`Created secure known_hosts file with verified host keys`);
17749

17850
// Create secure SSH config with StrictHostKeyChecking enabled
@@ -262,7 +134,7 @@ export class PullRemoteSSH extends PullRemoteBase {
262134
throw new Error(`SSH clone failed: ${message}`);
263135
}
264136

265-
const sshUrl = this.convertToSSHUrl(action.url);
137+
const sshUrl = convertToSSHUrl(action.url);
266138

267139
return {
268140
command: `git clone --depth 1 ${sshUrl}`,

src/proxy/ssh/sshHelpers.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { getSSHConfig } from '../../config';
22
import { KILOBYTE, MEGABYTE } from '../../constants';
33
import { ClientWithUser } from './types';
44
import { createLazyAgent } from './AgentForwarding';
5-
import { getKnownHosts, verifyHostKey } from './knownHosts';
5+
import { getKnownHosts, verifyHostKey, DEFAULT_KNOWN_HOSTS } from './knownHosts';
66
import * as crypto from 'crypto';
7+
import { execSync } from 'child_process';
8+
import * as fs from 'fs';
9+
import * as path from 'path';
710

811
/**
912
* Calculate SHA-256 fingerprint from SSH host key Buffer
@@ -108,6 +111,134 @@ export function createSSHConnectionOptions(
108111
return connectionOptions;
109112
}
110113

114+
/**
115+
* Create a known_hosts file with verified SSH host keys
116+
* Fetches the actual host key and verifies it against hardcoded fingerprints
117+
*
118+
* This prevents MITM attacks by using pre-verified fingerprints
119+
*
120+
* @param tempDir Temporary directory to create the known_hosts file in
121+
* @param sshUrl SSH URL (e.g., git@github.com:org/repo.git)
122+
* @returns Path to the created known_hosts file
123+
*/
124+
export async function createKnownHostsFile(tempDir: string, sshUrl: string): Promise<string> {
125+
const knownHostsPath = path.join(tempDir, 'known_hosts');
126+
127+
// Extract hostname from SSH URL (git@github.com:org/repo.git -> github.com)
128+
const hostMatch = sshUrl.match(/git@([^:]+):/);
129+
if (!hostMatch) {
130+
throw new Error(`Cannot extract hostname from SSH URL: ${sshUrl}`);
131+
}
132+
133+
const hostname = hostMatch[1];
134+
135+
// Get the known host key for this hostname from hardcoded fingerprints
136+
const knownFingerprint = DEFAULT_KNOWN_HOSTS[hostname];
137+
if (!knownFingerprint) {
138+
throw new Error(
139+
`No known host key for ${hostname}. ` +
140+
`Supported hosts: ${Object.keys(DEFAULT_KNOWN_HOSTS).join(', ')}. ` +
141+
`To add support for ${hostname}, add its ed25519 key fingerprint to DEFAULT_KNOWN_HOSTS.`,
142+
);
143+
}
144+
145+
// Fetch the actual host key from the remote server to get the public key
146+
// We'll verify its fingerprint matches our hardcoded one
147+
let actualHostKey: string;
148+
try {
149+
const output = execSync(`ssh-keyscan -t ed25519 ${hostname} 2>/dev/null`, {
150+
encoding: 'utf-8',
151+
timeout: 5000,
152+
});
153+
154+
// Parse ssh-keyscan output: "hostname ssh-ed25519 AAAAC3Nz..."
155+
const keyLine = output.split('\n').find((line) => line.includes('ssh-ed25519'));
156+
if (!keyLine) {
157+
throw new Error('No ed25519 key found in ssh-keyscan output');
158+
}
159+
160+
actualHostKey = keyLine.trim();
161+
162+
// Verify the fingerprint matches our hardcoded trusted fingerprint
163+
// Extract the public key portion
164+
const keyParts = actualHostKey.split(' ');
165+
if (keyParts.length < 3) {
166+
throw new Error('Invalid ssh-keyscan output format');
167+
}
168+
169+
const publicKeyBase64 = keyParts[2];
170+
const publicKeyBuffer = Buffer.from(publicKeyBase64, 'base64');
171+
172+
// Calculate SHA256 fingerprint
173+
const calculatedFingerprint = calculateHostKeyFingerprint(publicKeyBuffer);
174+
175+
// Verify against hardcoded fingerprint
176+
if (calculatedFingerprint !== knownFingerprint) {
177+
throw new Error(
178+
`Host key verification failed for ${hostname}!\n` +
179+
`Expected fingerprint: ${knownFingerprint}\n` +
180+
`Received fingerprint: ${calculatedFingerprint}\n` +
181+
`WARNING: This could indicate a man-in-the-middle attack!\n` +
182+
`If the host key has legitimately changed, update DEFAULT_KNOWN_HOSTS.`,
183+
);
184+
}
185+
186+
console.log(`[SSH] ✓ Host key verification successful for ${hostname}`);
187+
console.log(`[SSH] Fingerprint: ${calculatedFingerprint}`);
188+
} catch (error) {
189+
throw new Error(
190+
`Failed to verify host key for ${hostname}: ${error instanceof Error ? error.message : String(error)}`,
191+
);
192+
}
193+
194+
// Write the verified known_hosts file
195+
await fs.promises.writeFile(knownHostsPath, actualHostKey + '\n', { mode: 0o600 });
196+
197+
return knownHostsPath;
198+
}
199+
200+
/**
201+
* Validate SSH agent socket path for security
202+
* Ensures the path is absolute and contains no unsafe characters
203+
*/
204+
export function validateAgentSocketPath(socketPath: string | undefined): string {
205+
if (!socketPath) {
206+
throw new Error(
207+
'SSH agent socket path not found. Ensure SSH agent is running and SSH_AUTH_SOCK is set.',
208+
);
209+
}
210+
211+
// Security: Prevent path traversal and command injection
212+
// Allow only alphanumeric, dash, underscore, dot, forward slash
213+
const unsafeCharPattern = /[^a-zA-Z0-9\-_./]/;
214+
if (unsafeCharPattern.test(socketPath)) {
215+
throw new Error('Invalid SSH agent socket path: contains unsafe characters');
216+
}
217+
218+
// Ensure it's an absolute path
219+
if (!socketPath.startsWith('/')) {
220+
throw new Error('Invalid SSH agent socket path: must be an absolute path');
221+
}
222+
223+
return socketPath;
224+
}
225+
226+
/**
227+
* Convert HTTPS Git URL to SSH format
228+
* Example: https://github.com/org/repo.git -> git@github.com:org/repo.git
229+
*/
230+
export function convertToSSHUrl(httpsUrl: string): string {
231+
try {
232+
const url = new URL(httpsUrl);
233+
const hostname = url.hostname;
234+
const pathname = url.pathname.replace(/^\//, ''); // Remove leading slash
235+
236+
return `git@${hostname}:${pathname}`;
237+
} catch (error) {
238+
throw new Error(`Invalid repository URL: ${httpsUrl}`);
239+
}
240+
}
241+
111242
/**
112243
* Create a mock response object for security chain validation
113244
* This is used when SSH operations need to go through the proxy chain

0 commit comments

Comments
 (0)