11import { Action , Step } from '../../actions' ;
22import { PullRemoteBase , CloneResult } from './PullRemoteBase' ;
33import { ClientWithUser } from '../../ssh/types' ;
4- import { DEFAULT_KNOWN_HOSTS } from '../../ssh/knownHosts' ;
4+ import {
5+ validateAgentSocketPath ,
6+ convertToSSHUrl ,
7+ createKnownHostsFile ,
8+ } from '../../ssh/sshHelpers' ;
59import { spawn } from 'child_process' ;
6- import { execSync } from 'child_process' ;
7- import * as crypto from 'crypto' ;
810import fs from 'fs' ;
911import path from 'path' ;
1012import os from 'os' ;
@@ -14,136 +16,6 @@ import os from 'os';
1416 * Uses system git with SSH agent forwarding for cloning
1517 */
1618export 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 - z A - Z 0 - 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 ( / g i t @ ( [ ^ : ] + ) : / ) ;
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 ( / h t t p s : \/ \/ ( [ ^ / ] + ) \/ ( .+ ) / ) ;
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 } ` ,
0 commit comments