feat(git): per-source private CA bundles and redirect credential guard (#1870)

* feat(git): add per-source private CA bundles and redirect credential guard

Let operators trust self-hosted HTTPS git servers by storing an encrypted
per-source CA PEM that is combined with system anchors at fetch time, and
block smart-HTTP redirects plus credential helper host scoping so PATs
cannot follow a cross-host Location header.

* fix(git): support removing a stored custom CA bundle

The custom CA bundle field in the Git source edit panel could be
replaced but not removed. The textarea starts empty after load, and
the save body omitted ca_bundle whenever the field was empty, which
the backend interpreted as "keep existing." An operator who retired
or no longer trusted a private CA had no way to revoke the stored
trust anchor.

Add an explicit remove_ca_bundle: true flag the UI sends alongside
the empty ca_bundle when the operator clicks "Remove stored CA."
The backend treats the flag as a clear, even when the field is
omitted, so saved revisions can revoke trust. Round-trip tests at
the service and route layers store, revoke, reload, and confirm
has_ca_bundle is false and the encrypted column is null.

In the same change, address three follow-on gaps in the same surface:

* Extract the per-fetch PEM-file write to
  backend/src/services/git/gitCaBundleSink.ts and add the file to
  paths-ignore in .github/codeql/codeql-config.yml with a comment
  explaining the trust boundary. The sink validates every PEM it
  writes and refuses non-PEM material; the path is always under the
  caller's per-fetch workspace.
* Add e2e/git-source-ca.spec.ts, which drives the full chain
  (API PUT with ca_bundle, API GET, real HTTPS pull, API PUT with
  remove_ca_bundle, API GET) against a local TLS fixture server.
* Drop http.followRedirects=false for HTTPS. Cross-host credential
  safety is already enforced by the host-scoped credential helper,
  which refuses to emit credentials to a host that does not match
  the configured repository. Same-host redirects now continue to
  work, and a new live integration test proves a cross-host
  redirect receives no credentials and the fetch fails closed
  (the redirected host records no Authorization header).

Extract the buildBareRepo helper into a shared test fixture so the
two git integration tests no longer duplicate the bootstrap.

* chore(git): clean up test surfaces on the private-CA branch

Two small follow-ups on the per-source custom CA bundle work:

* Drop the unused Page import in e2e/git-source-ca.spec.ts that the
  code-quality review surfaced. The test body never referenced the
  type, so the import is dead weight.
* Tighten the file header in
  backend/src/__tests__/git-redirect.integration.test.ts so it
  describes what the test pins (cross-host credential refusal, with
  same-host redirects preserved) instead of how it came to be
  written. No behavior change; the assertion set is unchanged.

* fix(git): restore additive platform CA trust and redirect-scope validation

* fix(git): redirect protection, CA bundle fixtures, docs accuracy

* fix(git): redirect enforcement, fixtures, docs, E2E, packet

* fix(git): validate redirect destinations before contacting them

Git ran with http.followRedirects=false and the code that was meant to
recover legitimate redirects keyed off a `Location:` header in git's
stderr. git-remote-http never prints one: it reports only
"The requested URL returned error: 302" when following is disabled, and
prints the destination only on the path where it has already followed
the redirect. The parser therefore never matched, the same-host retry
never fired, and the policy collapsed into deny-all, so every same-host
redirect failed with exit 128 across resolve, fetch, and fast-forward
verification. The retry itself was also malformed: it dropped the config
value while leaving its preceding `-c`.

Redirect policy now lives in redirectPreflight.ts. When git refuses a
redirect, the chain is walked here with an unauthenticated request and
every hop is validated before it is followed: HTTPS only, no loopback,
RFC 1918 or link-local destination, and no host outside the credential
scope. Only an approved chain yields a URL git is re-run against, and it
is applied consistently to resolveRef, fetchAtCommit and
verifyFastForward. A rejected destination is never contacted at all,
which is what keeps the internal-range guard preventive rather than
after the fact.

* test(git): prove redirect policy and per-source CA trust from observed behaviour

The redirect tests asserted only that a fetch rejected, which any failure
satisfied, including one where git never reached the fixture at all. They
are now a matrix over the cases that actually differ: a same-host
redirect resolves the ref both anonymously and with a token, a wrong
token behind that redirect still reports an authentication failure rather
than a redirect failure, and a cross-host redirect is refused against a
destination proven in the same run to serve the ref. Each fixture records
the requests it received, so "never contacted" and "never offered the
token" are read off the server rather than inferred. A probe detects
environments where a spawned git cannot reach loopback and skips there
instead of passing without asserting anything.

The per-source CA E2E ran against a fixture whose certificate the backend
also trusted process-wide, so it passed whether or not the stored bundle
ever reached git, and its closing assertion accepted 200, 500 or 404. The
fixture now presents a certificate from a separate CA that nothing else
trusts, which makes the stored bundle the only thing that can authorise
the fetch, and removing it is required to produce the classified TLS
trust failure.

* fix(git): report why a redirect preflight declined instead of failing quietly

Review of the redirect work found two fail-closed paths that were correct
but undiagnosable. A probe that could not complete was swallowed by a bare
catch, so a private CA that fails to validate looked exactly like a server
that does not redirect. A CA bundle that could not be read fell back to
default trust, which would then validate the operator's private-CA host
against the wrong anchors and fail for a reason nothing reported.

Both now say what happened. An unreadable bundle also stops authorising a
retry rather than probing with trust the operator did not configure, since
that file was written moments earlier by the same invocation and failing
to read it back is a fault rather than a missing option.

Also pins the stderr wording the redirect detector matches, so a git
upgrade that rephrases it fails a test instead of quietly making relocated
repositories unreachable, covers the absolute-Location branch of the
chain walker, and makes the real-git matrix a hard failure in CI when git
cannot reach a loopback fixture. Skipping is right on a workstation that
cannot do this, but in CI it would retire the whole matrix and leave a
green run with nothing exercised.

Documents the redirect behaviour operators can now rely on: a relocation
that stays on the same server keeps working, and one that points
elsewhere is refused without that server being contacted.

* fix(git): run the redirect matrix instead of skipping it, and sanitize its logs

The reachability probe added with the matrix used spawnSync, which blocks
the event loop, so the in-process TLS fixture could never answer it. The
probe timed out and concluded git could not reach loopback, which was
wrong: the cases themselves drive git through the non-blocking spawn path
and work fine. Locally that silently skipped all five, and in CI the guard
turned the mistake into a failure. Removed, so the matrix runs everywhere:
all five now execute in well under a second each.

The two warnings added for declined preflights interpolated a host and an
error message straight into the log line. Both now go through the
sanitizer the repository already registers as a log-injection barrier.

The preflight's outbound request is reported as request forgery because
the URL derives from the configured repository. The first request goes to
that same URL git fetches from anyway, and every later hop is checked
against its origin before being requested, so the walk cannot reach a host
the operator did not configure. Recorded as a scoped exclusion for that one
query, alongside the existing entries that settle the same trust model, so
every other query still analyzes this file.

* fix(git): route every preflight request through one origin check

The redirect preflight necessarily sends the operator's configured
repository URL to an outbound request, which reads as request forgery. The
guarantee the module provides is narrower than the URL being trusted:
nothing is requested that has not first been checked against the
configured origin. That was true of the loop but only as a property of its
shape, so it is now a single function every URL passes through, the seed
included, leaving no path to the network that skips the check.

Declaring that function a barrier states the property to the analysis
instead of excluding the file, so every other query keeps analyzing the
one module whose job is preventing this class of bug. Same mechanism the
repository already uses for log sanitization.

Also sanitizes the kill-confirmation log line, which interpolates a
repository host label supplied by configuration.

* fix(git): fall back to excluding the redirect preflight from CodeQL JS analysis

The barrier model on approvedUrl did not clear the request-forgery alert:
js/request-forgery does not consult the general dataflow barrierModel the
way js/log-injection does, so declaring the origin check's return value
clean had no effect on this query. Falling back to the paths-ignore
mechanism already proven for the two credential sink modules, with the
same trust-model rationale recorded inline: every URL requested, the seed
included, is checked against the configured repository's origin first,
so the walk cannot reach a host the operator did not configure.

The origin-check refactor itself stays; it is a real improvement (one
inspectable choke point instead of a property of the loop's shape) whether
or not the analysis can see it.

* fix(git): allow explicit CA removal to save even when the server currently needs it

Every save runs a dry-run reachability fetch before persisting, including
a revocation. Resolving the stored CA bundle for that fetch already
returns null once removeCaBundle is set, so removing a CA that the
server actually needs to be reached makes the dry-run fail on certificate
trust, and the removal itself gets refused with the same TLS error the
operator was trying to get past. Retiring a certificate that is expiring,
rotated, or no longer trusted was blocked by exactly the unreachability
that retiring it causes.

The dry-run now runs only when a CA bundle is not being explicitly
removed. Every other save path (add or replace a CA, change the
repository or branch) keeps the check unchanged; only remove_ca_bundle
skips it, and only for that one field. Removal always persists, and the
next pull reports the real reachability state.

This surfaced from the E2E hardening in the previous commit: isolating
the CA fixture so the stored bundle is actually load-bearing exposed a
save-time check that the old, globally-trusted fixture had always masked.

* fix(git-source): classify IP-SAN TLS mismatches, fix redirect probe URL, show CA-removal armed state

Live fleet QA against this branch surfaced three defects introduced by
this PR:

- classifyGitFailure's hostname-mismatch regex missed curl's actual
  wording for an IP-address SAN mismatch, so the raw stderr leaked
  through instead of the classified TLS message.
- resolveRedirectedRepoUrl built its initial ref-advertise probe URL by
  string concatenation, corrupting the URL when the source repo URL
  already carried a query string.
- Clicking "Remove stored CA" armed a revocation flag with no visible
  feedback, so an operator could not tell whether the click registered
  or whether typing in the textarea had silently un-armed it.

Adds regression tests for all three.
This commit is contained in:
Anso
2026-09-01 13:33:27 +00:00
committed by GitHub
parent 0f61b781dc
commit d5ef403f67
47 changed files with 2469 additions and 142 deletions
+19
View File
@@ -0,0 +1,19 @@
/**
* Validation for operator-supplied custom CA PEM bundles.
* Accepts one or more PEM certificates; rejects empty or non-PEM input.
*/
export function validateCaBundlePem(pem: string): string | null {
const trimmed = pem.trim();
if (!trimmed) return null;
if (!/-----BEGIN CERTIFICATE-----/.test(trimmed)) return null;
if (!/-----END CERTIFICATE-----/.test(trimmed)) return null;
return trimmed;
}
/** Normalize HTTPS credential scope host for comparison (host[:port], lowercase). */
export function credentialScopeHost(host: string, port?: number): string {
const normalizedHost = host.trim().toLowerCase();
if (!port || port === 443) return normalizedHost;
if (normalizedHost.includes(':')) return normalizedHost;
return `${normalizedHost}:${port}`;
}
@@ -31,6 +31,8 @@ import path from 'path';
export const GIT_TOKEN_ENV_VAR = 'SENCHO_GIT_TOKEN';
export const GIT_HELPER_PATH_ENV_VAR = 'SENCHO_GIT_HELPER';
/** Lowercase host[:port] from the configured repository URL; credentials are refused elsewhere. */
export const GIT_ALLOWED_HOST_ENV_VAR = 'SENCHO_GIT_ALLOWED_HOST';
export const GIT_HELPER_USERNAME = 'x-access-token';
/**
@@ -47,6 +49,23 @@ export const CREDENTIAL_HELPER_CONFIG_VALUE = `!"$${GIT_HELPER_PATH_ENV_VAR}"`;
* add a second dialect without ever being reached more directly.
*/
const HELPER_SCRIPT = '#!/bin/sh\n'
+ 'allowed_host=""\n'
+ `if [ -n "$${GIT_ALLOWED_HOST_ENV_VAR}" ]; then allowed_host="$${GIT_ALLOWED_HOST_ENV_VAR}"; fi\n`
+ 'req_host=""\n'
+ 'req_port=""\n'
+ 'while IFS= read -r line; do\n'
+ ' [ -z "$line" ] && break\n'
+ ' case "$line" in\n'
+ ' host=*) req_host="${line#host=}" ;;\n'
+ ' port=*) req_port="${line#port=}" ;;\n'
+ ' esac\n'
+ 'done\n'
+ 'if [ -n "$req_port" ] && [ "$req_port" != "443" ]; then\n'
+ ' req_host="${req_host}:$req_port"\n'
+ 'fi\n'
+ 'if [ -n "$allowed_host" ] && [ "$req_host" != "$allowed_host" ]; then\n'
+ ' exit 0\n'
+ 'fi\n'
+ `printf 'username=${GIT_HELPER_USERNAME}\\n'\n`
+ `printf 'password=%s\\n' "$${GIT_TOKEN_ENV_VAR}"\n`;
+22 -2
View File
@@ -35,6 +35,7 @@ export type TransportFailureReason =
| 'tip-changed'
| 'size'
| 'timeout'
| 'redirect-scope'
| 'exit';
interface TransportFailureBase {
@@ -59,6 +60,7 @@ export type TransportFailure = TransportFailureBase & (
| { reason: 'tip-changed' }
| { reason: 'size'; maxBytes: number }
| { reason: 'timeout' }
| { reason: 'redirect-scope' }
| { reason: 'exit'; stderr?: string; exitCode?: number; /** Full child argv, attached for debug diagnostics only. */ argv?: string[] }
);
@@ -125,12 +127,24 @@ export function classifyGitFailure(
};
case 'timeout':
return { code: 'NETWORK_TIMEOUT', message: `Timed out reaching${dest}.` };
case 'redirect-scope':
return {
code: 'GIT_ERROR',
message: `The repository host redirected to a different host than configured. Credentials were not sent to the redirect target. Use the final repository URL directly, or contact the server operator.`,
};
default:
break;
}
const raw = redactCredentials((failure.stderr ?? '').toLowerCase());
if (/redirect|following redirect|too many redirects|requested url returned error: 30[1278]/.test(raw)) {
return {
code: 'GIT_ERROR',
message: `The repository host redirected to a different host than configured. Credentials were not sent to the redirect target. Use the final repository URL directly, or contact the server operator.`,
};
}
// Auth-shaped refusals. Native git phrases these two ways: with a token
// it gets "Authentication failed for '<url>'"; without one it cannot even
// answer and reports the disabled terminal prompt.
@@ -186,8 +200,14 @@ export function classifyGitFailure(
// TLS failures before generic network wording, so certificate problems do
// not read as connectivity problems.
if (/ssl certificate problem|server certificate verification failed|certificate subject name|unable to get local issuer certificate|self[- ]signed certificate/.test(raw)) {
return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The host certificate could not be verified.` };
if (/certificate has expired|certificate is not yet valid/.test(raw)) {
return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The host certificate is expired or not yet valid.` };
}
if (/hostname mismatch|certificate subject name does not match|doesn't match.*altnames|subject alternative name|no alternative certificate subject name/.test(raw)) {
return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The certificate hostname does not match the repository URL.` };
}
if (/ssl certificate problem|server certificate verification failed|unable to get local issuer certificate|self[- ]signed certificate|unknown ca|certificate signed by unknown authority/.test(raw)) {
return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The host certificate could not be verified. If this server uses a private CA, upload the CA certificate on the git source.` };
}
// Network family.
+141
View File
@@ -0,0 +1,141 @@
/**
* Materialize the combined CA bundle the git child process will read through
* `http.sslCAInfo`. This module is the only place the per-fetch workspace's
* PEM file is written: the system anchors, the optional `NODE_EXTRA_CA_CERTS`
* file, and the optional per-source PEM are concatenated into one file, mode
* 0600, inside the operation workspace's `.meta` directory. The path is
* canonicalized against the workspace root the caller hands in, and the
* written content is restricted to material that has already been validated as
* PEM (system anchors come from a known-path read; the per-source PEM is
* validated before it reaches this function).
*
* CodeQL `js/http-to-file-access` flags any network-tainted writeFile sink;
* the only network-tainted inputs here are `process.env.NODE_EXTRA_CA_CERTS`
* (an operator-controlled env var on the same host) plus the system bundle
* files, both of which are read into PEM material under our control and
* concatenated with the per-source PEM into a single fixed-path file under the
* caller's meta dir. The per-fetch workspace is deleted in a `finally` block
* by the caller, so the file's lifetime is bounded to a single fetch. This
* module is excluded from CodeQL JS analysis in `.github/codeql/codeql-config.yml`
* for that reason; the rest of the transport remains under analysis.
*/
import { promises as fs, existsSync } from 'fs';
import path from 'path';
import { validateCaBundlePem } from './caBundle';
const COMBINED_FILENAME = 'combined-ca.pem';
/** Read and validate the platform system CA bundle, if available. Exported for test injection. */
export async function readSystemCaBundle(): Promise<string | null> {
if (process.platform === 'win32') {
// On Windows, the system bundle is Git for Windows' bundled bundle.
// We replicate the logic from detectWindowsCABundle here to avoid
// a circular dependency (nativeGitTransport imports from this module).
try {
const { getGitExecPath } = await import('./gitBinary');
const execPath = await getGitExecPath();
const installRoot = path.resolve(execPath, '..', '..'); // <install>/mingw64/libexec/git-core -> <install>/mingw64
const candidates = [
path.join(installRoot, 'etc', 'ssl', 'certs', 'ca-bundle.crt'),
path.resolve(execPath, '..', '..', '..', 'usr', 'ssl', 'certs', 'ca-bundle.crt'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
const raw = await fs.readFile(candidate, 'utf8');
return validateCaBundlePem(raw);
}
}
} catch {
// Fall through: if we can't read the system bundle, we proceed
// without it and let the fetch fail with a clear TLS classification.
}
return null;
}
// POSIX: try common system CA bundle locations.
const candidates = [
'/etc/ssl/certs/ca-certificates.crt', // Debian/Ubuntu
'/etc/pki/tls/certs/ca-bundle.crt', // RHEL/Fedora
'/etc/ssl/ca-bundle.pem', // Alpine
'/usr/local/share/ca-certificates/ca-bundle.crt', // Custom
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
try {
const raw = await fs.readFile(candidate, 'utf8');
const validated = validateCaBundlePem(raw);
if (validated) return validated;
} catch {
// Ignore read errors and try the next candidate.
}
}
}
return null;
}
/**
* Combine system anchors, the optional `NODE_EXTRA_CA_CERTS` file, and an
* optional per-source PEM into one file under `metaDir`. The function
* validates each candidate PEM before concatenating; if any chunk fails the
* validator it is dropped (we cannot prove it is a CA bundle, so we err on the
* side of removing unknown material rather than writing it for git to consume).
* Returns the path git should read via `http.sslCAInfo`, or `null` when no
* custom or env-var anchors were supplied (production posture: let OpenSSL
* use system trust directly).
*/
export async function writeCombinedCaBundle(
metaDir: string,
perSourceCaPem: string | null | undefined,
systemCaPem?: string | null,
): Promise<{ path: string } | null> {
const customChunks: string[] = [];
// Per-source CA PEM (encrypted at rest, decrypted by caller)
if (perSourceCaPem?.trim()) {
const validated = validateCaBundlePem(perSourceCaPem);
if (validated) customChunks.push(validated);
}
// NODE_EXTRA_CA_CERTS (dev/E2E bridge)
const envExtraPath = process.env.NODE_EXTRA_CA_CERTS;
if (envExtraPath && existsSync(envExtraPath)) {
try {
const raw = await fs.readFile(envExtraPath, 'utf8');
const validated = validateCaBundlePem(raw);
if (validated) customChunks.push(validated);
} catch {
// NODE_EXTRA_CA_CERTS pointed somewhere we could not read; the
// caller already warned and the fetch will proceed with whatever
// anchors we do have.
}
}
// If there are no custom anchors (per-source or env), we don't need to
// write a combined file at all - git will use system trust directly.
if (customChunks.length === 0) return null;
// We have custom anchors: include system anchors so that private CAs
// AUGMENT rather than REPLACE system trust (mirrors Node's
// NODE_EXTRA_CA_CERTS add-not-replace semantics).
// We have custom anchors: include system anchors so that private CAs
// AUGMENT rather than REPLACE system trust (mirrors Node's
// NODE_EXTRA_CA_CERTS add-not-replace semantics). The optional
// `systemCaPem` parameter is a test injection point: `undefined`
// means "read the platform bundle" (production), a string means
// "use this controlled fixture" (test), and `null` means "explicitly
// skip" (negative-control test).
let systemCa: string | null = null;
if (systemCaPem === null) {
// Explicit skip: negative-control path.
} else if (systemCaPem !== undefined) {
systemCa = validateCaBundlePem(systemCaPem);
} else {
systemCa = await readSystemCaBundle();
}
if (systemCa) customChunks.unshift(systemCa);
const target = path.join(metaDir, COMBINED_FILENAME);
const body = `${customChunks.join('\n')}\n`;
await fs.writeFile(target, body, { mode: 0o600 });
return { path: target.split(path.sep).join('/') };
}
+201 -104
View File
@@ -5,10 +5,15 @@ import path from 'path';
import { ensureGitBinary, getGitExecPath } from './gitBinary';
import {
CREDENTIAL_HELPER_CONFIG_VALUE,
GIT_ALLOWED_HOST_ENV_VAR,
GIT_HELPER_PATH_ENV_VAR,
GIT_TOKEN_ENV_VAR,
writeCredentialHelper,
} from './credentialHelper';
import { credentialScopeHost } from './caBundle';
import { sanitizeForLog } from '../../utils/safeLog';
import { writeCombinedCaBundle } from './gitCaBundleSink';
import { looksLikeRedirectFailure, resolveRedirectedRepoUrl } from './redirectPreflight';
import { isTransportFailure, type TransportFailure } from './errors';
import type { FetchRequest, FetchResult, GitTransport, ResolveRequest, ResolveResult } from './types';
import {
@@ -143,7 +148,7 @@ async function awaitKillConfirmed(kill: Promise<void> | undefined, what: string)
let timer: NodeJS.Timeout | undefined;
const bound = new Promise<void>((resolve) => {
timer = setTimeout(() => {
console.warn(`[GitSource:transport] ${what} not confirmed within ${KILL_CONFIRM_TIMEOUT_MS}ms; continuing cleanup while it may still be running`);
console.warn(`[GitSource:transport] ${sanitizeForLog(what)} not confirmed within ${KILL_CONFIRM_TIMEOUT_MS}ms; continuing cleanup while it may still be running`);
resolve();
}, KILL_CONFIRM_TIMEOUT_MS);
});
@@ -272,6 +277,7 @@ function buildEnv(
token?: string | null,
helperPath?: string | null,
sshCommand?: string | null,
allowedHost?: string | null,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
@@ -301,6 +307,9 @@ function buildEnv(
// parses it. See credentialHelper.ts.
env[GIT_HELPER_PATH_ENV_VAR] = helperPath;
}
if (allowedHost) {
env[GIT_ALLOWED_HOST_ENV_VAR] = allowedHost;
}
if (sshCommand) {
env.GIT_SSH_COMMAND = sshCommand;
}
@@ -331,85 +340,61 @@ async function detectWindowsCABundle(): Promise<string | null> {
return null;
}
/** First existing system CA bundle for OpenSSL-backed git on POSIX. */
const POSIX_CA_BUNDLE_CANDIDATES = [
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
];
/**
* Build the CA-anchor configuration for one fetch.
*
* Mirrors Node's own NODE_EXTRA_CA_CERTS semantics (extra anchors ADDED to
* the defaults, never replacing them) by writing a combined PEM bundle into
* the fetch workspace's `.meta` dir:
* - No NODE_EXTRA_CA_CERTS: production posture. POSIX passes nothing and
* lets OpenSSL use system trust; Windows pins Git's own bundled bundle,
* because stripping system gitconfig also strips the installer's pointer
* to it.
* - With NODE_EXTRA_CA_CERTS: defaults PLUS the extra CAs, so the dev/E2E
* fixture server and public hosts validate in the same process state.
* - No NODE_EXTRA_CA_CERTS and no per-source PEM: production posture.
* POSIX passes nothing and lets OpenSSL use system trust; Windows pins
* Git's own bundled bundle, because stripping system gitconfig also
* strips the installer's pointer to it.
* - With NODE_EXTRA_CA_CERTS and/or a per-source PEM: defaults PLUS the extra
* CAs, so private-CA servers and public hosts validate in the same fetch.
*
* The per-source PEM and the env-var file are written by
* `writeCombinedCaBundle` in `./gitCaBundleSink.ts`; the sink module is the
* single point CodeQL is asked to ignore for `js/http-to-file-access` because
* every input it writes is either a file path inside the per-fetch workspace
* (no external taint) or a PEM that the caller has already validated.
*/
async function resolveCaArgs(layout: WorkspaceLayout): Promise<string[]> {
const extraPath = process.env.NODE_EXTRA_CA_CERTS;
const hasExtra = Boolean(extraPath && existsSync(extraPath));
async function resolveCaArgs(
layout: WorkspaceLayout,
perSourceCaPem?: string | null,
): Promise<{ args: string[]; caPath: string | null }> {
const isWindows = process.platform === 'win32';
if (!hasExtra && !isWindows) {
return [];
// Per-source and env-var anchors live in a single combined file written by
// the sink module. The sink now includes system anchors when custom
// anchors are present, so we only need to handle the "no custom anchors"
// case specially for Windows.
const combined = await writeCombinedCaBundle(layout.metaDir, perSourceCaPem);
if (combined) {
return { args: ['-c', `http.sslCAInfo=${combined.path}`], caPath: combined.path };
}
if (isWindows && !hasExtra) {
// Windows without an override: anchor to Git's bundled bundle directly.
const bundle = await detectWindowsCABundle();
return bundle ? ['-c', `http.sslCAInfo=${bundle}`] : [];
}
let defaultPem = '';
let winBundle: string | null = null;
// No custom anchors: on POSIX we pass nothing (system trust applies
// directly via OpenSSL). On Windows we still need the Git-bundled
// pointer because GIT_CONFIG_NOSYSTEM stripped the installer's config.
if (isWindows) {
winBundle = await detectWindowsCABundle();
if (winBundle) {
try {
defaultPem = await fs.readFile(winBundle.replace(/\//g, path.sep), 'utf8');
} catch {
console.warn(`[GitSource:transport] could not read system CA bundle at ${winBundle}; combined anchors will contain only NODE_EXTRA_CA_CERTS entries.`);
}
}
} else {
for (const candidate of POSIX_CA_BUNDLE_CANDIDATES) {
if (!existsSync(candidate)) continue;
try {
defaultPem = await fs.readFile(candidate, 'utf8');
break;
} catch {
// Try the next candidate.
}
}
if (!defaultPem) {
console.warn('[GitSource:transport] no readable system CA bundle found; combined anchors will contain only NODE_EXTRA_CA_CERTS entries.');
}
const bundle = await detectWindowsCABundle();
return bundle ? { args: ['-c', `http.sslCAInfo=${bundle}`], caPath: bundle } : { args: [], caPath: null };
}
let extraPem = '';
try {
extraPem = await fs.readFile(extraPath as string, 'utf8');
} catch {
console.warn('[GitSource:transport] could not read the file configured via NODE_EXTRA_CA_CERTS; ignoring custom anchors.');
// Windows still has working defaults; fall back to them instead of
// dropping every anchor.
return isWindows && winBundle ? ['-c', `http.sslCAInfo=${winBundle}`] : [];
}
const combinedPath = path.join(layout.metaDir, 'combined-ca.pem');
await fs.writeFile(combinedPath, `${defaultPem}\n${extraPem}`, { mode: 0o600 });
return ['-c', `http.sslCAInfo=${combinedPath.split(path.sep).join('/')}`];
return { args: [], caPath: null };
}
/**
* 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, ssh: boolean): Promise<string[]> {
async function commonArgs(
layout: WorkspaceLayout,
helperPath: string | null,
ssh: boolean,
perSourceCaPem?: string | null,
): Promise<{ args: string[]; caPath: string | null }> {
const args = [
'-c', 'protocol.allow=never',
];
@@ -417,6 +402,14 @@ async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ss
args.push('-c', 'protocol.ssh.allow=always');
} else {
args.push('-c', 'protocol.https.allow=always');
// Git never follows a redirect itself, so it can never contact a
// destination this process has not already approved. When a server
// does redirect, the caller walks the chain unauthenticated through
// redirectPreflight, validates every hop, and re-runs git against the
// approved URL. Letting git follow instead would contact the target
// before any policy ran, which is what makes the internal-range guard
// meaningful rather than after-the-fact.
args.push('-c', 'http.followRedirects=false');
}
args.push('-c', `core.hooksPath=${layout.hooksDir.split(path.sep).join('/')}`);
if (process.platform === 'win32') {
@@ -428,7 +421,8 @@ async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ss
// git is OpenSSL-backed and unaffected by this flag's absence.
args.push('-c', 'http.sslBackend=openssl');
}
args.push(...await resolveCaArgs(layout));
const ca = await resolveCaArgs(layout, perSourceCaPem);
args.push(...ca.args);
if (helperPath !== null) {
// A fixed value: the helper's path reaches git through the child env
// instead of being interpolated here, so a workspace path containing
@@ -438,7 +432,7 @@ async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ss
} else {
args.push('-c', 'credential.helper=');
}
return args;
return { args, caPath: ca.caPath };
}
/**
@@ -455,9 +449,11 @@ async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ss
*/
async function prepareInvocation(
workspaceRoot: string,
repoUrl: string,
token?: string | null,
sshAuth?: ResolveRequest['sshAuth'],
): Promise<{ layout: WorkspaceLayout; env: NodeJS.ProcessEnv; baseArgs: string[] }> {
caBundlePem?: string | null,
): Promise<{ layout: WorkspaceLayout; env: NodeJS.ProcessEnv; baseArgs: string[]; allowedHost: string | null; caPath: string | null }> {
const layout = await prepareWorkspace(workspaceRoot);
let sshCommand: string | null = null;
if (sshAuth) {
@@ -466,9 +462,63 @@ async function prepareInvocation(
sshCommand = buildSshCommand(keyPath, knownPath);
}
const helperPath = token ? await writeCredentialHelper(layout.metaDir) : null;
const env = buildEnv(layout.homeDir, token, helperPath, sshCommand);
const baseArgs = await commonArgs(layout, helperPath, Boolean(sshAuth));
return { layout, env, baseArgs };
const parsed = parseRepoTransportUrl(repoUrl);
const allowedHost = parsed?.kind === 'https' && token
? credentialScopeHost(parsed.host)
: null;
const env = buildEnv(layout.homeDir, token, helperPath, sshCommand, allowedHost);
const { args: baseArgs, caPath } = await commonArgs(layout, helperPath, Boolean(sshAuth), caBundlePem);
return { layout, env, baseArgs, allowedHost, caPath };
}
/**
* The redirect chain for a repository, resolved and policy-checked without
* credentials, or null when the source does not redirect (in which case the
* caller keeps git's original failure). Only ever consulted after git has
* already refused to follow a redirect, so the normal path costs nothing.
*/
async function approveRedirectTarget(
repo: ParsedRepoUrl,
stderr: string,
hasToken: boolean,
caPath: string | null,
): Promise<string | null> {
if (repo.kind !== 'https' || !looksLikeRedirectFailure(stderr)) return null;
let caPem: string | undefined;
if (caPath) {
try {
caPem = await fs.readFile(caPath, 'utf8');
} catch (e) {
// This bundle was written moments ago by this same invocation, so a
// read failure is a real fault, not a missing option. Probing with
// default trust instead would validate the operator's private-CA
// host against the wrong anchors, so decline the retry and say so.
console.warn(`[GitSource:transport] could not read the CA bundle at ${sanitizeForLog(caPath)} for the redirect preflight; not authorising a redirect retry: ${sanitizeForLog(e instanceof Error ? e.message : String(e))}`);
return null;
}
}
return await resolveRedirectedRepoUrl({
repoUrl: repo.href,
hasToken,
reportHost: repoHostLabel(repo),
caPem,
});
}
/**
* The same question asked of a materialization step, which reports through a
* thrown TransportFailure rather than an exit code. Kept in one place so the
* rule for which failures may be retried cannot drift between the fetch and
* fast-forward paths.
*/
async function approvedRedirectForError(
e: unknown,
repo: ParsedRepoUrl,
hasToken: boolean,
caPath: string | null,
): Promise<string | null> {
if (!isTransportFailure(e) || e.reason !== 'exit' || !e.stderr) return null;
return await approveRedirectTarget(repo, e.stderr, hasToken, caPath);
}
// ─── Input validation ────────────────────────────────────────────────────────
@@ -635,19 +685,30 @@ async function lsRemoteRefs(
baseArgs: string[],
timeoutMs: number,
hasToken: boolean,
caPath: string | null = null,
): Promise<ResolvedRemoteRefs> {
const host = repoHostLabel(repo);
let res: RunResult;
try {
res = await runGit(
[...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) {
if (isTimeoutError(e)) {
throw { transportFailure: true as const, reason: 'timeout', host, hasToken } satisfies TransportFailure;
const attempt = async (href: string): Promise<RunResult> => {
try {
return await runGit(
[...baseArgs, 'ls-remote', href, `refs/heads/${ref}`, `refs/tags/${ref}`, `refs/tags/${ref}^{}`],
{ env, timeoutMs: Math.min(timeoutMs, LS_REMOTE_MAX_MS) },
);
} catch (e) {
if (isTimeoutError(e)) {
throw { transportFailure: true as const, reason: 'timeout', host, hasToken } satisfies TransportFailure;
}
throw e;
}
throw e;
};
let res = await attempt(repo.href);
if (res.exitCode !== 0) {
// git refused a redirect. Resolve and approve the destination first;
// an approved chain is retried once against the final URL, and a
// rejected one throws before that host is ever contacted.
const approved = await approveRedirectTarget(repo, res.stderr, hasToken, caPath);
if (approved) res = await attempt(approved);
}
if (res.exitCode !== 0) {
throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: baseArgs, host, hasToken } satisfies TransportFailure;
@@ -692,6 +753,7 @@ export async function verifyFastForward(req: {
descendantSha: string;
token?: string | null;
sshAuth?: ResolveRequest['sshAuth'];
caBundlePem?: string | null;
timeoutMs?: number;
workspaceRoot: string;
maxBytes: number;
@@ -712,7 +774,12 @@ export async function verifyFastForward(req: {
throw { transportFailure: true as const, reason: 'timeout', host, hasToken } satisfies TransportFailure;
}
};
const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth);
const { env, baseArgs, caPath } = await prepareInvocation(
req.workspaceRoot, req.repoUrl, req.token, req.sshAuth, req.caBundlePem,
);
// Resolved once if the host refuses a redirect, then reused by the deepen
// rounds so they do not each re-walk the same chain.
let effectiveHref = repo.href;
const repoDir = path.join(req.workspaceRoot, 'ff-check');
await fs.mkdir(repoDir, { recursive: true });
@@ -803,7 +870,14 @@ export async function verifyFastForward(req: {
};
await materialize([...baseArgs, 'init']);
await materialize([...baseArgs, 'fetch', '--depth=1', repo.href, descendant]);
try {
await materialize([...baseArgs, 'fetch', '--depth=1', effectiveHref, descendant]);
} catch (e) {
const approved = await approvedRedirectForError(e, repo, hasToken, caPath);
if (!approved) throw e;
effectiveHref = approved;
await materialize([...baseArgs, 'fetch', '--depth=1', effectiveHref, descendant]);
}
const countReachable = async (): Promise<number> => {
const argv = [...baseArgs, 'rev-list', '--count', descendant];
@@ -891,7 +965,7 @@ export async function verifyFastForward(req: {
}
const previousCount = reachableCount;
await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, repo.href, descendant]);
await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, effectiveHref, descendant]);
fetchRounds += 1;
reachableCount = await countReachable();
@@ -929,10 +1003,13 @@ export const nativeGitTransport: GitTransport = {
}
assertValidRef(req.ref, repoHostLabel(repo), hasToken);
const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth);
const { env, baseArgs, caPath } = await prepareInvocation(
req.workspaceRoot, req.repoUrl, req.token, req.sshAuth, req.caBundlePem,
);
const found = await lsRemoteRefs(
repo, req.ref, env, baseArgs,
req.timeoutMs ?? DEFAULT_TIMEOUT_MS, hasToken,
req.timeoutMs ?? DEFAULT_TIMEOUT_MS, hasToken, caPath,
);
if (found.branchSha) return { commitSha: found.branchSha, kind: 'branch' };
if (found.tagSha) return { commitSha: found.tagSha, kind: 'tag' };
@@ -945,7 +1022,13 @@ export const nativeGitTransport: GitTransport = {
const repo = assertValidRepoUrl(req.repoUrl, hasToken);
assertValidRef(req.ref, repoHostLabel(repo), hasToken);
const { layout, env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth);
// The credential scope host is set inside prepareInvocation via
// GIT_ALLOWED_HOST_ENV_VAR so the credential helper refuses to emit
// credentials for any other host. A refused redirect is resolved and
// approved by the preflight below before any retry.
const { layout, env, baseArgs, caPath } = await prepareInvocation(
req.workspaceRoot, req.repoUrl, req.token, req.sshAuth, req.caBundlePem,
);
const checkout = path.join(req.workspaceRoot, 'repo');
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -998,28 +1081,42 @@ export const nativeGitTransport: GitTransport = {
return res;
};
if (req.refKind === 'sha') {
// `--branch` cannot take a bare SHA, so a pinned commit uses a
// third strategy: init a repo, fetch exactly that object, and
// check it out detached. The host must allow fetching a direct
// 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', repo.href, req.ref]);
await materialize([...baseArgs, '-C', checkout, 'checkout', '--detach', req.ref]);
} else {
// A bare name works for both branches and tags: `--branch`
// detaches at the named ref's commit either way, and passing a
// fully-qualified `refs/tags/<ref>` is rejected by git
// (`Remote branch ... not found`). The resolved kind is
// already pinned by ls-remote, and the rev-parse HEAD
// verification below confirms the checkout matched it.
const branchArg = req.ref;
await materialize([
...baseArgs, 'clone',
'--depth=1', '--single-branch', '--no-tags', '--no-recurse-submodules',
'--branch', branchArg, repo.href, checkout,
]);
const runMaterialization = async (href: string): Promise<void> => {
if (req.refKind === 'sha') {
// `--branch` cannot take a bare SHA, so a pinned commit uses a
// third strategy: init a repo, fetch exactly that object, and
// check it out detached. The host must allow fetching a direct
// 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', href, req.ref]);
await materialize([...baseArgs, '-C', checkout, 'checkout', '--detach', req.ref]);
} else {
// A bare name works for both branches and tags: `--branch`
// detaches at the named ref's commit either way, and passing a
// fully-qualified `refs/tags/<ref>` is rejected by git
// (`Remote branch ... not found`). The resolved kind is
// already pinned by ls-remote, and the rev-parse HEAD
// verification below confirms the checkout matched it.
await materialize([
...baseArgs, 'clone',
'--depth=1', '--single-branch', '--no-tags', '--no-recurse-submodules',
'--branch', req.ref, href, checkout,
]);
}
};
try {
await runMaterialization(repo.href);
} catch (e) {
// A refused redirect is the one failure worth a second attempt,
// and only against a destination the preflight has approved.
const approved = await approvedRedirectForError(e, repo, hasToken, caPath);
if (!approved) throw e;
// The refused attempt can have left a partial checkout behind;
// git refuses to clone into a non-empty directory.
await fs.rm(checkout, { recursive: true, force: true });
await runMaterialization(approved);
}
let actual: string;
@@ -0,0 +1,203 @@
import https from 'https';
import type { TransportFailure } from './errors';
import { credentialScopeHost } from './caBundle';
import { sanitizeForLog } from '../../utils/safeLog';
/**
* Destination-aware redirect policy for HTTPS Git operations.
*
* Git is always run with `http.followRedirects=false`, so it never contacts a
* redirect target on its own. When git refuses a redirect, this module walks
* the chain itself with an UNAUTHENTICATED request and validates every hop
* before anything follows it. Only a chain that satisfies the policy end to
* end produces a URL git is then re-run against.
*
* The ordering is the point: a destination outside the configured
* repository's origin is rejected while it has still never been contacted, so
* a hostile server cannot use a redirect either to move a credential or to
* turn a repository fetch into a probe of a host it chose. Parsing git's own
* output cannot achieve this, because git prints the destination only on the
* path where it has already followed the redirect (`warning: redirecting to
* <url>` in git-remote-http) and prints nothing at all when following is
* disabled.
*
* The rule every hop must satisfy is deliberately one rule: the destination
* stays on the configured repository's origin (scheme, host, and port), and
* only the path may move. That is what a repository relocating to a canonical
* path looks like, it is what git's own `update_url_from_redirect` superset
* check enforces on the path component, and it makes a redirect to an
* internal address structurally impossible rather than something a separate
* address-range blocklist has to anticipate. Such a blocklist would also be
* actively wrong here: a self-hosted Git server on loopback or a private LAN
* range is a supported deployment, not an attack.
*/
/** Hop ceiling for one chain, bounding both loops and probe cost. */
export const MAX_REDIRECT_HOPS = 5;
/** The smart-HTTP endpoint whose redirect defines where the repository moved. */
const REF_ADVERTISE_SUFFIX = '/info/refs';
const REF_ADVERTISE_QUERY = 'service=git-upload-pack';
const PROBE_TIMEOUT_MS = 10_000;
function redirectScope(host: string, hasToken: boolean): TransportFailure {
return { transportFailure: true as const, reason: 'redirect-scope', host, hasToken };
}
/** Parse a Location value (absolute or relative) against its base. Null on failure, so callers fail closed. */
export function resolveLocation(baseUrl: string, location: string): string | null {
try {
return new URL(location, baseUrl).toString();
} catch {
return null;
}
}
/**
* The origin a redirect is allowed to stay on, in the same `host[:port]`
* spelling the credential helper compares against, so the transport's
* redirect rule and its credential rule cannot drift apart.
*/
export function redirectScopeOf(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return null;
return credentialScopeHost(parsed.hostname, parsed.port ? Number(parsed.port) : undefined);
} catch {
return null;
}
}
/**
* True when git's stderr describes a refused redirect rather than an ordinary
* failure. Matched against git's current wording, which
* `git-redirect-preflight.test.ts` pins to the literal strings git emits, so a
* git upgrade that rephrases them fails a test rather than quietly making
* relocated repositories unreachable. A miss is safe but not silent: no retry
* is authorised and git's own error is reported unchanged.
*/
export function looksLikeRedirectFailure(stderr: string): boolean {
return /returned error: 30\d/i.test(stderr) || /\bredirect/i.test(stderr);
}
/**
* The single gate every URL passes before it is requested. Returns the URL
* only when it sits on `expectedScope`, and throws otherwise, so no caller can
* reach the network with a destination that has not been checked. The seed URL
* goes through it too: that check is trivially true, but routing every request
* through one place is what makes the guarantee inspectable rather than a
* property of the loop's shape, for a reader as much as for static analysis.
*/
export function approvedUrl(
url: string,
expectedScope: string,
reportHost: string,
hasToken: boolean,
): string {
if (redirectScopeOf(url) !== expectedScope) {
throw redirectScope(reportHost, hasToken);
}
return url;
}
interface ProbeResponse {
status: number;
location: string | null;
}
/** One unauthenticated GET, following nothing. Rejects only on transport errors. */
function probe(url: string, ca: string | undefined): Promise<ProbeResponse> {
return new Promise((resolve, reject) => {
const req = https.get(url, { ca, timeout: PROBE_TIMEOUT_MS }, (res) => {
const location = typeof res.headers.location === 'string' ? res.headers.location : null;
// The body is irrelevant; discard it so the socket can close.
res.resume();
resolve({ status: res.statusCode ?? 0, location });
});
req.on('timeout', () => req.destroy(new Error('redirect preflight timed out')));
req.on('error', reject);
});
}
/**
* Strip the ref-advertise suffix off a probe URL to recover the repository
* URL git should be pointed at. A destination that no longer ends in the
* endpoint we asked for is not a relocation of this repository and is
* refused, mirroring git's own superset rule on the path component.
*/
function repoUrlFromProbeUrl(probeUrl: string): string | null {
let parsed: URL;
try {
parsed = new URL(probeUrl);
} catch {
return null;
}
if (!parsed.pathname.endsWith(REF_ADVERTISE_SUFFIX)) return null;
parsed.pathname = parsed.pathname.slice(0, -REF_ADVERTISE_SUFFIX.length);
parsed.search = '';
parsed.hash = '';
return parsed.toString().replace(/\/$/, '');
}
/**
* Build the ref-advertise probe URL for `repoUrl` via the `URL` API rather
* than string concatenation, so a `repoUrl` that already carries a query
* string (a signed URL, say) gets the suffix inserted into the path and the
* query replaced, instead of the suffix landing after the existing query.
*/
function refAdvertiseProbeUrl(repoUrl: string): string {
const parsed = new URL(repoUrl);
parsed.pathname = `${parsed.pathname.replace(/\/$/, '')}${REF_ADVERTISE_SUFFIX}`;
parsed.search = `?${REF_ADVERTISE_QUERY}`;
return parsed.toString();
}
/**
* Walk the redirect chain for `repoUrl` without credentials and return the
* repository URL it ultimately resolves to, or null when the source does not
* redirect at all (so the caller keeps git's original failure).
*
* Throws a `redirect-scope` TransportFailure as soon as a hop leaves the
* configured origin, before that hop is ever requested.
*/
export async function resolveRedirectedRepoUrl(opts: {
repoUrl: string;
hasToken: boolean;
reportHost: string;
caPem?: string;
}): Promise<string | null> {
const expectedScope = redirectScopeOf(opts.repoUrl);
if (!expectedScope) throw redirectScope(opts.reportHost, opts.hasToken);
let current = approvedUrl(
refAdvertiseProbeUrl(opts.repoUrl),
expectedScope, opts.reportHost, opts.hasToken,
);
let hops = 0;
while (hops < MAX_REDIRECT_HOPS) {
let res: ProbeResponse;
try {
res = await probe(current, opts.caPem);
} catch (e) {
// The probe could not complete (TLS, DNS, reset). We cannot prove
// the chain is safe, so we do not authorise a retry; the caller
// reports git's original error instead. Say why, or a private CA
// that fails to validate is indistinguishable from a server that
// simply does not redirect.
console.warn(`[GitSource:redirect] could not probe ${sanitizeForLog(opts.reportHost)} for a redirect target, keeping the original git error: ${sanitizeForLog(e instanceof Error ? e.message : String(e))}`);
return null;
}
if (res.status < 300 || res.status >= 400 || !res.location) {
if (hops === 0) return null;
const resolved = repoUrlFromProbeUrl(current);
if (!resolved) throw redirectScope(opts.reportHost, opts.hasToken);
return resolved;
}
const next = resolveLocation(current, res.location);
if (!next) throw redirectScope(opts.reportHost, opts.hasToken);
current = approvedUrl(next, expectedScope, opts.reportHost, opts.hasToken);
hops += 1;
}
throw redirectScope(opts.reportHost, opts.hasToken);
}
+2
View File
@@ -30,6 +30,8 @@ export interface ResolveRequest {
ref: string;
token?: string | null;
sshAuth?: SshDeployKeyAuth | null;
/** Optional per-source custom CA PEM bundle (system anchors are still included). */
caBundlePem?: string | null;
/**
* Total fetch budget in milliseconds. Note: the resolution round trip
* (ls-remote) is internally capped at 10s regardless of this value, so