mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 14:18:02 +00:00
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:
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Shared fixture helpers for tests that need a real local git repository
|
||||
* served over smart HTTPS. Two tests in this directory build the same shape
|
||||
* of bare repo (init source, write compose, commit, bare-clone) for their own
|
||||
* TLS servers; this helper keeps that logic in one place.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import { mkdtempSync, writeFileSync } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export interface BuildBareRepoOptions {
|
||||
/** Tmpdir prefix for the working source repo. */
|
||||
srcPrefix?: string;
|
||||
/** Tmpdir prefix for the bare clone. */
|
||||
barePrefix?: string;
|
||||
/** Git user.email for the fixture commit. */
|
||||
userEmail?: string;
|
||||
/** Git user.name for the fixture commit. */
|
||||
userName?: string;
|
||||
/** Branch name; defaults to 'main'. */
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export function buildBareRepo(opts: BuildBareRepoOptions = {}): string {
|
||||
const srcPrefix = opts.srcPrefix ?? 'sencho-git-src-';
|
||||
const barePrefix = opts.barePrefix ?? 'sencho-git-bare-';
|
||||
const userEmail = opts.userEmail ?? 'git-fixture@sencho.test';
|
||||
const userName = opts.userName ?? 'Sencho Git Fixture';
|
||||
const branch = opts.branch ?? 'main';
|
||||
const srcDir = mkdtempSync(path.join(os.tmpdir(), srcPrefix));
|
||||
const run = (args: string[], cwd: string, label: string) => {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
if (r.status !== 0) throw new Error(`git ${label} failed: ${r.stderr}`);
|
||||
};
|
||||
run(['init', '-b', branch], srcDir, 'init');
|
||||
run(['config', 'user.email', userEmail], srcDir, 'config email');
|
||||
run(['config', 'user.name', userName], srcDir, 'config name');
|
||||
writeFileSync(path.join(srcDir, 'compose.yaml'), 'services:\n x:\n image: nginx\n');
|
||||
run(['add', '-A'], srcDir, 'add');
|
||||
run(['commit', '-m', 'fixture'], srcDir, 'commit');
|
||||
const bareRoot = mkdtempSync(path.join(os.tmpdir(), barePrefix));
|
||||
const bareDir = path.join(bareRoot, 'repo.git');
|
||||
const clone = spawnSync('git', ['clone', '--bare', '--quiet', srcDir, bareDir], { encoding: 'utf8' });
|
||||
if (clone.status !== 0) throw new Error(`git clone --bare failed: ${clone.stderr}`);
|
||||
return bareDir;
|
||||
}
|
||||
@@ -50,6 +50,7 @@ function seedSource(stackName: string, composePaths: string[]): void {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
|
||||
@@ -359,6 +359,7 @@ describe('GET /api/stacks/statuses caching', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { validateCaBundlePem, credentialScopeHost } from '../services/git/caBundle';
|
||||
import { classifyGitFailure } from '../services/git/errors';
|
||||
|
||||
describe('validateCaBundlePem', () => {
|
||||
it('accepts a PEM certificate block', () => {
|
||||
const pem = '-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv\n-----END CERTIFICATE-----\n';
|
||||
expect(validateCaBundlePem(pem)).toBe(pem.trim());
|
||||
});
|
||||
|
||||
it('rejects empty and non-PEM input', () => {
|
||||
expect(validateCaBundlePem('')).toBeNull();
|
||||
expect(validateCaBundlePem('not a cert')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('credentialScopeHost', () => {
|
||||
it('normalizes host and non-default port', () => {
|
||||
expect(credentialScopeHost('Git.Example.COM')).toBe('git.example.com');
|
||||
expect(credentialScopeHost('git.example.com', 8443)).toBe('git.example.com:8443');
|
||||
expect(credentialScopeHost('git.example.com:8443')).toBe('git.example.com:8443');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyGitFailure TLS and redirect nuance', () => {
|
||||
it('maps hostname mismatch to a clear TLS message', () => {
|
||||
const result = classifyGitFailure({
|
||||
transportFailure: true,
|
||||
reason: 'exit',
|
||||
host: 'git.example.com',
|
||||
hasToken: false,
|
||||
stderr: 'SSL: certificate subject name does not match target host name',
|
||||
});
|
||||
expect(result.message).toContain('hostname does not match');
|
||||
});
|
||||
|
||||
it('maps curl\'s IP-address SAN mismatch wording to the same clear TLS message', () => {
|
||||
const result = classifyGitFailure({
|
||||
transportFailure: true,
|
||||
reason: 'exit',
|
||||
host: 'git.example.com',
|
||||
hasToken: false,
|
||||
stderr: "SSL: no alternative certificate subject name matches target ipv4 address '172.18.0.1'",
|
||||
});
|
||||
expect(result.message).toContain('hostname does not match');
|
||||
});
|
||||
|
||||
it('maps expired certificates distinctly', () => {
|
||||
const result = classifyGitFailure({
|
||||
transportFailure: true,
|
||||
reason: 'exit',
|
||||
host: 'git.example.com',
|
||||
hasToken: false,
|
||||
stderr: 'certificate has expired',
|
||||
});
|
||||
expect(result.message).toContain('expired');
|
||||
});
|
||||
|
||||
it('maps unknown CA to private-CA guidance', () => {
|
||||
const result = classifyGitFailure({
|
||||
transportFailure: true,
|
||||
reason: 'exit',
|
||||
host: 'git.example.com',
|
||||
hasToken: false,
|
||||
stderr: 'SSL certificate problem: unable to get local issuer certificate',
|
||||
});
|
||||
expect(result.message).toContain('private CA');
|
||||
});
|
||||
|
||||
it('maps redirect-scope and redirect stderr to credential-scope guidance', () => {
|
||||
const scoped = classifyGitFailure({
|
||||
transportFailure: true,
|
||||
reason: 'redirect-scope',
|
||||
host: 'git.example.com',
|
||||
hasToken: true,
|
||||
});
|
||||
expect(scoped.message).toContain('redirected');
|
||||
|
||||
const stderr = classifyGitFailure({
|
||||
transportFailure: true,
|
||||
reason: 'exit',
|
||||
host: 'git.example.com',
|
||||
hasToken: true,
|
||||
stderr: 'The requested URL returned error: 302',
|
||||
});
|
||||
expect(stderr.message).toContain('redirected');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Proves per-source CA bundles work without the process-wide NODE_EXTRA_CA_CERTS bridge.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { promises as fs, readFileSync } from 'fs';
|
||||
import https from 'https';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { nativeGitTransport } from '../services/git/nativeGitTransport';
|
||||
import { buildBareRepo } from './__helpers__/gitFixture';
|
||||
|
||||
function gitAvailable(): boolean {
|
||||
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
|
||||
const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8');
|
||||
|
||||
function serveRepo(bareDir: string): Promise<{ url: string; close: () => void }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = https.createServer(
|
||||
{
|
||||
cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')),
|
||||
key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')),
|
||||
},
|
||||
(req, res) => {
|
||||
const url = req.url ?? '/';
|
||||
if (!url.startsWith('/repo.git/')) {
|
||||
res.statusCode = 404;
|
||||
res.end('unknown repo');
|
||||
return;
|
||||
}
|
||||
const pathname = url.slice('/repo.git'.length).split('?')[0];
|
||||
if (pathname === '/info/refs' && (req.method === 'GET' || req.method === 'POST')) {
|
||||
const ps = spawn('git', ['upload-pack', '--stateless-rpc', '--advertise-refs', bareDir]);
|
||||
let out = Buffer.alloc(0);
|
||||
ps.stdout.on('data', (d: Buffer) => { out = Buffer.concat([out, d]); });
|
||||
ps.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
res.statusCode = 500;
|
||||
res.end('git upload-pack failed');
|
||||
return;
|
||||
}
|
||||
res.setHeader('content-type', 'application/x-git-upload-pack-advertisement');
|
||||
res.end(Buffer.concat([Buffer.from('001e# service=git-upload-pack\n0000'), out]));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === '/git-upload-pack' && req.method === 'POST') {
|
||||
const ps = spawn('git', ['upload-pack', '--stateless-rpc', bareDir]);
|
||||
res.setHeader('content-type', 'application/x-git-upload-pack-result');
|
||||
ps.stdout.pipe(res);
|
||||
req.pipe(ps.stdin);
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end('unsupported');
|
||||
},
|
||||
);
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === 'string') {
|
||||
reject(new Error('server did not bind'));
|
||||
return;
|
||||
}
|
||||
resolve({ url: `https://127.0.0.1:${address.port}/repo.git`, close: () => server.close() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!gitAvailable())('per-source private CA transport (real git)', () => {
|
||||
let repoUrl: string;
|
||||
let closeServer: () => void;
|
||||
let prevExtraCaCerts: string | undefined;
|
||||
const workspaces: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const bareDir = buildBareRepo({ srcPrefix: 'sencho-ca-src-', barePrefix: 'sencho-ca-bare-', userEmail: 'ca-test@sencho.test', userName: 'Sencho CA Test' });
|
||||
const served = await serveRepo(bareDir);
|
||||
repoUrl = served.url;
|
||||
closeServer = served.close;
|
||||
prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
closeServer?.();
|
||||
if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(workspaces.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it('clones a private-CA HTTPS repo when the per-source CA PEM is supplied', async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-ca-ws-'));
|
||||
workspaces.push(workspaceRoot);
|
||||
const resolved = await nativeGitTransport.resolveRef({
|
||||
repoUrl,
|
||||
ref: 'main',
|
||||
caBundlePem: CA_PEM,
|
||||
workspaceRoot,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const fetched = await nativeGitTransport.fetchAtCommit({
|
||||
repoUrl,
|
||||
ref: 'main',
|
||||
refKind: resolved.kind,
|
||||
commitSha: resolved.commitSha,
|
||||
caBundlePem: CA_PEM,
|
||||
workspaceRoot,
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
});
|
||||
expect(fetched.commitSha).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it('fails TLS verification without a matching per-source CA when NODE_EXTRA_CA_CERTS is unset', async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-ca-ws-'));
|
||||
workspaces.push(workspaceRoot);
|
||||
await expect(nativeGitTransport.resolveRef({
|
||||
repoUrl,
|
||||
ref: 'main',
|
||||
workspaceRoot,
|
||||
timeoutMs: 30_000,
|
||||
})).rejects.toMatchObject({ transportFailure: true });
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,7 @@ function seedGitSource(stackName: string): void {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -271,6 +272,7 @@ describe('promoteGeneration', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -781,6 +783,7 @@ describe('sweepManagedArea (crash recovery)', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -1287,6 +1290,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Redirect policy against real HTTPS servers.
|
||||
*
|
||||
* These run in-process (no git subprocess), so they exercise the preflight
|
||||
* walk itself: which destinations are approved, which are refused, and
|
||||
* crucially whether a refused destination is contacted at all. Each fixture
|
||||
* counts its own requests, so "rejected before contact" is asserted as an
|
||||
* observed request count rather than inferred from the thrown error.
|
||||
*/
|
||||
import { readFileSync } from 'fs';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { looksLikeRedirectFailure, resolveRedirectedRepoUrl } from '../services/git/redirectPreflight';
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
|
||||
const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8');
|
||||
const TLS_OPTS = {
|
||||
cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')),
|
||||
key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')),
|
||||
};
|
||||
|
||||
interface Fixture {
|
||||
port: number;
|
||||
origin: string;
|
||||
/** Every path this server was asked for, in order. */
|
||||
hits: string[];
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const open: Fixture[] = [];
|
||||
|
||||
/** Start a TLS server whose handler may redirect; records every request path. */
|
||||
function serve(handler: (url: string, res: import('http').ServerResponse) => void): Promise<Fixture> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hits: string[] = [];
|
||||
const server = https.createServer(TLS_OPTS, (req, res) => {
|
||||
hits.push(req.url ?? '');
|
||||
handler(req.url ?? '', res);
|
||||
});
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === 'string') {
|
||||
reject(new Error('fixture did not bind'));
|
||||
return;
|
||||
}
|
||||
const fixture: Fixture = {
|
||||
port: address.port,
|
||||
origin: `https://127.0.0.1:${address.port}`,
|
||||
hits,
|
||||
close: () => server.close(),
|
||||
};
|
||||
open.push(fixture);
|
||||
resolve(fixture);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Answers the ref-advertise with a 200 so a chain can terminate successfully. */
|
||||
function ok(res: import('http').ServerResponse): void {
|
||||
res.statusCode = 200;
|
||||
res.end('refs');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
open.splice(0).forEach((f) => f.close());
|
||||
});
|
||||
|
||||
describe('redirect preflight', () => {
|
||||
it('approves a same-origin redirect and returns the relocated repository URL', async () => {
|
||||
const server = await serve((url, res) => {
|
||||
if (url.startsWith('/old.git/')) {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('location', url.replace('/old.git/', '/new.git/'));
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
ok(res);
|
||||
});
|
||||
|
||||
const resolved = await resolveRedirectedRepoUrl({
|
||||
repoUrl: `${server.origin}/old.git`,
|
||||
hasToken: true,
|
||||
reportHost: '127.0.0.1',
|
||||
caPem: CA_PEM,
|
||||
});
|
||||
|
||||
expect(resolved).toBe(`${server.origin}/new.git`);
|
||||
});
|
||||
|
||||
it('approves a same-origin redirect expressed as an absolute Location', async () => {
|
||||
// The case above sends a relative Location; this one sends the fully
|
||||
// qualified form, so both branches of the Location parser are covered.
|
||||
let origin = '';
|
||||
const server = await serve((url, res) => {
|
||||
if (url.startsWith('/old.git/')) {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('location', `${origin}${url.replace('/old.git/', '/moved.git/')}`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
ok(res);
|
||||
});
|
||||
origin = server.origin;
|
||||
|
||||
await expect(
|
||||
resolveRedirectedRepoUrl({
|
||||
repoUrl: `${server.origin}/old.git`,
|
||||
hasToken: false,
|
||||
reportHost: '127.0.0.1',
|
||||
caPem: CA_PEM,
|
||||
}),
|
||||
).resolves.toBe(`${server.origin}/moved.git`);
|
||||
});
|
||||
|
||||
it('inserts the ref-advertise suffix into the path, not after an existing query string', async () => {
|
||||
// A repoUrl of `/repo.git?temp=1` (a signed URL, say) must not turn
|
||||
// into `/repo.git?temp=1/info/refs?service=...`: the query has to be
|
||||
// replaced, not appended to.
|
||||
const server = await serve((_url, res) => ok(res));
|
||||
|
||||
await expect(
|
||||
resolveRedirectedRepoUrl({
|
||||
repoUrl: `${server.origin}/repo.git?temp=1`,
|
||||
hasToken: true,
|
||||
reportHost: '127.0.0.1',
|
||||
caPem: CA_PEM,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(server.hits).toEqual(['/repo.git/info/refs?service=git-upload-pack']);
|
||||
});
|
||||
|
||||
it('refuses a cross-origin redirect without ever contacting the destination', async () => {
|
||||
// The destination is a fully working server: if the policy leaked, the
|
||||
// chain would resolve successfully rather than merely failing, so a
|
||||
// rejection here cannot be an accident of the target being broken.
|
||||
const destination = await serve((_url, res) => ok(res));
|
||||
const source = await serve((url, res) => {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('location', `${destination.origin}${url}`);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveRedirectedRepoUrl({
|
||||
repoUrl: `${source.origin}/repo.git`,
|
||||
hasToken: true,
|
||||
reportHost: '127.0.0.1',
|
||||
caPem: CA_PEM,
|
||||
}),
|
||||
).rejects.toMatchObject({ transportFailure: true, reason: 'redirect-scope' });
|
||||
|
||||
expect(source.hits).toHaveLength(1);
|
||||
expect(destination.hits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a redirect that only changes the port, destination uncontacted', async () => {
|
||||
const destination = await serve((_url, res) => ok(res));
|
||||
const source = await serve((url, res) => {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('location', `https://127.0.0.1:${destination.port}${url}`);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveRedirectedRepoUrl({
|
||||
repoUrl: `${source.origin}/repo.git`,
|
||||
hasToken: false,
|
||||
reportHost: '127.0.0.1',
|
||||
caPem: CA_PEM,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: 'redirect-scope' });
|
||||
expect(destination.hits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a downgrade to plain http', async () => {
|
||||
const source = await serve((_url, res) => {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('location', 'http://127.0.0.1:9/repo.git/info/refs?service=git-upload-pack');
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveRedirectedRepoUrl({
|
||||
repoUrl: `${source.origin}/repo.git`,
|
||||
hasToken: true,
|
||||
reportHost: '127.0.0.1',
|
||||
caPem: CA_PEM,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: 'redirect-scope' });
|
||||
});
|
||||
|
||||
it('refuses a chain longer than the hop cap instead of following it forever', async () => {
|
||||
const source = await serve((url, res) => {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('location', `${url}x`);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveRedirectedRepoUrl({
|
||||
repoUrl: `${source.origin}/repo.git`,
|
||||
hasToken: false,
|
||||
reportHost: '127.0.0.1',
|
||||
caPem: CA_PEM,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: 'redirect-scope' });
|
||||
});
|
||||
|
||||
it('refuses a same-origin redirect that leaves the ref-advertise endpoint', async () => {
|
||||
// Only the path prefix may move. A destination that no longer ends in
|
||||
// /info/refs is not this repository relocating.
|
||||
const source = await serve((_url, res) => {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('location', '/somewhere/else');
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveRedirectedRepoUrl({
|
||||
repoUrl: `${source.origin}/repo.git`,
|
||||
hasToken: true,
|
||||
reportHost: '127.0.0.1',
|
||||
caPem: CA_PEM,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: 'redirect-scope' });
|
||||
});
|
||||
|
||||
it('returns null when the source does not redirect, leaving git\'s own error intact', async () => {
|
||||
const server = await serve((_url, res) => {
|
||||
res.statusCode = 404;
|
||||
res.end('nope');
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveRedirectedRepoUrl({
|
||||
repoUrl: `${server.origin}/repo.git`,
|
||||
hasToken: true,
|
||||
reportHost: '127.0.0.1',
|
||||
caPem: CA_PEM,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('recognises the stderr git actually emits for a refused redirect', () => {
|
||||
// Pinned to git's real wording. git-remote-http reports a refused
|
||||
// redirect as an HTTP error and never prints a Location header, which
|
||||
// is why the destination has to be resolved by probing rather than by
|
||||
// reading it out of stderr. If a git upgrade rephrases these, this
|
||||
// fails here instead of silently making relocated repositories
|
||||
// unreachable in production.
|
||||
expect(looksLikeRedirectFailure(
|
||||
"fatal: unable to access 'https://git.example.com/repo.git/': The requested URL returned error: 302",
|
||||
)).toBe(true);
|
||||
expect(looksLikeRedirectFailure('warning: redirecting to https://git.example.com/new.git/')).toBe(true);
|
||||
expect(looksLikeRedirectFailure(
|
||||
"fatal: unable to access 'https://git.example.com/repo.git/': The requested URL returned error: 404",
|
||||
)).toBe(false);
|
||||
expect(looksLikeRedirectFailure('fatal: Authentication failed')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when the probe itself cannot complete', async () => {
|
||||
// Untrusted certificate: the chain cannot be proven safe, so no retry
|
||||
// is authorised and the caller keeps git's original failure.
|
||||
const server = await serve((_url, res) => {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('location', '/other.git/info/refs?service=git-upload-pack');
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveRedirectedRepoUrl({
|
||||
repoUrl: `${server.origin}/repo.git`,
|
||||
hasToken: true,
|
||||
reportHost: '127.0.0.1',
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Redirect behaviour end to end through real git against real TLS servers.
|
||||
*
|
||||
* The matrix pins both halves of the contract that the transport has to hold
|
||||
* at once: a repository that relocates on its own host stays usable, and a
|
||||
* redirect that leaves that host is refused without the destination being
|
||||
* contacted or a credential being offered to it.
|
||||
*
|
||||
* Every fixture records the requests it receives, including the Authorization
|
||||
* header, so credential scope and "never contacted" are asserted from what the
|
||||
* servers actually observed rather than inferred from the thrown error.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { promises as fs, readFileSync } from 'fs';
|
||||
import https from 'https';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { nativeGitTransport } from '../services/git/nativeGitTransport';
|
||||
import { buildBareRepo } from './__helpers__/gitFixture';
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
|
||||
const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8');
|
||||
const TLS_OPTS = {
|
||||
cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')),
|
||||
key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')),
|
||||
};
|
||||
const GOOD_TOKEN = 'correct-horse-battery-staple';
|
||||
|
||||
function gitAvailable(): boolean {
|
||||
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
|
||||
interface Request { url: string; authorization: string | null }
|
||||
|
||||
interface Fixture {
|
||||
origin: string;
|
||||
requests: Request[];
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const openFixtures: Fixture[] = [];
|
||||
const workspaces: string[] = [];
|
||||
|
||||
/**
|
||||
* A TLS git server. Paths under `/old.git/` are 302-redirected by `redirect`
|
||||
* (returning an absolute URL, or null to serve normally); paths under
|
||||
* `/new.git/` are served from the bare repo, behind Basic auth when
|
||||
* `requireAuth` is set.
|
||||
*/
|
||||
function serveGit(opts: {
|
||||
bareDir: string;
|
||||
redirect?: (fixture: Fixture, url: string) => string | null;
|
||||
requireAuth?: boolean;
|
||||
}): Promise<Fixture> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requests: Request[] = [];
|
||||
let self: Fixture;
|
||||
const server = https.createServer(TLS_OPTS, (req, res) => {
|
||||
const url = req.url ?? '/';
|
||||
requests.push({ url, authorization: req.headers.authorization ?? null });
|
||||
|
||||
if (url.startsWith('/old.git/') && opts.redirect) {
|
||||
const target = opts.redirect(self, url);
|
||||
if (target) {
|
||||
res.statusCode = 302;
|
||||
res.setHeader('location', target);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!url.startsWith('/new.git/')) {
|
||||
res.statusCode = 404;
|
||||
res.end('unknown repo');
|
||||
return;
|
||||
}
|
||||
if (opts.requireAuth) {
|
||||
const expected = `Basic ${Buffer.from(`x-access-token:${GOOD_TOKEN}`).toString('base64')}`;
|
||||
if (req.headers.authorization !== expected) {
|
||||
res.statusCode = 401;
|
||||
res.setHeader('www-authenticate', 'Basic realm="git"');
|
||||
res.end('unauthorized');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const pathname = url.slice('/new.git'.length).split('?')[0];
|
||||
if (pathname === '/info/refs') {
|
||||
const ps = spawn('git', ['upload-pack', '--stateless-rpc', '--advertise-refs', opts.bareDir]);
|
||||
let out = Buffer.alloc(0);
|
||||
ps.stdout.on('data', (d: Buffer) => { out = Buffer.concat([out, d]); });
|
||||
ps.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
res.statusCode = 500;
|
||||
res.end('git upload-pack failed');
|
||||
return;
|
||||
}
|
||||
res.setHeader('content-type', 'application/x-git-upload-pack-advertisement');
|
||||
res.end(Buffer.concat([Buffer.from('001e# service=git-upload-pack\n0000'), out]));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === '/git-upload-pack') {
|
||||
const ps = spawn('git', ['upload-pack', '--stateless-rpc', opts.bareDir]);
|
||||
res.setHeader('content-type', 'application/x-git-upload-pack-result');
|
||||
ps.stdout.pipe(res);
|
||||
req.pipe(ps.stdin);
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end('unsupported');
|
||||
});
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === 'string') {
|
||||
reject(new Error('fixture did not bind'));
|
||||
return;
|
||||
}
|
||||
self = {
|
||||
origin: `https://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
close: () => server.close(),
|
||||
};
|
||||
openFixtures.push(self);
|
||||
resolve(self);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function workspace(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-redir-ws-'));
|
||||
workspaces.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe.skipIf(!gitAvailable())('redirect destination revalidation (real git)', () => {
|
||||
let bareDir: string;
|
||||
let headSha: string;
|
||||
let prevExtraCaCerts: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
bareDir = buildBareRepo({
|
||||
srcPrefix: 'sencho-redir-src-',
|
||||
barePrefix: 'sencho-redir-bare-',
|
||||
userEmail: 'redir-test@sencho.test',
|
||||
userName: 'Sencho Redirect Test',
|
||||
});
|
||||
headSha = spawnSync('git', ['-C', bareDir, 'rev-parse', 'main'], { encoding: 'utf8' })
|
||||
.stdout.trim().toLowerCase();
|
||||
prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
openFixtures.splice(0).forEach((f) => f.close());
|
||||
await Promise.all(workspaces.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })));
|
||||
if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts;
|
||||
});
|
||||
|
||||
it('resolves a ref through an unauthenticated same-host redirect', async () => {
|
||||
const server = await serveGit({
|
||||
bareDir,
|
||||
redirect: (self, url) => `${self.origin}${url.replace('/old.git/', '/new.git/')}`,
|
||||
});
|
||||
|
||||
const resolved = await nativeGitTransport.resolveRef({
|
||||
repoUrl: `${server.origin}/old.git`,
|
||||
ref: 'main',
|
||||
caBundlePem: CA_PEM,
|
||||
workspaceRoot: await workspace(),
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ commitSha: headSha, kind: 'branch' });
|
||||
});
|
||||
|
||||
it('resolves a ref through an authenticated same-host redirect and sends the token to the relocated path', async () => {
|
||||
const server = await serveGit({
|
||||
bareDir,
|
||||
requireAuth: true,
|
||||
redirect: (self, url) => `${self.origin}${url.replace('/old.git/', '/new.git/')}`,
|
||||
});
|
||||
|
||||
const resolved = await nativeGitTransport.resolveRef({
|
||||
repoUrl: `${server.origin}/old.git`,
|
||||
ref: 'main',
|
||||
token: GOOD_TOKEN,
|
||||
caBundlePem: CA_PEM,
|
||||
workspaceRoot: await workspace(),
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ commitSha: headSha, kind: 'branch' });
|
||||
// The credential is scoped to the host, not to the original path, so
|
||||
// the relocated endpoint on that same host must have received it.
|
||||
const authorized = server.requests.filter(
|
||||
(r) => r.url.startsWith('/new.git/') && r.authorization !== null,
|
||||
);
|
||||
expect(authorized.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reports an authentication failure, not a redirect failure, when the token is wrong behind a same-host redirect', async () => {
|
||||
const server = await serveGit({
|
||||
bareDir,
|
||||
requireAuth: true,
|
||||
redirect: (self, url) => `${self.origin}${url.replace('/old.git/', '/new.git/')}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
nativeGitTransport.resolveRef({
|
||||
repoUrl: `${server.origin}/old.git`,
|
||||
ref: 'main',
|
||||
token: 'not-the-right-token',
|
||||
caBundlePem: CA_PEM,
|
||||
workspaceRoot: await workspace(),
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).rejects.toMatchObject({ transportFailure: true, reason: 'exit' });
|
||||
});
|
||||
|
||||
it('refuses a cross-host redirect without contacting the destination or offering it the token', async () => {
|
||||
// The destination is a fully working repository server. If the policy
|
||||
// leaked, this fetch would SUCCEED, so the rejection below cannot be an
|
||||
// artefact of a target that was broken anyway.
|
||||
const destination = await serveGit({ bareDir });
|
||||
const source = await serveGit({
|
||||
bareDir,
|
||||
redirect: (_self, url) => `${destination.origin}${url.replace('/old.git/', '/new.git/')}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
nativeGitTransport.resolveRef({
|
||||
repoUrl: `${source.origin}/old.git`,
|
||||
ref: 'main',
|
||||
token: 'sensitive-pat-do-not-leak',
|
||||
caBundlePem: CA_PEM,
|
||||
workspaceRoot: await workspace(),
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).rejects.toMatchObject({ transportFailure: true, reason: 'redirect-scope' });
|
||||
|
||||
// Settle, so a late request would still be counted rather than raced past.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
expect(destination.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('proves the cross-host destination would otherwise serve the same ref', async () => {
|
||||
const destination = await serveGit({ bareDir });
|
||||
const resolved = await nativeGitTransport.resolveRef({
|
||||
repoUrl: `${destination.origin}/new.git`,
|
||||
ref: 'main',
|
||||
caBundlePem: CA_PEM,
|
||||
workspaceRoot: await workspace(),
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(resolved.commitSha).toBe(headSha);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import fs from 'fs';
|
||||
import fs, { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { REF_MAX_LEN } from '../services/git/nativeGitTransport';
|
||||
@@ -114,6 +114,7 @@ function seedGitSource(stackName: string): void {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -1162,6 +1163,7 @@ describe('stack_git_sources manifest cache columns', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -1952,6 +1954,7 @@ describe('SSH deploy-key route validation', () => {
|
||||
encrypted_deploy_key: CryptoService.getInstance().encrypt(deployKey),
|
||||
ssh_known_hosts_entry: knownHosts,
|
||||
ssh_host_key_fingerprint: 'SHA256:fixtureFingerprint',
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -1974,4 +1977,90 @@ describe('SSH deploy-key route validation', () => {
|
||||
expect(serialized).not.toContain(deployKey);
|
||||
expect(serialized).not.toContain('encrypted_deploy_key');
|
||||
});
|
||||
|
||||
it('PUT stores a custom CA bundle and GET exposes has_ca_bundle without returning PEM', async () => {
|
||||
const stackName = 'https-ca-stack';
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
fs.mkdirSync(path.join(composeDir, stackName), { recursive: true });
|
||||
fs.writeFileSync(path.join(composeDir, stackName, 'compose.yaml'), 'services:\n x:\n image: nginx\n');
|
||||
const pem = readFileSync(path.join(process.cwd(), '..', 'e2e', 'fixtures', 'git-ca.pem'), 'utf8');
|
||||
const fetchFromGit = vi.spyOn(GitSourceService.getInstance(), 'fetchFromGit')
|
||||
.mockResolvedValue({
|
||||
composeFiles: [{ path: 'compose.yaml', content: 'services:\n x:\n image: nginx\n' }],
|
||||
envContent: null,
|
||||
commitSha: 'a'.repeat(40),
|
||||
resolvedRefKind: 'branch',
|
||||
warnings: [],
|
||||
});
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${stackName}/git-source`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
repo_url: 'https://git.example.com/org/repo.git',
|
||||
branch: 'main',
|
||||
compose_paths: ['compose.yaml'],
|
||||
auth_type: 'none',
|
||||
ca_bundle: pem,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.has_ca_bundle).toBe(true);
|
||||
expect(JSON.stringify(res.body)).not.toContain('BEGIN CERTIFICATE');
|
||||
fetchFromGit.mockRestore();
|
||||
});
|
||||
|
||||
it('PUT with remove_ca_bundle=true clears a previously stored CA bundle', async () => {
|
||||
const stackName = 'https-ca-revoke-stack';
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
fs.mkdirSync(path.join(composeDir, stackName), { recursive: true });
|
||||
fs.writeFileSync(path.join(composeDir, stackName, 'compose.yaml'), 'services:\n x:\n image: nginx\n');
|
||||
const pem = readFileSync(path.join(process.cwd(), '..', 'e2e', 'fixtures', 'git-ca.pem'), 'utf8');
|
||||
const fetchFromGit = vi.spyOn(GitSourceService.getInstance(), 'fetchFromGit')
|
||||
.mockResolvedValue({
|
||||
composeFiles: [{ path: 'compose.yaml', content: 'services:\n x:\n image: nginx\n' }],
|
||||
envContent: null,
|
||||
commitSha: 'b'.repeat(40),
|
||||
resolvedRefKind: 'branch',
|
||||
warnings: [],
|
||||
});
|
||||
// Step 1: store a CA bundle.
|
||||
const storeRes = await request(app)
|
||||
.put(`/api/stacks/${stackName}/git-source`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
repo_url: 'https://git.example.com/org/repo.git',
|
||||
branch: 'main',
|
||||
compose_paths: ['compose.yaml'],
|
||||
auth_type: 'none',
|
||||
ca_bundle: pem,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
});
|
||||
expect(storeRes.status).toBe(200);
|
||||
expect(storeRes.body.has_ca_bundle).toBe(true);
|
||||
// Step 2: explicit removal (textarea left empty, UI sets the flag).
|
||||
const revokeRes = await request(app)
|
||||
.put(`/api/stacks/${stackName}/git-source`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
repo_url: 'https://git.example.com/org/repo.git',
|
||||
branch: 'main',
|
||||
compose_paths: ['compose.yaml'],
|
||||
auth_type: 'none',
|
||||
remove_ca_bundle: true,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
});
|
||||
expect(revokeRes.status).toBe(200);
|
||||
expect(revokeRes.body.has_ca_bundle).toBe(false);
|
||||
expect(JSON.stringify(revokeRes.body)).not.toContain('BEGIN CERTIFICATE');
|
||||
// Step 3: GET should confirm the row no longer carries a CA bundle.
|
||||
const getRes = await request(app)
|
||||
.get(`/api/stacks/${stackName}/git-source`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body.has_ca_bundle).toBe(false);
|
||||
fetchFromGit.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,6 +409,137 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
|
||||
expect(row?.encrypted_token).not.toBe('ghp_secret_token_value');
|
||||
});
|
||||
|
||||
it('stores an encrypted CA bundle and exposes has_ca_bundle without leaking PEM', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
const pem = '-----BEGIN CERTIFICATE-----\nTEST-CA-PEM\n-----END CERTIFICATE-----\n';
|
||||
const created = await svc.upsert({
|
||||
stackName: 'ca-stack',
|
||||
repoUrl: 'https://git.example.com/org/repo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
caBundle: pem,
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
expect(created.has_ca_bundle).toBe(true);
|
||||
expect(JSON.stringify(created)).not.toContain('TEST-CA-PEM');
|
||||
const row = DatabaseService.getInstance().getGitSource('ca-stack');
|
||||
expect(row?.encrypted_ca_bundle).toBeTruthy();
|
||||
expect(row?.encrypted_ca_bundle).not.toBe(pem);
|
||||
});
|
||||
|
||||
it('explicitly removes a stored CA bundle when removeCaBundle is true, even when caBundle is omitted', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
const pem = '-----BEGIN CERTIFICATE-----\nTEST-CA-PEM\n-----END CERTIFICATE-----\n';
|
||||
// Step 1: store a CA bundle.
|
||||
await svc.upsert({
|
||||
stackName: 'ca-revoke-stack',
|
||||
repoUrl: 'https://git.example.com/org/repo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
caBundle: pem,
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
let row = DatabaseService.getInstance().getGitSource('ca-revoke-stack');
|
||||
expect(row?.encrypted_ca_bundle).toBeTruthy();
|
||||
// Step 2: simulate the operator clicking "Remove stored CA": the
|
||||
// textarea is left empty and the UI sends removeCaBundle: true with
|
||||
// caBundle omitted. The stored CA must be cleared.
|
||||
const updated = await svc.upsert({
|
||||
stackName: 'ca-revoke-stack',
|
||||
repoUrl: 'https://git.example.com/org/repo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
removeCaBundle: true,
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
expect(updated.has_ca_bundle).toBe(false);
|
||||
expect(JSON.stringify(updated)).not.toContain('TEST-CA-PEM');
|
||||
row = DatabaseService.getInstance().getGitSource('ca-revoke-stack');
|
||||
expect(row?.encrypted_ca_bundle).toBeNull();
|
||||
});
|
||||
|
||||
it('saves an explicit CA removal even when the repository is unreachable without that CA', async () => {
|
||||
// The dry-run reachability check normally runs on every save. A
|
||||
// repository that genuinely needs its CA to be reached would fail
|
||||
// that check the instant the CA is removed, refusing the very
|
||||
// request meant to retire it. removeCaBundle must bypass the
|
||||
// check so the operator's explicit intent to remove always saves.
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
const pem = '-----BEGIN CERTIFICATE-----\nTEST-CA-PEM\n-----END CERTIFICATE-----\n';
|
||||
mockResolveRef.mockImplementation(async (req: { caBundlePem?: string | null }) => {
|
||||
if (!req.caBundlePem) {
|
||||
throw gitFailure('unable to get local issuer certificate', false);
|
||||
}
|
||||
return { commitSha: 'a'.repeat(40), kind: 'branch' as const };
|
||||
});
|
||||
await svc.upsert({
|
||||
stackName: 'ca-required-stack',
|
||||
repoUrl: 'https://git.example.com/org/repo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
caBundle: pem,
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
|
||||
// Sanity check: without removeCaBundle, an upsert that can no longer
|
||||
// reach the repository is refused, proving the dry-run check itself
|
||||
// still runs for ordinary saves.
|
||||
await expect(svc.upsert({
|
||||
stackName: 'ca-required-stack',
|
||||
repoUrl: 'https://git.example.com/org/repo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
caBundle: null,
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
})).rejects.toBeTruthy();
|
||||
|
||||
// The removal itself must still save.
|
||||
const removed = await svc.upsert({
|
||||
stackName: 'ca-required-stack',
|
||||
repoUrl: 'https://git.example.com/org/repo.git',
|
||||
branch: 'main',
|
||||
composePaths: ['compose.yaml'],
|
||||
contextDir: null,
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
removeCaBundle: true,
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
expect(removed.has_ca_bundle).toBe(false);
|
||||
const row = DatabaseService.getInstance().getGitSource('ca-required-stack');
|
||||
expect(row?.encrypted_ca_bundle).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves an existing token when update omits token (undefined)', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
@@ -2619,6 +2750,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -2674,6 +2806,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -2814,6 +2947,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -2845,6 +2979,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -2878,6 +3013,7 @@ describe('GitSourceService legacy pending apply (migration path)', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
@@ -3156,6 +3292,7 @@ describe('GitSourceService classified plan fingerprint', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '../services/git/credentialHelper';
|
||||
import * as gitBinary from '../services/git/gitBinary';
|
||||
import { nativeGitTransport, REF_MAX_LEN, startSizeWatchdog, verifyFastForward } from '../services/git/nativeGitTransport';
|
||||
import { GIT_ALLOWED_HOST_ENV_VAR } from '../services/git/credentialHelper';
|
||||
|
||||
const GIT_EXEC_PATH_STUB = 'C:/Program Files/Git/mingw64/libexec/git-core';
|
||||
|
||||
@@ -410,6 +411,41 @@ describe('transport argv hardening', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('exports the configured HTTPS host[:port] to the credential helper so a cross-host redirect cannot match', async () => {
|
||||
scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]);
|
||||
const root = await makeWorkspace();
|
||||
try {
|
||||
await nativeGitTransport.resolveRef({
|
||||
repoUrl: 'https://git.example.com/example/repo.git',
|
||||
ref: 'main',
|
||||
token: 'sekrit',
|
||||
timeoutMs: 5000,
|
||||
workspaceRoot: root,
|
||||
});
|
||||
const env = spawnEnv(0);
|
||||
expect(env[GIT_ALLOWED_HOST_ENV_VAR]).toBe('git.example.com');
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not export the allowed host when no token is supplied (no credentials to scope)', async () => {
|
||||
scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]);
|
||||
const root = await makeWorkspace();
|
||||
try {
|
||||
await nativeGitTransport.resolveRef({
|
||||
repoUrl: 'https://git.example.com/example/repo.git',
|
||||
ref: 'main',
|
||||
timeoutMs: 5000,
|
||||
workspaceRoot: root,
|
||||
});
|
||||
const env = spawnEnv(0);
|
||||
expect(env[GIT_ALLOWED_HOST_ENV_VAR]).toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('combines NODE_EXTRA_CA_CERTS with platform defaults into http.sslCAInfo when set (dev/E2E bridge)', async () => {
|
||||
const caPath = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-git-ca-test-')), 'ca.pem');
|
||||
await fs.writeFile(caPath, '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----\n');
|
||||
@@ -482,6 +518,40 @@ describe('transport argv hardening', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('combines a per-source CA PEM into http.sslCAInfo without NODE_EXTRA_CA_CERTS', async () => {
|
||||
const prev = process.env.NODE_EXTRA_CA_CERTS;
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
try {
|
||||
scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]);
|
||||
const root = await makeWorkspace();
|
||||
const perSourcePem = '-----BEGIN CERTIFICATE-----\nPER-SOURCE-CA-MARKER\n-----END CERTIFICATE-----\n';
|
||||
await nativeGitTransport.resolveRef({
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
ref: 'main',
|
||||
caBundlePem: perSourcePem,
|
||||
timeoutMs: 5000,
|
||||
workspaceRoot: root,
|
||||
});
|
||||
const setArgs = spawnArgs(0);
|
||||
// Git never follows a redirect itself; an approved destination is
|
||||
// resolved by the preflight and retried explicitly.
|
||||
expect(setArgs).toContain('http.followRedirects=false');
|
||||
const combined = setArgs.find((a) => a.startsWith('http.sslCAInfo='));
|
||||
expect(combined).toBeDefined();
|
||||
if (process.platform !== 'win32') {
|
||||
const combinedBody = await fs.readFile(
|
||||
(combined as string).slice('http.sslCAInfo='.length).replace(/\//g, path.sep),
|
||||
'utf8',
|
||||
);
|
||||
expect(combinedBody).toContain('PER-SOURCE-CA-MARKER');
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
else process.env.NODE_EXTRA_CA_CERTS = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['plain http', 'http://github.com/example/repo.git'],
|
||||
['embedded userinfo', 'https://user:pass@github.com/example/repo.git'],
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The CA bundle sink is the only place a per-fetch PEM file is written for
|
||||
* git to read via http.sslCAInfo. These tests pin the invariants CodeQL was
|
||||
* asked to ignore: every output path is inside the supplied metaDir, the
|
||||
* written content is concatenated PEM only, and a non-PEM NODE_EXTRA_CA_CERTS
|
||||
* file is dropped rather than passed through to git.
|
||||
*/
|
||||
import { promises as fs, mkdtempSync, statSync, writeFileSync } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { writeCombinedCaBundle } from '../services/git/gitCaBundleSink';
|
||||
|
||||
const SAMPLE_CA_A = '-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv\n-----END CERTIFICATE-----\n';
|
||||
const SAMPLE_CA_B = '-----BEGIN CERTIFICATE-----\nQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=\n-----END CERTIFICATE-----\n';
|
||||
// Controlled system CA fixture with a unique marker for deterministic tests
|
||||
const SYSTEM_CA_FIXTURE = '-----BEGIN CERTIFICATE-----\nSENCHO_TEST_SYSTEM_CA_MARKER_UNIQUE_12345\n-----END CERTIFICATE-----\n';
|
||||
|
||||
describe('writeCombinedCaBundle', () => {
|
||||
let metaDir: string;
|
||||
let prevExtraCaCerts: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
metaDir = mkdtempSync(path.join(os.tmpdir(), 'sencho-ca-sink-'));
|
||||
prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts;
|
||||
await fs.rm(metaDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null when no per-source PEM and no NODE_EXTRA_CA_CERTS is set', async () => {
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
const result = await writeCombinedCaBundle(metaDir, null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('writes a file inside the supplied metaDir and returns its absolute path', async () => {
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A);
|
||||
expect(result).not.toBeNull();
|
||||
// The returned path is the metaDir/combined-ca.pem, with forward slashes
|
||||
// for git. The test must compare resolved paths, not string prefixes,
|
||||
// because the metaDir may itself contain a forward-slash via mkdtemp
|
||||
// (which it does not on POSIX, but path.resolve normalizes either way).
|
||||
const expected = path.resolve(metaDir, 'combined-ca.pem');
|
||||
expect(result!.path.replace(/\//g, path.sep)).toBe(expected);
|
||||
expect(result!.path.endsWith('combined-ca.pem')).toBe(true);
|
||||
const body = await fs.readFile(result!.path, 'utf8');
|
||||
expect(body).toContain('BEGIN CERTIFICATE');
|
||||
expect(body).toContain('END CERTIFICATE');
|
||||
});
|
||||
|
||||
it('writes the file with mode 0600', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
// POSIX-only check; Windows ignores mode bits on fs.writeFile.
|
||||
return;
|
||||
}
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A);
|
||||
const stat = statSync(result!.path);
|
||||
// 0o600 -> owner read+write, no group/other bits.
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('includes system, per-source, and env-var CAs when all are provided', async () => {
|
||||
const extraPath = path.join(metaDir, '..', 'extra-ca.pem');
|
||||
writeFileSync(extraPath, SAMPLE_CA_B);
|
||||
process.env.NODE_EXTRA_CA_CERTS = extraPath;
|
||||
// Inject a controlled system CA fixture with a unique marker via the
|
||||
// optional third parameter. This bypasses the platform read so the test
|
||||
// is deterministic regardless of CI distro.
|
||||
const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A, SYSTEM_CA_FIXTURE);
|
||||
expect(result).not.toBeNull();
|
||||
const body = await fs.readFile(result!.path, 'utf8');
|
||||
// Verify all three categories exist with unique, identifiable markers:
|
||||
// (1) Per-source PEM (SAMPLE_CA_A)
|
||||
expect(body).toContain('MIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv');
|
||||
// (2) NODE_EXTRA_CA_CERTS file content (SAMPLE_CA_B)
|
||||
expect(body).toContain('QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=');
|
||||
// (3) System CA fixture (controlled, unique marker)
|
||||
expect(body).toContain('SENCHO_TEST_SYSTEM_CA_MARKER_UNIQUE_12345');
|
||||
});
|
||||
|
||||
it('omits system CA when injection parameter is null (negative control)', async () => {
|
||||
const extraPath = path.join(metaDir, '..', 'extra-ca.pem');
|
||||
writeFileSync(extraPath, SAMPLE_CA_B);
|
||||
process.env.NODE_EXTRA_CA_CERTS = extraPath;
|
||||
// Pass null to explicitly skip system CA injection
|
||||
const result = await writeCombinedCaBundle(metaDir, SAMPLE_CA_A, null);
|
||||
expect(result).not.toBeNull();
|
||||
const body = await fs.readFile(result!.path, 'utf8');
|
||||
// Per-source and env-var CAs are present
|
||||
expect(body).toContain('MIIBkTCB+wIJAKHHCgVZU1w0MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv');
|
||||
expect(body).toContain('QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=');
|
||||
// System CA marker is NOT present - proves injection was needed
|
||||
expect(body).not.toContain('SENCHO_TEST_SYSTEM_CA_MARKER_UNIQUE_12345');
|
||||
});
|
||||
|
||||
it('drops a NODE_EXTRA_CA_CERTS file that is not valid PEM rather than writing it through', async () => {
|
||||
const extraPath = path.join(metaDir, '..', 'extra-ca.pem');
|
||||
writeFileSync(extraPath, 'this is not a certificate');
|
||||
process.env.NODE_EXTRA_CA_CERTS = extraPath;
|
||||
// No per-source PEM either: nothing valid to write, so null.
|
||||
const result = await writeCombinedCaBundle(metaDir, null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a non-PEM per-source input rather than writing it through', async () => {
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
const result = await writeCombinedCaBundle(metaDir, 'not a cert at all');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -184,6 +184,7 @@ describe('gitops interrupted create recovery', () => {
|
||||
encrypted_deploy_key: null,
|
||||
ssh_known_hosts_entry: null,
|
||||
ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: SHA,
|
||||
@@ -379,6 +380,7 @@ function seedCreate(
|
||||
encrypted_deploy_key: options.encryptedDeployKey ?? null,
|
||||
ssh_known_hosts_entry: options.sshKnownHostsEntry ?? null,
|
||||
ssh_host_key_fingerprint: options.sshHostKeyFingerprint ?? null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: 0,
|
||||
auto_deploy_on_apply: 0,
|
||||
commit_sha: SHA,
|
||||
|
||||
@@ -655,6 +655,7 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck
|
||||
encrypted_deploy_key: null,
|
||||
ssh_known_hosts_entry: null,
|
||||
ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: 0,
|
||||
auto_deploy_on_apply: 0,
|
||||
commit_sha: SHA,
|
||||
|
||||
@@ -667,6 +667,7 @@ describe('Direct Git producers drive the revision state', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: 'eeeeeee5',
|
||||
|
||||
@@ -115,6 +115,7 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck
|
||||
encrypted_deploy_key: null,
|
||||
ssh_known_hosts_entry: null,
|
||||
ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: 0,
|
||||
auto_deploy_on_apply: 0,
|
||||
commit_sha: SHA,
|
||||
|
||||
@@ -358,6 +358,7 @@ function seedStack(
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: options.lastApplied,
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('captured invocation on recovery Compose args', () => {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: 'abc',
|
||||
|
||||
@@ -25,6 +25,7 @@ function seedGitSource(stackName: string): void {
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null,
|
||||
encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
|
||||
Reference in New Issue
Block a user