mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-08 00:16:23 +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:
@@ -429,7 +429,7 @@ export interface Webhook {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export type GitSourceAuthType = 'none' | 'token';
|
||||
export type GitSourceAuthType = 'none' | 'token' | 'deploy_key';
|
||||
|
||||
/**
|
||||
* The ordered set of local compose files actually materialized on disk for a
|
||||
@@ -457,6 +457,9 @@ export interface StackGitSource {
|
||||
env_path: string | null;
|
||||
auth_type: GitSourceAuthType;
|
||||
encrypted_token: string | null;
|
||||
encrypted_deploy_key: string | null;
|
||||
ssh_known_hosts_entry: string | null;
|
||||
ssh_host_key_fingerprint: string | null;
|
||||
auto_apply_on_webhook: boolean;
|
||||
auto_deploy_on_apply: boolean;
|
||||
last_applied_commit_sha: string | null;
|
||||
@@ -1169,9 +1172,11 @@ export class DatabaseService {
|
||||
this.migrateFleetSyncStickyError();
|
||||
this.migrateStackDossierHashes();
|
||||
this.migrateGitSourceMultiFile();
|
||||
this.migrateGitSourceSshDeployKey();
|
||||
this.migrateGitSourceManifest();
|
||||
this.migrateGitSourceChangePlan();
|
||||
this.migrateGitOpsRecoveryColumns();
|
||||
this.migrateGitOpsCreateCheckpointSshDeployKey();
|
||||
this.migrateNodeUpdateSkips();
|
||||
this.migrateStackAlertServiceScope();
|
||||
|
||||
@@ -2574,6 +2579,12 @@ stmt.run('gitops_schema_version', '1');
|
||||
this.tryAddColumn('stack_dossiers', 'last_drift_check_at', 'INTEGER');
|
||||
}
|
||||
|
||||
private migrateGitSourceSshDeployKey(): void {
|
||||
this.tryAddColumn('stack_git_sources', 'encrypted_deploy_key', 'TEXT');
|
||||
this.tryAddColumn('stack_git_sources', 'ssh_known_hosts_entry', 'TEXT');
|
||||
this.tryAddColumn('stack_git_sources', 'ssh_host_key_fingerprint', 'TEXT');
|
||||
}
|
||||
|
||||
private migrateGitSourceManifest(): void {
|
||||
// Cache columns for the managed-project manifest (the manifest FILE in
|
||||
// <DATA_DIR>/git-managed/<nodeId>/<stackName>/ is the source of truth).
|
||||
@@ -2596,6 +2607,12 @@ stmt.run('gitops_schema_version', '1');
|
||||
this.tryAddColumn('stack_update_recovery_generations', 'gitops_source_acceptance_ref', 'TEXT');
|
||||
}
|
||||
|
||||
private migrateGitOpsCreateCheckpointSshDeployKey(): void {
|
||||
this.tryAddColumn('gitops_create_checkpoints', 'encrypted_deploy_key', 'TEXT');
|
||||
this.tryAddColumn('gitops_create_checkpoints', 'ssh_known_hosts_entry', 'TEXT');
|
||||
this.tryAddColumn('gitops_create_checkpoints', 'ssh_host_key_fingerprint', 'TEXT');
|
||||
}
|
||||
|
||||
private migrateGitSourceMultiFile(): void {
|
||||
this.tryAddColumn('stack_git_sources', 'compose_paths', 'TEXT');
|
||||
this.tryAddColumn('stack_git_sources', 'context_dir', 'TEXT');
|
||||
@@ -6275,6 +6292,9 @@ stmt.run('gitops_schema_version', '1');
|
||||
env_path: (row.env_path as string | null) ?? null,
|
||||
auth_type: row.auth_type as GitSourceAuthType,
|
||||
encrypted_token: (row.encrypted_token as string | null) ?? null,
|
||||
encrypted_deploy_key: (row.encrypted_deploy_key as string | null) ?? null,
|
||||
ssh_known_hosts_entry: (row.ssh_known_hosts_entry as string | null) ?? null,
|
||||
ssh_host_key_fingerprint: (row.ssh_host_key_fingerprint as string | null) ?? null,
|
||||
auto_apply_on_webhook: Number(row.auto_apply_on_webhook) === 1,
|
||||
auto_deploy_on_apply: Number(row.auto_deploy_on_apply) === 1,
|
||||
last_applied_commit_sha: (row.last_applied_commit_sha as string | null) ?? null,
|
||||
@@ -6315,14 +6335,16 @@ stmt.run('gitops_schema_version', '1');
|
||||
`UPDATE stack_git_sources SET
|
||||
repo_url = ?, branch = ?, compose_path = ?, compose_paths = ?, context_dir = ?,
|
||||
sync_env = ?, env_path = ?,
|
||||
auth_type = ?, encrypted_token = ?,
|
||||
auth_type = ?, encrypted_token = ?, encrypted_deploy_key = ?,
|
||||
ssh_known_hosts_entry = ?, ssh_host_key_fingerprint = ?,
|
||||
auto_apply_on_webhook = ?, auto_deploy_on_apply = ?,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(
|
||||
source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
|
||||
source.sync_env ? 1 : 0, source.env_path,
|
||||
source.auth_type, source.encrypted_token,
|
||||
source.auth_type, source.encrypted_token, source.encrypted_deploy_key,
|
||||
source.ssh_known_hosts_entry, source.ssh_host_key_fingerprint,
|
||||
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
|
||||
now, source.stack_name
|
||||
);
|
||||
@@ -6331,13 +6353,15 @@ stmt.run('gitops_schema_version', '1');
|
||||
const result = this.db.prepare(
|
||||
`INSERT INTO stack_git_sources
|
||||
(stack_name, repo_url, branch, compose_path, compose_paths, context_dir, sync_env, env_path,
|
||||
auth_type, encrypted_token, auto_apply_on_webhook, auto_deploy_on_apply,
|
||||
auth_type, encrypted_token, encrypted_deploy_key, ssh_known_hosts_entry, ssh_host_key_fingerprint,
|
||||
auto_apply_on_webhook, auto_deploy_on_apply,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
source.stack_name, source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
|
||||
source.sync_env ? 1 : 0, source.env_path,
|
||||
source.auth_type, source.encrypted_token,
|
||||
source.auth_type, source.encrypted_token, source.encrypted_deploy_key,
|
||||
source.ssh_known_hosts_entry, source.ssh_host_key_fingerprint,
|
||||
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
|
||||
now, now
|
||||
);
|
||||
|
||||
@@ -29,8 +29,9 @@ import type { GitChangePlan, PublicGitChangePlan, GitChangePlanCounts, PublicGit
|
||||
import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan';
|
||||
import type { NotificationCategory } from './NotificationService';
|
||||
import { classifyGitFailure, isTransportFailure } from './git/errors';
|
||||
import type { RefKind } from './git/types';
|
||||
import type { RefKind, SshDeployKeyAuth } from './git/types';
|
||||
import { nativeGitTransport, verifyFastForward } from './git/nativeGitTransport';
|
||||
import { fingerprintFromKnownHostsLine } from './git/sshTrust';
|
||||
import { GitOpsStore } from './gitops/store';
|
||||
import { GitOpsTransitions, GitOpsTransitionError } from './gitops/transitions';
|
||||
import {
|
||||
@@ -62,6 +63,7 @@ export type GitSourceErrorCode =
|
||||
| 'REF_NOT_FOUND'
|
||||
| 'REF_DELETED'
|
||||
| 'UNSUPPORTED_REF'
|
||||
| 'SSH_HOST_KEY_FAILED'
|
||||
| 'FILE_NOT_FOUND'
|
||||
| 'NETWORK_TIMEOUT'
|
||||
| 'GIT_ERROR'
|
||||
@@ -107,6 +109,7 @@ export interface FetchParams {
|
||||
composePaths: string[];
|
||||
envPath?: string | null;
|
||||
token?: string | null;
|
||||
sshAuth?: SshDeployKeyAuth | null;
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Runs inside the clone lifecycle (before the temp dir is removed) so the
|
||||
@@ -165,8 +168,17 @@ export interface UpsertInput {
|
||||
envPath: string | null;
|
||||
authType: GitSourceAuthType;
|
||||
token?: string | null; // undefined = keep existing, '' = clear, non-empty = replace
|
||||
deployKey?: string | null;
|
||||
sshKnownHostsEntry?: string | null;
|
||||
sshHostKeyFingerprint?: string | null;
|
||||
autoApplyOnWebhook: boolean;
|
||||
autoDeployOnApply: boolean;
|
||||
auditContext?: {
|
||||
username: string;
|
||||
method: string;
|
||||
path: string;
|
||||
ipAddress: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateStackFromGitInput {
|
||||
@@ -179,8 +191,17 @@ export interface CreateStackFromGitInput {
|
||||
envPath: string | null;
|
||||
authType: GitSourceAuthType;
|
||||
token: string | null;
|
||||
deployKey?: string | null;
|
||||
sshKnownHostsEntry?: string | null;
|
||||
sshHostKeyFingerprint?: string | null;
|
||||
autoApplyOnWebhook: boolean;
|
||||
autoDeployOnApply: boolean;
|
||||
auditContext?: {
|
||||
username: string;
|
||||
method: string;
|
||||
path: string;
|
||||
ipAddress: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateStackFromGitResult {
|
||||
@@ -224,6 +245,8 @@ export interface PublicGitSource {
|
||||
env_path: string | null;
|
||||
auth_type: GitSourceAuthType;
|
||||
has_token: boolean;
|
||||
has_deploy_key: boolean;
|
||||
ssh_host_key_fingerprint: string | null;
|
||||
auto_apply_on_webhook: boolean;
|
||||
auto_deploy_on_apply: boolean;
|
||||
last_applied_commit_sha: string | null;
|
||||
@@ -542,6 +565,8 @@ export class GitSourceService {
|
||||
env_path: src.env_path,
|
||||
auth_type: src.auth_type,
|
||||
has_token: !!src.encrypted_token,
|
||||
has_deploy_key: !!src.encrypted_deploy_key,
|
||||
ssh_host_key_fingerprint: src.ssh_host_key_fingerprint ?? null,
|
||||
auto_apply_on_webhook: src.auto_apply_on_webhook,
|
||||
auto_deploy_on_apply: src.auto_deploy_on_apply,
|
||||
last_applied_commit_sha: src.last_applied_commit_sha,
|
||||
@@ -567,21 +592,126 @@ export class GitSourceService {
|
||||
|
||||
// ─── CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
private resolveSshTrustFromKnownHostsEntry(
|
||||
knownHostsEntry: string,
|
||||
clientFingerprint?: string | null,
|
||||
): { sshKnownHostsEntry: string; sshHostKeyFingerprint: string } {
|
||||
const sshKnownHostsEntry = knownHostsEntry.trim();
|
||||
const derived = fingerprintFromKnownHostsLine(sshKnownHostsEntry);
|
||||
if (!derived) {
|
||||
throw new GitSourceError('GIT_ERROR', 'SSH known_hosts entry is invalid or incomplete.');
|
||||
}
|
||||
const trimmedClient = clientFingerprint?.trim();
|
||||
if (trimmedClient && trimmedClient !== derived) {
|
||||
throw new GitSourceError('GIT_ERROR', 'SSH host key fingerprint does not match the trusted key entry.');
|
||||
}
|
||||
return { sshKnownHostsEntry, sshHostKeyFingerprint: derived };
|
||||
}
|
||||
|
||||
private recordSshTrustAudit(args: {
|
||||
stackName: string;
|
||||
username: string;
|
||||
method: string;
|
||||
path: string;
|
||||
ipAddress: string;
|
||||
fingerprint: string;
|
||||
action: 'created' | 'rotated';
|
||||
}): void {
|
||||
try {
|
||||
DatabaseService.getInstance().insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: args.username,
|
||||
method: args.method,
|
||||
path: args.path,
|
||||
status_code: 200,
|
||||
node_id: null,
|
||||
ip_address: args.ipAddress,
|
||||
summary: `git_source.ssh_trust_${args.action}: stack=${args.stackName} fingerprint=${args.fingerprint}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[GitSource] SSH trust audit write failed:', sanitizeForLog(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
private maybeRecordSshTrustAudit(
|
||||
auditContext: UpsertInput['auditContext'],
|
||||
stackName: string,
|
||||
fingerprint: string,
|
||||
action: 'created' | 'rotated',
|
||||
): void {
|
||||
if (!auditContext) return;
|
||||
this.recordSshTrustAudit({ ...auditContext, stackName, fingerprint, action });
|
||||
}
|
||||
|
||||
private resolveTransportAuth(src: Pick<StackGitSource, 'auth_type' | 'encrypted_token' | 'encrypted_deploy_key' | 'ssh_known_hosts_entry'>): {
|
||||
token?: string | null;
|
||||
sshAuth?: SshDeployKeyAuth | null;
|
||||
} {
|
||||
if (src.auth_type === 'token') {
|
||||
return { token: src.encrypted_token ? this.crypto.decrypt(src.encrypted_token) : null };
|
||||
}
|
||||
if (src.auth_type === 'deploy_key') {
|
||||
if (!src.encrypted_deploy_key || !src.ssh_known_hosts_entry) {
|
||||
return { sshAuth: null };
|
||||
}
|
||||
return {
|
||||
sshAuth: {
|
||||
privateKey: this.crypto.decrypt(src.encrypted_deploy_key),
|
||||
knownHostsEntry: src.ssh_known_hosts_entry,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { token: null };
|
||||
}
|
||||
|
||||
public async upsert(input: UpsertInput): Promise<PublicGitSource> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getGitSource(input.stackName);
|
||||
|
||||
// Determine the stored token.
|
||||
let encryptedToken: string | null;
|
||||
// Determine stored credentials per auth type.
|
||||
let encryptedToken: string | null = null;
|
||||
let encryptedDeployKey: string | null = null;
|
||||
let sshKnownHostsEntry: string | null = null;
|
||||
let sshHostKeyFingerprint: string | null = null;
|
||||
|
||||
if (input.authType === 'none') {
|
||||
encryptedToken = null;
|
||||
} else if (input.token === undefined) {
|
||||
// Keep existing
|
||||
encryptedToken = existing?.encrypted_token ?? null;
|
||||
} else if (input.token === null || input.token === '') {
|
||||
encryptedToken = null;
|
||||
} else {
|
||||
encryptedToken = this.crypto.encrypt(input.token);
|
||||
// all null
|
||||
} else if (input.authType === 'token') {
|
||||
if (input.token === undefined) {
|
||||
encryptedToken = existing?.encrypted_token ?? null;
|
||||
} else if (input.token === null || input.token === '') {
|
||||
encryptedToken = null;
|
||||
} else {
|
||||
encryptedToken = this.crypto.encrypt(input.token);
|
||||
}
|
||||
} else if (input.authType === 'deploy_key') {
|
||||
if (input.deployKey === undefined) {
|
||||
encryptedDeployKey = existing?.encrypted_deploy_key ?? null;
|
||||
} else if (input.deployKey === null || input.deployKey === '') {
|
||||
encryptedDeployKey = null;
|
||||
} else {
|
||||
encryptedDeployKey = this.crypto.encrypt(input.deployKey);
|
||||
}
|
||||
if (input.sshKnownHostsEntry === undefined) {
|
||||
sshKnownHostsEntry = existing?.ssh_known_hosts_entry ?? null;
|
||||
sshHostKeyFingerprint = existing?.ssh_host_key_fingerprint ?? null;
|
||||
} else if (input.sshKnownHostsEntry === null || input.sshKnownHostsEntry.trim() === '') {
|
||||
sshKnownHostsEntry = null;
|
||||
sshHostKeyFingerprint = null;
|
||||
} else {
|
||||
const trust = this.resolveSshTrustFromKnownHostsEntry(
|
||||
input.sshKnownHostsEntry,
|
||||
input.sshHostKeyFingerprint,
|
||||
);
|
||||
sshKnownHostsEntry = trust.sshKnownHostsEntry;
|
||||
sshHostKeyFingerprint = trust.sshHostKeyFingerprint;
|
||||
}
|
||||
if (!encryptedDeployKey || !sshKnownHostsEntry) {
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
'Deploy key authentication requires a private key and a trusted SSH host key.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply-matrix sanity: auto_deploy requires auto_apply.
|
||||
@@ -609,13 +739,22 @@ export class GitSourceService {
|
||||
|
||||
// Dry-run reachability check before persisting. Fetches every configured
|
||||
// file so a bad path in the ordered list is caught at save time.
|
||||
const token = encryptedToken ? this.crypto.decrypt(encryptedToken) : null;
|
||||
const fetchAuth = input.authType === 'token'
|
||||
? { token: encryptedToken ? this.crypto.decrypt(encryptedToken) : null }
|
||||
: input.authType === 'deploy_key'
|
||||
? {
|
||||
sshAuth: {
|
||||
privateKey: this.crypto.decrypt(encryptedDeployKey!),
|
||||
knownHostsEntry: sshKnownHostsEntry!,
|
||||
},
|
||||
}
|
||||
: { token: null };
|
||||
await this.fetchFromGit({
|
||||
repoUrl: input.repoUrl,
|
||||
branch: input.branch,
|
||||
composePaths: input.composePaths,
|
||||
envPath: input.syncEnv ? input.envPath : null,
|
||||
token,
|
||||
...fetchAuth,
|
||||
});
|
||||
|
||||
const resolvedEnvPath = input.syncEnv ? input.envPath : null;
|
||||
@@ -657,6 +796,9 @@ export class GitSourceService {
|
||||
env_path: resolvedEnvPath,
|
||||
auth_type: input.authType,
|
||||
encrypted_token: encryptedToken,
|
||||
encrypted_deploy_key: encryptedDeployKey,
|
||||
ssh_known_hosts_entry: sshKnownHostsEntry,
|
||||
ssh_host_key_fingerprint: sshHostKeyFingerprint,
|
||||
auto_apply_on_webhook: input.autoApplyOnWebhook,
|
||||
auto_deploy_on_apply: input.autoDeployOnApply,
|
||||
last_applied_commit_sha: existing?.last_applied_commit_sha ?? null,
|
||||
@@ -714,6 +856,23 @@ export class GitSourceService {
|
||||
}
|
||||
})();
|
||||
|
||||
if (
|
||||
input.authType === 'deploy_key'
|
||||
&& input.sshKnownHostsEntry !== undefined
|
||||
&& sshKnownHostsEntry
|
||||
&& sshHostKeyFingerprint
|
||||
) {
|
||||
const priorKnownHosts = existing?.ssh_known_hosts_entry ?? null;
|
||||
if (priorKnownHosts !== sshKnownHostsEntry) {
|
||||
this.maybeRecordSshTrustAudit(
|
||||
input.auditContext,
|
||||
input.stackName,
|
||||
sshHostKeyFingerprint,
|
||||
priorKnownHosts ? 'rotated' : 'created',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.get(input.stackName)!;
|
||||
}
|
||||
|
||||
@@ -959,13 +1118,14 @@ export class GitSourceService {
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
token?: string | null;
|
||||
sshAuth?: SshDeployKeyAuth | null;
|
||||
timeoutMs?: number;
|
||||
hasPriorHistory?: boolean;
|
||||
priorIdentity?: { commitSha: string; kind: RefKind };
|
||||
},
|
||||
fn: (dir: string, commitSha: string, warnings: string[], resolvedRefKind: RefKind) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { repoUrl, branch, token } = params;
|
||||
const { repoUrl, branch, token, sshAuth } = params;
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
||||
const root = await createTempDir();
|
||||
const hasPriorHistory = params.hasPriorHistory === true || params.priorIdentity != null;
|
||||
@@ -975,6 +1135,7 @@ export class GitSourceService {
|
||||
repoUrl,
|
||||
ref: branch,
|
||||
token,
|
||||
sshAuth,
|
||||
timeoutMs,
|
||||
workspaceRoot: root,
|
||||
});
|
||||
@@ -989,6 +1150,7 @@ export class GitSourceService {
|
||||
ancestorSha: prior.commitSha,
|
||||
descendantSha: resolved.commitSha,
|
||||
token,
|
||||
sshAuth,
|
||||
timeoutMs,
|
||||
workspaceRoot: root,
|
||||
maxBytes: maxCloneBytes(),
|
||||
@@ -1003,6 +1165,7 @@ export class GitSourceService {
|
||||
ref: branch,
|
||||
refKind: resolved.kind,
|
||||
token,
|
||||
sshAuth,
|
||||
timeoutMs,
|
||||
commitSha: resolved.commitSha,
|
||||
workspaceRoot: root,
|
||||
@@ -1047,7 +1210,7 @@ export class GitSourceService {
|
||||
}
|
||||
|
||||
public async fetchFromGit(params: FetchParams): Promise<FetchResult> {
|
||||
const { repoUrl, branch, composePaths, envPath, token } = params;
|
||||
const { repoUrl, branch, composePaths, envPath, token, sshAuth } = params;
|
||||
|
||||
// Reject any compose/env target that resolves inside the `.git`
|
||||
// metadata directory BEFORE we spin up a clone. This blocks a
|
||||
@@ -1069,6 +1232,7 @@ export class GitSourceService {
|
||||
repoUrl,
|
||||
branch,
|
||||
token,
|
||||
sshAuth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
hasPriorHistory: params.hasPriorHistory,
|
||||
priorIdentity: params.priorIdentity,
|
||||
@@ -1138,7 +1302,7 @@ export class GitSourceService {
|
||||
* same clone size/timeout guards as fetch, plus a file-count cap.
|
||||
*/
|
||||
public async listRepoTree(
|
||||
params: { repoUrl: string; branch: string; token?: string | null; timeoutMs?: number },
|
||||
params: { repoUrl: string; branch: string; token?: string | null; sshAuth?: SshDeployKeyAuth | null; timeoutMs?: number },
|
||||
): Promise<{ files: string[]; truncated: boolean; commitSha: string; warnings: string[] }> {
|
||||
return this.withClonedRepo(params, async (dir, commitSha, warnings) => {
|
||||
const { files, truncated } = await this.walkRepoFiles(dir);
|
||||
@@ -1811,7 +1975,7 @@ export class GitSourceService {
|
||||
console.log(`[GitSource:diag] pull start stack=${stackName} branch=${src.branch} host=${repoHost(src.repo_url)}`);
|
||||
}
|
||||
|
||||
const token = src.encrypted_token ? this.crypto.decrypt(src.encrypted_token) : null;
|
||||
const transportAuth = this.resolveTransportAuth(src);
|
||||
const manifestSvc = GitProjectManifestService.getInstance();
|
||||
// Object holder: property access is not narrowed by control-flow
|
||||
// analysis, so the closure assignment below stays visible.
|
||||
@@ -1824,7 +1988,8 @@ export class GitSourceService {
|
||||
branch: src.branch,
|
||||
composePaths: src.compose_paths,
|
||||
envPath: src.sync_env ? src.env_path : null,
|
||||
token,
|
||||
token: transportAuth.token,
|
||||
sshAuth: transportAuth.sshAuth,
|
||||
hasPriorHistory: priorIdentity != null,
|
||||
priorIdentity,
|
||||
onClone: async (cloneDir, commitSha, envContent) => {
|
||||
@@ -2613,19 +2778,47 @@ export class GitSourceService {
|
||||
});
|
||||
const staged: { candidateRelPath: string | null } = { candidateRelPath: null };
|
||||
|
||||
const createDeployKeyTrust = input.authType === 'deploy_key'
|
||||
? (() => {
|
||||
if (!input.deployKey?.trim() || !input.sshKnownHostsEntry?.trim()) {
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
'Deploy key authentication requires a private key and a trusted SSH host key.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
encryptedDeployKey: this.crypto.encrypt(input.deployKey.trim()),
|
||||
...this.resolveSshTrustFromKnownHostsEntry(
|
||||
input.sshKnownHostsEntry,
|
||||
input.sshHostKeyFingerprint,
|
||||
),
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
// 1. Fetch from git BEFORE touching disk or DB. If the fetch
|
||||
// fails there is nothing to clean up. The onClone hook stages
|
||||
// the complete-project candidate inside the clone lifecycle.
|
||||
const manifestSvc = GitProjectManifestService.getInstance();
|
||||
const materialization: { value: MaterializationResult | null } = { value: null };
|
||||
let fetched: FetchResult;
|
||||
const createFetchAuth = input.authType === 'token'
|
||||
? { token: input.token }
|
||||
: createDeployKeyTrust
|
||||
? {
|
||||
sshAuth: {
|
||||
privateKey: input.deployKey!.trim(),
|
||||
knownHostsEntry: createDeployKeyTrust.sshKnownHostsEntry,
|
||||
},
|
||||
}
|
||||
: { token: null };
|
||||
try {
|
||||
fetched = await this.fetchFromGit({
|
||||
repoUrl: input.repoUrl,
|
||||
branch: input.branch,
|
||||
composePaths: input.composePaths,
|
||||
envPath: input.syncEnv ? input.envPath : null,
|
||||
token: input.token,
|
||||
...createFetchAuth,
|
||||
onClone: async (cloneDir, commitSha, envContent) => {
|
||||
// The candidate path is recorded before the build that
|
||||
// creates it, so a crash mid-build still names exactly one
|
||||
@@ -2829,6 +3022,9 @@ export class GitSourceService {
|
||||
encryptedToken: input.authType === 'token' && input.token
|
||||
? this.crypto.encrypt(input.token)
|
||||
: null,
|
||||
encryptedDeployKey: createDeployKeyTrust?.encryptedDeployKey ?? null,
|
||||
sshKnownHostsEntry: createDeployKeyTrust?.sshKnownHostsEntry ?? null,
|
||||
sshHostKeyFingerprint: createDeployKeyTrust?.sshHostKeyFingerprint ?? null,
|
||||
autoApplyOnWebhook: input.autoApplyOnWebhook,
|
||||
autoDeployOnApply: input.autoDeployOnApply,
|
||||
commitSha: fetched.commitSha,
|
||||
@@ -2900,6 +3096,9 @@ export class GitSourceService {
|
||||
env_path: input.syncEnv ? input.envPath : null,
|
||||
auth_type: input.authType,
|
||||
encrypted_token: encryptedToken,
|
||||
encrypted_deploy_key: createDeployKeyTrust?.encryptedDeployKey ?? null,
|
||||
ssh_known_hosts_entry: createDeployKeyTrust?.sshKnownHostsEntry ?? null,
|
||||
ssh_host_key_fingerprint: createDeployKeyTrust?.sshHostKeyFingerprint ?? null,
|
||||
auto_apply_on_webhook: input.autoApplyOnWebhook,
|
||||
auto_deploy_on_apply: input.autoDeployOnApply,
|
||||
last_applied_commit_sha: fetched.commitSha,
|
||||
@@ -2983,6 +3182,14 @@ export class GitSourceService {
|
||||
if (diag) {
|
||||
console.log(`[GitSource:diag] createStackFromGit ok stack=${input.stackName} sha=${fetched.commitSha.slice(0, 7)} envWritten=${envWritten} warnings=${fetched.warnings.length}`);
|
||||
}
|
||||
if (createDeployKeyTrust?.sshHostKeyFingerprint) {
|
||||
this.maybeRecordSshTrustAudit(
|
||||
input.auditContext,
|
||||
input.stackName,
|
||||
createDeployKeyTrust.sshHostKeyFingerprint,
|
||||
'created',
|
||||
);
|
||||
}
|
||||
return { source, commitSha: fetched.commitSha, envWritten, warnings: fetched.warnings };
|
||||
} catch (e) {
|
||||
// Past the success boundary the stack is live and owned by the
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
export type TransportFacingCode =
|
||||
| 'REPO_NOT_FOUND'
|
||||
| 'AUTH_FAILED'
|
||||
| 'SSH_HOST_KEY_FAILED'
|
||||
| 'REF_NOT_FOUND'
|
||||
| 'UNSUPPORTED_REF'
|
||||
| 'NETWORK_TIMEOUT'
|
||||
@@ -104,7 +105,7 @@ export function classifyGitFailure(
|
||||
// stderr guessing.
|
||||
switch (failure.reason) {
|
||||
case 'invalid-url':
|
||||
return { code: 'GIT_ERROR', message: 'Unsupported repository URL. Use an https:// URL without embedded credentials.' };
|
||||
return { code: 'GIT_ERROR', message: 'Unsupported repository URL. Use https:// or SSH (git@host:org/repo.git or ssh://) without embedded credentials.' };
|
||||
case 'invalid-ref':
|
||||
return { code: 'GIT_ERROR', message: 'Unsupported ref name. Use a branch name, a tag name, or a full commit SHA as the remote reports it.' };
|
||||
case 'git-missing':
|
||||
@@ -141,6 +142,20 @@ export function classifyGitFailure(
|
||||
message: PRIVATE_REPO_HINT,
|
||||
};
|
||||
}
|
||||
if (/host key verification failed|remotely changed the ssh host key|no matching host key found|offending key for ip|host key mismatch/.test(raw)) {
|
||||
return {
|
||||
code: 'SSH_HOST_KEY_FAILED',
|
||||
message: 'SSH host key verification failed. The server key changed or is not trusted. Review the fingerprint and update host trust if you intend to accept the new key.',
|
||||
};
|
||||
}
|
||||
if (/permission denied \(publickey|publickey denied|no supported authentication methods/.test(raw)) {
|
||||
return failure.hasToken
|
||||
? { code: 'AUTH_FAILED', message: 'Repository authentication failed. Check your deploy key or token.' }
|
||||
: {
|
||||
code: 'REPO_NOT_FOUND',
|
||||
message: PRIVATE_REPO_HINT,
|
||||
};
|
||||
}
|
||||
if (/authentication failed|\b40[13]\b/.test(raw)) {
|
||||
return failure.hasToken
|
||||
? { code: 'AUTH_FAILED', message: 'Repository authentication failed. Check your token.' }
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
} from './credentialHelper';
|
||||
import { isTransportFailure, type TransportFailure } from './errors';
|
||||
import type { FetchRequest, FetchResult, GitTransport, ResolveRequest, ResolveResult } from './types';
|
||||
import {
|
||||
buildSshCommand,
|
||||
parseRepoTransportUrl,
|
||||
type ParsedRepoUrl,
|
||||
} from './sshTrust';
|
||||
import { writeDeployKey, writeKnownHosts } from './sshCredentialFiles';
|
||||
|
||||
/**
|
||||
* Native git transport: every Git operation is an `execFile`-style spawn of
|
||||
@@ -261,7 +267,12 @@ async function prepareWorkspace(root: string): Promise<WorkspaceLayout> {
|
||||
return { metaDir, hooksDir, homeDir };
|
||||
}
|
||||
|
||||
function buildEnv(homeDir: string, token?: string | null, helperPath?: string | null): NodeJS.ProcessEnv {
|
||||
function buildEnv(
|
||||
homeDir: string,
|
||||
token?: string | null,
|
||||
helperPath?: string | null,
|
||||
sshCommand?: string | null,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
@@ -290,6 +301,9 @@ function buildEnv(homeDir: string, token?: string | null, helperPath?: string |
|
||||
// parses it. See credentialHelper.ts.
|
||||
env[GIT_HELPER_PATH_ENV_VAR] = helperPath;
|
||||
}
|
||||
if (sshCommand) {
|
||||
env.GIT_SSH_COMMAND = sshCommand;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -395,12 +409,16 @@ async function resolveCaArgs(layout: WorkspaceLayout): Promise<string[]> {
|
||||
* Config shared by every invocation. With no helper, credential.helper is
|
||||
* explicitly cleared so nothing from the environment can answer prompts.
|
||||
*/
|
||||
async function commonArgs(layout: WorkspaceLayout, helperPath: string | null): Promise<string[]> {
|
||||
async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ssh: boolean): Promise<string[]> {
|
||||
const args = [
|
||||
'-c', 'protocol.allow=never',
|
||||
'-c', 'protocol.https.allow=always',
|
||||
'-c', `core.hooksPath=${layout.hooksDir.split(path.sep).join('/')}`,
|
||||
];
|
||||
if (ssh) {
|
||||
args.push('-c', 'protocol.ssh.allow=always');
|
||||
} else {
|
||||
args.push('-c', 'protocol.https.allow=always');
|
||||
}
|
||||
args.push('-c', `core.hooksPath=${layout.hooksDir.split(path.sep).join('/')}`);
|
||||
if (process.platform === 'win32') {
|
||||
// With every config channel neutralized above, git falls back to its
|
||||
// build-default TLS backend, which on Git for Windows can be
|
||||
@@ -438,13 +456,18 @@ async function commonArgs(layout: WorkspaceLayout, helperPath: string | null): P
|
||||
async function prepareInvocation(
|
||||
workspaceRoot: string,
|
||||
token?: string | null,
|
||||
sshAuth?: ResolveRequest['sshAuth'],
|
||||
): Promise<{ layout: WorkspaceLayout; env: NodeJS.ProcessEnv; baseArgs: string[] }> {
|
||||
const layout = await prepareWorkspace(workspaceRoot);
|
||||
let sshCommand: string | null = null;
|
||||
if (sshAuth) {
|
||||
const keyPath = await writeDeployKey(layout.metaDir, sshAuth.privateKey);
|
||||
const knownPath = await writeKnownHosts(layout.metaDir, sshAuth.knownHostsEntry);
|
||||
sshCommand = buildSshCommand(keyPath, knownPath);
|
||||
}
|
||||
const helperPath = token ? await writeCredentialHelper(layout.metaDir) : null;
|
||||
const env = buildEnv(layout.homeDir, token, helperPath);
|
||||
// The same helperPath drives the env export and the config arg, so the two
|
||||
// cannot describe different worlds.
|
||||
const baseArgs = await commonArgs(layout, helperPath);
|
||||
const env = buildEnv(layout.homeDir, token, helperPath, sshCommand);
|
||||
const baseArgs = await commonArgs(layout, helperPath, Boolean(sshAuth));
|
||||
return { layout, env, baseArgs };
|
||||
}
|
||||
|
||||
@@ -454,17 +477,19 @@ function invalidUrl(host: string, hasToken: boolean): TransportFailure {
|
||||
return { transportFailure: true as const, reason: 'invalid-url', host, hasToken };
|
||||
}
|
||||
|
||||
function assertValidRepoUrl(repoUrl: string, hasToken: boolean): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(repoUrl);
|
||||
} catch {
|
||||
function assertValidRepoUrl(repoUrl: string, hasToken: boolean): ParsedRepoUrl {
|
||||
const parsed = parseRepoTransportUrl(repoUrl);
|
||||
if (!parsed) {
|
||||
throw invalidUrl('unknown', hasToken);
|
||||
}
|
||||
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) {
|
||||
throw invalidUrl(url.host || 'unknown', hasToken);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function repoHostLabel(repo: ParsedRepoUrl): string {
|
||||
if (repo.kind === 'ssh' && repo.port && repo.port !== 22) {
|
||||
return `${repo.host}:${repo.port}`;
|
||||
}
|
||||
return url;
|
||||
return repo.host;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -604,29 +629,28 @@ interface ResolvedRemoteRefs {
|
||||
* tag's raw line already points at the commit.
|
||||
*/
|
||||
async function lsRemoteRefs(
|
||||
url: URL,
|
||||
repo: ParsedRepoUrl,
|
||||
ref: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
baseArgs: string[],
|
||||
timeoutMs: number,
|
||||
hasToken: boolean,
|
||||
): Promise<ResolvedRemoteRefs> {
|
||||
const host = repoHostLabel(repo);
|
||||
let res: RunResult;
|
||||
try {
|
||||
res = await runGit(
|
||||
[...baseArgs, 'ls-remote', url.href, `refs/heads/${ref}`, `refs/tags/${ref}`, `refs/tags/${ref}^{}`],
|
||||
[...baseArgs, 'ls-remote', repo.href, `refs/heads/${ref}`, `refs/tags/${ref}`, `refs/tags/${ref}^{}`],
|
||||
{ env, timeoutMs: Math.min(timeoutMs, LS_REMOTE_MAX_MS) },
|
||||
);
|
||||
} catch (e) {
|
||||
// A resolution-phase timeout must classify like any other network
|
||||
// timeout, not leak the internal flagged error to callers.
|
||||
if (isTimeoutError(e)) {
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'timeout', host, hasToken } satisfies TransportFailure;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (res.exitCode !== 0) {
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: baseArgs, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: baseArgs, host, hasToken } satisfies TransportFailure;
|
||||
}
|
||||
const found: ResolvedRemoteRefs = { branchSha: null, tagSha: null };
|
||||
for (const line of res.stdout.split(/\r?\n/)) {
|
||||
@@ -667,6 +691,7 @@ export async function verifyFastForward(req: {
|
||||
ancestorSha: string;
|
||||
descendantSha: string;
|
||||
token?: string | null;
|
||||
sshAuth?: ResolveRequest['sshAuth'];
|
||||
timeoutMs?: number;
|
||||
workspaceRoot: string;
|
||||
maxBytes: number;
|
||||
@@ -675,18 +700,19 @@ export async function verifyFastForward(req: {
|
||||
const descendant = req.descendantSha.toLowerCase();
|
||||
if (ancestor === descendant) return true;
|
||||
|
||||
const hasToken = Boolean(req.token);
|
||||
const hasToken = Boolean(req.token) || Boolean(req.sshAuth);
|
||||
await ensureBinaryReady(hasToken);
|
||||
const url = assertValidRepoUrl(req.repoUrl, hasToken);
|
||||
const repo = assertValidRepoUrl(req.repoUrl, hasToken);
|
||||
const host = repoHostLabel(repo);
|
||||
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const remainingMs = (): number => Math.max(1, deadline - Date.now());
|
||||
const assertTimeBudget = (): void => {
|
||||
if (Date.now() >= deadline) {
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'timeout', host, hasToken } satisfies TransportFailure;
|
||||
}
|
||||
};
|
||||
const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token);
|
||||
const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth);
|
||||
const repoDir = path.join(req.workspaceRoot, 'ff-check');
|
||||
await fs.mkdir(repoDir, { recursive: true });
|
||||
|
||||
@@ -700,7 +726,7 @@ export async function verifyFastForward(req: {
|
||||
|
||||
const throwIfSizeExceeded = (): void => {
|
||||
if (sizeExceeded) {
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -718,13 +744,13 @@ export async function verifyFastForward(req: {
|
||||
} catch (e) {
|
||||
throwIfSizeExceeded();
|
||||
if (isTimeoutError(e)) {
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
throwIfSizeExceeded();
|
||||
if (res.exitCode !== 0) {
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: args, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: args, host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
return res;
|
||||
};
|
||||
@@ -736,7 +762,7 @@ export async function verifyFastForward(req: {
|
||||
stderr: res.stderr,
|
||||
exitCode: res.exitCode,
|
||||
argv,
|
||||
host: url.host,
|
||||
host: repoHostLabel(repo),
|
||||
hasToken,
|
||||
} satisfies TransportFailure;
|
||||
};
|
||||
@@ -754,14 +780,14 @@ export async function verifyFastForward(req: {
|
||||
} catch (e) {
|
||||
throwIfSizeExceeded();
|
||||
if (isTimeoutError(e)) {
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
throw {
|
||||
transportFailure: true as const,
|
||||
reason: 'exit',
|
||||
stderr: e instanceof Error ? e.message : String(e),
|
||||
argv: args,
|
||||
host: url.host,
|
||||
host: repoHostLabel(repo),
|
||||
hasToken,
|
||||
} satisfies TransportFailure;
|
||||
}
|
||||
@@ -777,7 +803,7 @@ export async function verifyFastForward(req: {
|
||||
};
|
||||
|
||||
await materialize([...baseArgs, 'init']);
|
||||
await materialize([...baseArgs, 'fetch', '--depth=1', url.href, descendant]);
|
||||
await materialize([...baseArgs, 'fetch', '--depth=1', repo.href, descendant]);
|
||||
|
||||
const countReachable = async (): Promise<number> => {
|
||||
const argv = [...baseArgs, 'rev-list', '--count', descendant];
|
||||
@@ -793,7 +819,7 @@ export async function verifyFastForward(req: {
|
||||
stderr: `unexpected rev-list output: ${listed.stdout}`,
|
||||
exitCode: listed.exitCode,
|
||||
argv,
|
||||
host: url.host,
|
||||
host: repoHostLabel(repo),
|
||||
hasToken,
|
||||
} satisfies TransportFailure;
|
||||
}
|
||||
@@ -815,7 +841,7 @@ export async function verifyFastForward(req: {
|
||||
stderr: `unexpected shallow-repository output: ${shallow.stdout}`,
|
||||
exitCode: shallow.exitCode,
|
||||
argv,
|
||||
host: url.host,
|
||||
host: repoHostLabel(repo),
|
||||
hasToken,
|
||||
} satisfies TransportFailure;
|
||||
};
|
||||
@@ -838,7 +864,7 @@ export async function verifyFastForward(req: {
|
||||
return -1;
|
||||
});
|
||||
if (sizeExceeded || finalSize < 0 || finalSize > req.maxBytes) {
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -861,11 +887,11 @@ export async function verifyFastForward(req: {
|
||||
}
|
||||
|
||||
if (fetchRounds >= MAX_FF_FETCH_ROUNDS) {
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
|
||||
const previousCount = reachableCount;
|
||||
await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, url.href, descendant]);
|
||||
await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, repo.href, descendant]);
|
||||
fetchRounds += 1;
|
||||
reachableCount = await countReachable();
|
||||
|
||||
@@ -874,14 +900,14 @@ export async function verifyFastForward(req: {
|
||||
await assertWithinSizeBudget();
|
||||
return false;
|
||||
}
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
|
||||
deepenStep = Math.min(deepenStep * 2, MAX_FF_DEEPEN_STEP);
|
||||
}
|
||||
} finally {
|
||||
watchdog.stop();
|
||||
await awaitKillConfirmed(breachKill, `size-breach kill for ${url.host}`);
|
||||
await awaitKillConfirmed(breachKill, `size-breach kill for ${repoHostLabel(repo)}`);
|
||||
await fs.rm(repoDir, { recursive: true, force: true }).catch((e: unknown) => {
|
||||
console.warn(`[GitSource:transport] failed to remove fast-forward scratch repo ${repoDir}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
});
|
||||
@@ -890,9 +916,9 @@ export async function verifyFastForward(req: {
|
||||
|
||||
export const nativeGitTransport: GitTransport = {
|
||||
async resolveRef(req: ResolveRequest): Promise<ResolveResult> {
|
||||
const hasToken = Boolean(req.token);
|
||||
const hasToken = Boolean(req.token) || Boolean(req.sshAuth);
|
||||
await ensureBinaryReady(hasToken);
|
||||
const url = assertValidRepoUrl(req.repoUrl, hasToken);
|
||||
const repo = assertValidRepoUrl(req.repoUrl, hasToken);
|
||||
|
||||
if (SHA_PATTERN.test(req.ref)) {
|
||||
// A full SHA is self-resolving: the immutable identity IS the
|
||||
@@ -902,24 +928,24 @@ export const nativeGitTransport: GitTransport = {
|
||||
return { commitSha: req.ref.toLowerCase(), kind: 'sha' };
|
||||
}
|
||||
|
||||
assertValidRef(req.ref, url.host, hasToken);
|
||||
const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token);
|
||||
assertValidRef(req.ref, repoHostLabel(repo), hasToken);
|
||||
const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth);
|
||||
const found = await lsRemoteRefs(
|
||||
url, req.ref, env, baseArgs,
|
||||
repo, req.ref, env, baseArgs,
|
||||
req.timeoutMs ?? DEFAULT_TIMEOUT_MS, hasToken,
|
||||
);
|
||||
if (found.branchSha) return { commitSha: found.branchSha, kind: 'branch' };
|
||||
if (found.tagSha) return { commitSha: found.tagSha, kind: 'tag' };
|
||||
throw { transportFailure: true as const, reason: 'ref-not-found', host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'ref-not-found', host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
},
|
||||
|
||||
async fetchAtCommit(req: FetchRequest): Promise<FetchResult> {
|
||||
const hasToken = Boolean(req.token);
|
||||
const hasToken = Boolean(req.token) || Boolean(req.sshAuth);
|
||||
await ensureBinaryReady(hasToken);
|
||||
const url = assertValidRepoUrl(req.repoUrl, hasToken);
|
||||
assertValidRef(req.ref, url.host, hasToken);
|
||||
const repo = assertValidRepoUrl(req.repoUrl, hasToken);
|
||||
assertValidRef(req.ref, repoHostLabel(repo), hasToken);
|
||||
|
||||
const { layout, env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token);
|
||||
const { layout, env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth);
|
||||
const checkout = path.join(req.workspaceRoot, 'repo');
|
||||
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
@@ -952,22 +978,22 @@ export const nativeGitTransport: GitTransport = {
|
||||
} catch (e) {
|
||||
// A size breach wins over the timeout wording.
|
||||
if (sizeExceeded) {
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
if (isTimeoutError(e)) {
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
// A watchdog-triggered SIGKILL settles runGit's promise via the
|
||||
// child's normal 'close' event (code null -> exitCode -1), not
|
||||
// a rejection, so this is the common path for an in-flight
|
||||
// breach and must check sizeExceeded before the generic mapping.
|
||||
if (sizeExceeded) {
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
if (res.exitCode !== 0) {
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: args, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: args, host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
return res;
|
||||
};
|
||||
@@ -979,7 +1005,7 @@ export const nativeGitTransport: GitTransport = {
|
||||
// SHA (GitHub does by default); a refusal surfaces as a
|
||||
// non-zero `git fetch` here and classifies as UNSUPPORTED_REF.
|
||||
await materialize([...baseArgs, 'init', checkout]);
|
||||
await materialize([...baseArgs, '-C', checkout, 'fetch', '--depth=1', url.href, req.ref]);
|
||||
await materialize([...baseArgs, '-C', checkout, 'fetch', '--depth=1', repo.href, req.ref]);
|
||||
await materialize([...baseArgs, '-C', checkout, 'checkout', '--detach', req.ref]);
|
||||
} else {
|
||||
// A bare name works for both branches and tags: `--branch`
|
||||
@@ -992,7 +1018,7 @@ export const nativeGitTransport: GitTransport = {
|
||||
await materialize([
|
||||
...baseArgs, 'clone',
|
||||
'--depth=1', '--single-branch', '--no-tags', '--no-recurse-submodules',
|
||||
'--branch', branchArg, url.href, checkout,
|
||||
'--branch', branchArg, repo.href, checkout,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1005,20 +1031,20 @@ export const nativeGitTransport: GitTransport = {
|
||||
});
|
||||
actual = head.stdout.trim().toLowerCase();
|
||||
if (!SHA_PATTERN.test(actual)) {
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: `unexpected rev-parse output: ${head.stdout}`, exitCode: head.exitCode, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: `unexpected rev-parse output: ${head.stdout}`, exitCode: head.exitCode, host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
} catch (e) {
|
||||
if (isTransportFailure(e)) throw e;
|
||||
if (isTimeoutError(e)) {
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
|
||||
if (actual !== req.commitSha.toLowerCase()) {
|
||||
// The branch tip moved between resolution and fetch. Refuse
|
||||
// rather than materialize content nobody reviewed.
|
||||
throw { transportFailure: true as const, reason: 'tip-changed', host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'tip-changed', host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
|
||||
// Deterministic final measure: a breach landing between the last
|
||||
@@ -1031,13 +1057,13 @@ export const nativeGitTransport: GitTransport = {
|
||||
return -1;
|
||||
});
|
||||
if (sizeExceeded || finalSize < 0 || finalSize > req.maxBytes) {
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure;
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: repoHostLabel(repo), hasToken } satisfies TransportFailure;
|
||||
}
|
||||
|
||||
return { commitSha: actual, dir: checkout };
|
||||
} finally {
|
||||
watchdog.stop();
|
||||
await awaitKillConfirmed(breachKill, `size-breach kill for ${url.host}`);
|
||||
await awaitKillConfirmed(breachKill, `size-breach kill for ${repoHostLabel(repo)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { canonicalizeDeployKeyPem, canonicalizeKnownHostsEntry } from './sshTrust';
|
||||
|
||||
/** Materialize a validated deploy key PEM for one git/ssh invocation (mode 0600). */
|
||||
export async function writeDeployKey(metaDir: string, pem: string): Promise<string> {
|
||||
const keyPath = path.join(metaDir, 'deploy-key');
|
||||
const canonical = canonicalizeDeployKeyPem(pem);
|
||||
await fs.writeFile(keyPath, canonical, { mode: 0o600 });
|
||||
return keyPath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
/** Materialize a validated known_hosts entry for one git/ssh invocation (mode 0600). */
|
||||
export async function writeKnownHosts(metaDir: string, entry: string): Promise<string> {
|
||||
const knownHostsPath = path.join(metaDir, 'known_hosts');
|
||||
const canonical = canonicalizeKnownHostsEntry(entry);
|
||||
await fs.writeFile(knownHostsPath, canonical, { mode: 0o600 });
|
||||
return knownHostsPath.split(path.sep).join('/');
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -18,11 +18,18 @@
|
||||
|
||||
export type RefKind = 'branch' | 'tag' | 'sha';
|
||||
|
||||
/** Deploy-key authentication material for SSH transports. */
|
||||
export interface SshDeployKeyAuth {
|
||||
privateKey: string;
|
||||
knownHostsEntry: string;
|
||||
}
|
||||
|
||||
export interface ResolveRequest {
|
||||
repoUrl: string;
|
||||
/** Configured ref: a branch name, a tag name, or a full 40/64-hex commit SHA. */
|
||||
ref: string;
|
||||
token?: string | null;
|
||||
sshAuth?: SshDeployKeyAuth | null;
|
||||
/**
|
||||
* Total fetch budget in milliseconds. Note: the resolution round trip
|
||||
* (ls-remote) is internally capped at 10s regardless of this value, so
|
||||
|
||||
@@ -64,13 +64,6 @@ function envelopeFor(checkpoint: GitOpsCreateCheckpointRow) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Three states, because the two callers need opposite fail-safe directions.
|
||||
*
|
||||
* Teardown must not treat "cannot tell" as absent, or it would skip a directory
|
||||
* that is really there. Completion must not treat it as present, or it would
|
||||
* mark a create live on the strength of a failed stat.
|
||||
*/
|
||||
/**
|
||||
* Clear a settled create's staging marker, reporting rather than throwing.
|
||||
*
|
||||
@@ -94,6 +87,13 @@ async function clearSettledMarker(stackName: string, managedRoot: string): Promi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Three states, because the two callers need opposite fail-safe directions.
|
||||
*
|
||||
* Teardown must not treat "cannot tell" as absent, or it would skip a directory
|
||||
* that is really there. Completion must not treat it as present, or it would
|
||||
* mark a create live on the strength of a failed stat.
|
||||
*/
|
||||
async function stackDirState(stackName: string): Promise<'present' | 'absent' | 'unknown'> {
|
||||
try {
|
||||
const base = FileSystemService.getInstance().getBaseDir();
|
||||
@@ -262,8 +262,11 @@ async function resolveOne(checkpoint: GitOpsCreateCheckpointRow): Promise<Create
|
||||
context_dir: checkpoint.context_dir,
|
||||
sync_env: checkpoint.sync_env === 1,
|
||||
env_path: checkpoint.env_path,
|
||||
auth_type: checkpoint.auth_type as 'none' | 'token',
|
||||
auth_type: checkpoint.auth_type as 'none' | 'token' | 'deploy_key',
|
||||
encrypted_token: checkpoint.encrypted_token,
|
||||
encrypted_deploy_key: checkpoint.encrypted_deploy_key,
|
||||
ssh_known_hosts_entry: checkpoint.ssh_known_hosts_entry,
|
||||
ssh_host_key_fingerprint: checkpoint.ssh_host_key_fingerprint,
|
||||
auto_apply_on_webhook: checkpoint.auto_apply_on_webhook === 1,
|
||||
auto_deploy_on_apply: checkpoint.auto_deploy_on_apply === 1,
|
||||
last_applied_commit_sha: checkpoint.commit_sha,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NodeRegistry } from '../NodeRegistry';
|
||||
import { MANAGED_ROOT_NAME } from './managedPaths';
|
||||
import { encodeGitOpsJson } from './json';
|
||||
import { materializationFingerprint } from './fingerprint';
|
||||
import { parseHttpsRepoUrl, parseLegacyRepoUrl, secretFreeRepoUrl, serializeRepoIdentity, type RepoIdentity } from './repoIdentity';
|
||||
import { parseLegacyRepoUrl, parseStorableRepoUrl, secretFreeRepoUrl, secretFreeRepoUrlFromStorable, serializeRepoIdentity, serializeRepoIdentityFromStorable, type RepoIdentity } from './repoIdentity';
|
||||
import type { RefKind } from '../git/types';
|
||||
import type {
|
||||
GitOpsApplicationRow,
|
||||
@@ -44,9 +44,19 @@ export class GitOpsIdentityError extends Error {
|
||||
* secret-free identity that gets persisted, never from the raw operational URL.
|
||||
*/
|
||||
export function directSourceIdentity(config: DirectSourceConfig): DirectSourceIdentity {
|
||||
const parsed = parseHttpsRepoUrl(config.repoUrl);
|
||||
const parsed = parseStorableRepoUrl(config.repoUrl);
|
||||
if (!parsed.ok) throw new GitOpsIdentityError(`repository URL is not storable: ${parsed.reason}`);
|
||||
return directSourceIdentityFromUrl(config, parsed.url);
|
||||
const identity = serializeRepoIdentityFromStorable(parsed);
|
||||
const repoUrl = secretFreeRepoUrlFromStorable(parsed);
|
||||
const fingerprint = materializationFingerprint({
|
||||
repoIdentity: identity,
|
||||
configuredRef: config.branch,
|
||||
composePaths: config.composePaths,
|
||||
contextDir: config.contextDir,
|
||||
syncEnv: config.syncEnv,
|
||||
envPath: config.envPath,
|
||||
});
|
||||
return { repoUrl, identity, fingerprint };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,6 +228,9 @@ export function buildCreateCheckpointRow(args: {
|
||||
identity: DirectSourceIdentity;
|
||||
authType: string;
|
||||
encryptedToken: string | null;
|
||||
encryptedDeployKey?: string | null;
|
||||
sshKnownHostsEntry?: string | null;
|
||||
sshHostKeyFingerprint?: string | null;
|
||||
autoApplyOnWebhook: boolean;
|
||||
autoDeployOnApply: boolean;
|
||||
commitSha: string;
|
||||
@@ -241,6 +254,9 @@ export function buildCreateCheckpointRow(args: {
|
||||
env_path: args.config.syncEnv ? args.config.envPath : null,
|
||||
auth_type: args.authType,
|
||||
encrypted_token: args.encryptedToken,
|
||||
encrypted_deploy_key: args.encryptedDeployKey ?? null,
|
||||
ssh_known_hosts_entry: args.sshKnownHostsEntry ?? null,
|
||||
ssh_host_key_fingerprint: args.sshHostKeyFingerprint ?? null,
|
||||
auto_apply_on_webhook: args.autoApplyOnWebhook ? 1 : 0,
|
||||
auto_deploy_on_apply: args.autoDeployOnApply ? 1 : 0,
|
||||
commit_sha: args.commitSha,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseSshUrl, type ParsedSshRepoUrl } from '../git/sshTrust';
|
||||
|
||||
// Upper bound so a caller cannot flood the service with a huge payload.
|
||||
// Generous compared to anything a real Git provider emits.
|
||||
export const MAX_REPO_URL_LENGTH = 2048;
|
||||
@@ -83,14 +85,51 @@ export function secretFreeRepoUrl(identity: RepoIdentity): string {
|
||||
return `https://${identity.host}${identity.pathname}`;
|
||||
}
|
||||
|
||||
export type ParseStorableRepoUrlResult =
|
||||
| { ok: true; kind: 'https'; url: URL }
|
||||
| { ok: true; kind: 'ssh'; ssh: ParsedSshRepoUrl }
|
||||
| { ok: false; reason: 'too_long' | 'invalid' | 'not_supported' | 'userinfo' | 'query' | 'fragment' };
|
||||
|
||||
export function parseStorableRepoUrl(raw: string): ParseStorableRepoUrlResult {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0 || trimmed.length > MAX_REPO_URL_LENGTH) {
|
||||
return { ok: false, reason: trimmed.length > MAX_REPO_URL_LENGTH ? 'too_long' : 'invalid' };
|
||||
}
|
||||
const https = parseHttpsRepoUrl(trimmed);
|
||||
if (https.ok) return { ok: true, kind: 'https', url: https.url };
|
||||
const ssh = parseSshUrl(trimmed);
|
||||
if (ssh) return { ok: true, kind: 'ssh', ssh };
|
||||
if (!https.ok && https.reason !== 'not_https') {
|
||||
return { ok: false, reason: https.reason };
|
||||
}
|
||||
return { ok: false, reason: 'not_supported' };
|
||||
}
|
||||
|
||||
export function serializeRepoIdentityFromStorable(parsed: ParseStorableRepoUrlResult & { ok: true }): RepoIdentity {
|
||||
if (parsed.kind === 'https') {
|
||||
return serializeRepoIdentity(parsed.url);
|
||||
}
|
||||
const host = parsed.ssh.port === 22 ? parsed.ssh.host : `${parsed.ssh.host}:${parsed.ssh.port}`;
|
||||
return { host, pathname: parsed.ssh.pathname };
|
||||
}
|
||||
|
||||
export function secretFreeRepoUrlFromStorable(parsed: ParseStorableRepoUrlResult & { ok: true }): string {
|
||||
if (parsed.kind === 'https') {
|
||||
return secretFreeRepoUrl(serializeRepoIdentity(parsed.url));
|
||||
}
|
||||
const ssh = parsed.ssh;
|
||||
const portSuffix = ssh.port === 22 ? '' : `:${ssh.port}`;
|
||||
return `ssh://git@${ssh.host}${portSuffix}${ssh.pathname}`;
|
||||
}
|
||||
|
||||
export function repoUrlRejectionMessage(raw: string): string | null {
|
||||
const parsed = parseHttpsRepoUrl(raw);
|
||||
const parsed = parseStorableRepoUrl(raw);
|
||||
if (parsed.ok) return null;
|
||||
switch (parsed.reason) {
|
||||
case 'too_long':
|
||||
return 'repo_url is too long';
|
||||
case 'not_https':
|
||||
return 'Only HTTPS repository URLs are supported';
|
||||
case 'not_supported':
|
||||
return 'Use an https:// URL or an SSH URL (git@host:org/repo.git or ssh://)';
|
||||
case 'userinfo':
|
||||
return 'Repository URL must not include userinfo';
|
||||
case 'query':
|
||||
|
||||
@@ -31,6 +31,9 @@ CREATE TABLE IF NOT EXISTS gitops_create_checkpoints (
|
||||
env_path TEXT NULL,
|
||||
auth_type TEXT NOT NULL,
|
||||
encrypted_token TEXT NULL,
|
||||
encrypted_deploy_key TEXT NULL,
|
||||
ssh_known_hosts_entry TEXT NULL,
|
||||
ssh_host_key_fingerprint TEXT NULL,
|
||||
auto_apply_on_webhook INTEGER NOT NULL DEFAULT 0,
|
||||
auto_deploy_on_apply INTEGER NOT NULL DEFAULT 0,
|
||||
commit_sha TEXT NULL,
|
||||
|
||||
@@ -265,13 +265,15 @@ export class GitOpsStore {
|
||||
`INSERT INTO gitops_create_checkpoints (
|
||||
application_id, stack_name, phase, generation_id, operation_id, repo_url, branch,
|
||||
compose_path, compose_paths_json, context_dir, sync_env, env_path, auth_type,
|
||||
encrypted_token, auto_apply_on_webhook, auto_deploy_on_apply, commit_sha,
|
||||
encrypted_token, encrypted_deploy_key, ssh_known_hosts_entry, ssh_host_key_fingerprint,
|
||||
auto_apply_on_webhook, auto_deploy_on_apply, commit_sha,
|
||||
applied_spec_json, created_managed_root, created_at, updated_at
|
||||
) VALUES (${Array(21).fill('?').join(', ')})`,
|
||||
) VALUES (${Array(24).fill('?').join(', ')})`,
|
||||
).run(
|
||||
row.application_id, row.stack_name, row.phase, row.generation_id, row.operation_id,
|
||||
row.repo_url, row.branch, row.compose_path, row.compose_paths_json, row.context_dir,
|
||||
row.sync_env, row.env_path, row.auth_type, row.encrypted_token, row.auto_apply_on_webhook,
|
||||
row.sync_env, row.env_path, row.auth_type, row.encrypted_token, row.encrypted_deploy_key,
|
||||
row.ssh_known_hosts_entry, row.ssh_host_key_fingerprint, row.auto_apply_on_webhook,
|
||||
row.auto_deploy_on_apply, row.commit_sha, row.applied_spec_json, row.created_managed_root,
|
||||
row.created_at, row.updated_at,
|
||||
);
|
||||
|
||||
@@ -120,6 +120,9 @@ export type GitOpsCreateCheckpointRow = {
|
||||
env_path: string | null;
|
||||
auth_type: string;
|
||||
encrypted_token: string | null;
|
||||
encrypted_deploy_key: string | null;
|
||||
ssh_known_hosts_entry: string | null;
|
||||
ssh_host_key_fingerprint: string | null;
|
||||
auto_apply_on_webhook: number;
|
||||
auto_deploy_on_apply: number;
|
||||
commit_sha: string | null;
|
||||
|
||||
Reference in New Issue
Block a user