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
+272
View File
@@ -0,0 +1,272 @@
import { createHash } from 'crypto';
import { spawn } from 'child_process';
import path from 'path';
/** Parsed SSH repository target for transport and host-key trust. */
export interface ParsedSshRepoUrl {
/** URL string passed to git (scp-style or ssh://). */
href: string;
host: string;
port: number;
pathname: string;
}
const DEFAULT_SSH_PORT = 22;
const SCP_URL_PATTERN = /^([^@\s/]+)@([^:\s]+):(.+)$/;
const KNOWN_HOSTS_SCAN_TIMEOUT_MS = 15_000;
function normalizePathname(pathname: string): string {
const trimmed = pathname.trim();
if (!trimmed.startsWith('/')) return `/${trimmed}`;
return trimmed;
}
/**
* Parse scp-style `git@host:org/repo.git` URLs. Git accepts these directly;
* ssh:// is used when a nonstandard port is required.
*/
export function parseSshScpUrl(raw: string): ParsedSshRepoUrl | null {
const trimmed = raw.trim();
const match = SCP_URL_PATTERN.exec(trimmed);
if (!match) return null;
const user = match[1];
const hostPart = match[2];
const repoPath = match[3].trim();
if (!user || !hostPart || !repoPath || repoPath.includes('..')) return null;
const colon = hostPart.lastIndexOf(':');
let host = hostPart;
let port = DEFAULT_SSH_PORT;
if (colon > 0 && colon < hostPart.length - 1) {
const portText = hostPart.slice(colon + 1);
const parsedPort = Number.parseInt(portText, 10);
if (!Number.isFinite(parsedPort) || parsedPort < 1 || parsedPort > 65535) return null;
host = hostPart.slice(0, colon);
port = parsedPort;
}
if (!host) return null;
const pathname = normalizePathname(repoPath);
const href = port === DEFAULT_SSH_PORT
? `${user}@${host}:${repoPath}`
: `ssh://${user}@${host}:${port}${pathname}`;
return { href, host, port, pathname };
}
export function parseSshUrl(raw: string): ParsedSshRepoUrl | null {
const trimmed = raw.trim();
let url: URL;
try {
url = new URL(trimmed);
} catch {
return parseSshScpUrl(trimmed);
}
if (url.protocol !== 'ssh:') return null;
if (!url.hostname || url.username === '' || url.password !== '') return null;
if (url.search !== '' || url.hash !== '') return null;
const port = url.port ? Number.parseInt(url.port, 10) : DEFAULT_SSH_PORT;
if (!Number.isFinite(port) || port < 1 || port > 65535) return null;
const pathname = normalizePathname(url.pathname);
if (pathname === '/' || pathname.includes('..')) return null;
const user = url.username;
const href = port === DEFAULT_SSH_PORT
? `${user}@${url.hostname}:${pathname.slice(1)}`
: `ssh://${user}@${url.hostname}:${port}${pathname}`;
return { href, host: url.hostname, port, pathname };
}
export type RepoTransportKind = 'https' | 'ssh';
export interface ParsedRepoUrl {
kind: RepoTransportKind;
href: string;
host: string;
port?: number;
pathname: string;
}
export function parseRepoTransportUrl(raw: string): ParsedRepoUrl | null {
const trimmed = raw.trim();
if (!trimmed) return null;
let https: URL;
try {
https = new URL(trimmed);
} catch {
https = null as unknown as URL;
}
if (https && https.protocol === 'https:' && https.hostname && !https.username && !https.password
&& https.search === '' && https.hash === '') {
return {
kind: 'https',
href: https.href,
host: https.host,
pathname: https.pathname,
};
}
const ssh = parseSshUrl(trimmed);
if (!ssh) return null;
return {
kind: 'ssh',
href: ssh.href,
host: ssh.host,
port: ssh.port,
pathname: ssh.pathname,
};
}
/** SHA256 fingerprint in OpenSSH display form (`SHA256:...`). */
export function fingerprintFromKnownHostsLine(line: string): string | null {
const material = keyMaterialFromKnownHostsLine(line);
if (!material) return null;
try {
const digest = createHash('sha256').update(Buffer.from(material.keyBase64, 'base64')).digest('base64');
return `SHA256:${digest.replace(/=+$/, '')}`;
} catch {
return null;
}
}
function isSshKeyType(token: string): boolean {
return token.startsWith('ssh-') || token.startsWith('ecdsa-') || token.startsWith('sk-');
}
function keyMaterialFromKnownHostsLine(line: string): { keyType: string; keyBase64: string } | null {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return null;
const parts = trimmed.split(/\s+/);
if (parts.length < 3) return null;
for (let i = 0; i < parts.length - 1; i += 1) {
if (isSshKeyType(parts[i])) {
return { keyType: parts[i], keyBase64: parts[i + 1] };
}
}
return null;
}
const DEPLOY_KEY_PEM_HEADER = /^-----BEGIN (?:OPENSSH )?PRIVATE KEY-----$/;
const DEPLOY_KEY_PEM_FOOTER = /^-----END (?:OPENSSH )?PRIVATE KEY-----$/;
/** Rebuild a deploy key PEM from validated envelope and base64 body lines only. */
export function canonicalizeDeployKeyPem(raw: string): string {
const lines = raw.replace(/\r\n/g, '\n').trim().split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
if (lines.length < 3) {
throw new Error('deploy key is invalid');
}
const header = lines[0];
const footer = lines[lines.length - 1];
if (!DEPLOY_KEY_PEM_HEADER.test(header) || !DEPLOY_KEY_PEM_FOOTER.test(footer)) {
throw new Error('deploy key is invalid');
}
const bodyLines = lines.slice(1, -1);
for (const bodyLine of bodyLines) {
if (!/^[A-Za-z0-9+/=]+$/.test(bodyLine)) {
throw new Error('deploy key is invalid');
}
}
return `${header}\n${bodyLines.join('\n')}\n${footer}\n`;
}
function canonicalizeKnownHostsLine(line: string): string {
const material = keyMaterialFromKnownHostsLine(line);
if (!material) {
throw new Error('known_hosts entry is invalid');
}
try {
Buffer.from(material.keyBase64, 'base64');
} catch {
throw new Error('known_hosts entry is invalid');
}
const parts = line.trim().split(/\s+/);
let keyTypeIdx = -1;
for (let i = 0; i < parts.length - 1; i += 1) {
if (isSshKeyType(parts[i])) {
keyTypeIdx = i;
break;
}
}
if (keyTypeIdx < 1) {
throw new Error('known_hosts entry is invalid');
}
const hostPart = parts.slice(0, keyTypeIdx).join(' ');
return `${hostPart} ${material.keyType} ${material.keyBase64}`;
}
/** Rebuild known_hosts content from parsed host markers and key material only. */
export function canonicalizeKnownHostsEntry(raw: string): string {
const lines = raw.trim().split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'));
if (lines.length === 0) {
throw new Error('known_hosts entry is empty');
}
return `${lines.map(canonicalizeKnownHostsLine).join('\n')}\n`;
}
export interface ScannedHostKey {
keyType: string;
fingerprint: string;
line: string;
}
function runSshKeyscan(host: string, port: number): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolve, reject) => {
const args = port === DEFAULT_SSH_PORT
? ['-H', host]
: ['-p', String(port), '-H', host];
const child = spawn('ssh-keyscan', args, { windowsHide: true });
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
const timer = setTimeout(() => {
child.kill('SIGKILL');
}, KNOWN_HOSTS_SCAN_TIMEOUT_MS);
child.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
child.on('close', (code) => {
clearTimeout(timer);
resolve({ stdout, stderr, exitCode: code ?? -1 });
});
});
}
/** Fetch host keys from the server without trusting them (probe step only). */
export async function scanHostKeys(host: string, port: number): Promise<ScannedHostKey[]> {
const result = await runSshKeyscan(host, port);
if (result.exitCode !== 0 && !result.stdout.trim()) {
throw new Error(result.stderr.trim() || 'ssh-keyscan failed');
}
const keys: ScannedHostKey[] = [];
for (const line of result.stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const fingerprint = fingerprintFromKnownHostsLine(trimmed);
if (!fingerprint) continue;
const material = keyMaterialFromKnownHostsLine(trimmed);
const keyType = material?.keyType ?? 'unknown';
keys.push({ keyType, fingerprint, line: trimmed });
}
if (keys.length === 0) {
throw new Error('No host keys returned from ssh-keyscan');
}
return keys;
}
/**
* Build GIT_SSH_COMMAND / core.sshCommand value enforcing strict host-key
* checking against our per-fetch known_hosts file and a single deploy key.
*/
export function buildSshCommand(keyPath: string, knownHostsPath: string): string {
const key = keyPath.split(path.sep).join('/');
const known = knownHostsPath.split(path.sep).join('/');
return [
'ssh',
'-o', 'BatchMode=yes',
'-o', 'StrictHostKeyChecking=yes',
'-o', 'UserKnownHostsFile=' + known,
'-o', 'IdentitiesOnly=yes',
'-o', 'IdentityAgent=none',
'-F', '/dev/null',
'-i', key,
].join(' ');
}