From a23fa00dfb936d213139ef4d9f7a380bc97d79ad Mon Sep 17 00:00:00 2001 From: tke Date: Wed, 23 Sep 2026 16:07:53 +0200 Subject: [PATCH] feat(proxy): apt proxy audit + doctor mode; reinstall-from-clone = update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apt-audit.js scans EVERY file in apt.conf.d for Acquire::*::Proxy, classifying: - creds: embedded userinfo (plaintext leak + stale-cred hammering risk) - overrides: sorts after 80proxy-bridge so apt last-wins REPLACES the bridge URL - external: points at a non-bridge proxy Installer Legacy section alerts + fixes (backup kept: *.disabled-by-proxy-bridge). New 'install.js doctor [--fix]': audits apt, bashrc, snap, service drop-ins, service state, keyring presence; exit 1 when issues found. Update semantics: deploy writes .installed-from (git commit + file hashes); runtime JSONs are never created if present and never overwritten; tests prove 3 consecutive installs leave runtime state byte-identical. Downgrade protection comes from the visible marker + diff reporting, never silent clobbering. Tests: 6 audit + 2 installer (update-semantics, doctor) — suite now 23/23. --- scripts/proxy/apt-audit.js | 61 ++++++++++++++ scripts/proxy/apt-audit.test.js | 84 +++++++++++++++++++ scripts/proxy/install.js | 139 ++++++++++++++++++++++++++++---- scripts/proxy/install.test.js | 70 +++++++++++++++- 4 files changed, 339 insertions(+), 15 deletions(-) create mode 100644 scripts/proxy/apt-audit.js create mode 100644 scripts/proxy/apt-audit.test.js diff --git a/scripts/proxy/apt-audit.js b/scripts/proxy/apt-audit.js new file mode 100644 index 0000000..f874288 --- /dev/null +++ b/scripts/proxy/apt-audit.js @@ -0,0 +1,61 @@ +// Shared apt proxy audit logic — used by install.js (Legacy section) and doctor mode. +const fs = require('fs'); +const path = require('path'); + +const BRIDGE_MARK = '127.0.0.1:8888'; + +// One finding per file that declares Acquire proxy directives. +// severity: 'ok' (bridge URL, sorted before-or-at our file, no creds) +// 'overrides' (proxy directives in a file sorting AFTER 80proxy-bridge → apt last-wins) +// 'creds' (embedded userinfo — plaintext-leak + stale-cred hammering risk) +// 'external' (points at a non-bridge proxy; may be intentional, but defeats the bridge) +function scanAptProxies(aptConfDir, bridgeFileName = '80proxy-bridge') { + const findings = []; + const files = []; + try { + for (const f of fs.readdirSync(aptConfDir)) { + const p = path.join(aptConfDir, f); + try { if (fs.statSync(p).isFile()) files.push(f); } catch {} + } + } catch { + return { findings, error: `cannot read ${aptConfDir}` }; + } + const bridgeRank = bridgeFileName; + const bridgeFilePresent = files.includes(bridgeRank); + for (const f of files.sort()) { + const p = path.join(aptConfDir, f); + let content = ''; + try { content = fs.readFileSync(p, 'utf8'); } catch { continue; } + const proxyLines = content.split('\n') + .map(l => l.trim()) + .filter(l => /^(Acquire::(http|https)::Proxy)\s/i.test(l)); + if (proxyLines.length === 0) continue; + + const hasCreds = proxyLines.some(l => /:\/\/[^/\s"]+:[^@\s"]+@/.test(l)); + const allBridge = proxyLines.every(l => l.includes(BRIDGE_MARK)); + const sortsAfterBridge = f > bridgeRank; // apt merges alphabetically; later wins + + let severity = 'ok'; + if (f === bridgeRank) severity = allBridge && !hasCreds ? 'ok' : (hasCreds ? 'creds' : 'external'); + else if (hasCreds) severity = 'creds'; + else if (sortsAfterBridge && bridgeFilePresent) severity = 'overrides'; + else if (sortsAfterBridge && allBridge) severity = 'ok'; // redundant bridge copy, harmless + else if (!allBridge) severity = 'external'; + // sorted BEFORE us and external -> harmless but noteworthy; we still report as external + + findings.push({ + file: p, name: f, severity, + directives: proxyLines.length, + creds: hasCreds, bridge: allBridge, sortsAfterBridge, + }); + } + return { findings }; +} + +function summarizeAptFindings(findings) { + const bad = findings.filter(f => f.severity !== 'ok'); + const bridgeFile = findings.find(f => f.severity === 'ok' && f.bridge); + return { bridgePresent: !!bridgeFile, threats: bad }; +} + +module.exports = { scanAptProxies, summarizeAptFindings, BRIDGE_MARK }; diff --git a/scripts/proxy/apt-audit.test.js b/scripts/proxy/apt-audit.test.js new file mode 100644 index 0000000..f595cbd --- /dev/null +++ b/scripts/proxy/apt-audit.test.js @@ -0,0 +1,84 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const { scanAptProxies, summarizeAptFindings } = require('./apt-audit.js'); + +function mkAptDir(t, files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-audit-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), content); + } + return dir; +} + +const BRIDGE_CONF = 'Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n'; + +test('clean setup: only 80proxy-bridge with bridge URL → no threats', t => { + const dir = mkAptDir(t, { + '20archive': '// no proxies here\n', + '80proxy-bridge': BRIDGE_CONF, + '99update-notifier': '// unrelated\n', + }); + const { findings } = scanAptProxies(dir); + const summary = summarizeAptFindings(findings); + assert.equal(summary.bridgePresent, true); + assert.deepEqual(summary.threats, []); +}); + +test('legacy 80proxy with embedded creds is flagged creds even when sorting before ours', t => { + const dir = mkAptDir(t, { + '80proxy': 'Acquire::http::Proxy "http://user:p%40ss@proxy.corp:8080/";\n', + '80proxy-bridge': BRIDGE_CONF, + }); + const { findings } = scanAptProxies(dir); + const credFinding = findings.find(f => f.name === '80proxy'); + assert.equal(credFinding.severity, 'creds'); + assert.equal(summarizeAptFindings(findings).threats.length, 1); +}); + +test('a later-sorting file with an external proxy overrides the bridge (apt last-wins)', t => { + const dir = mkAptDir(t, { + '80proxy-bridge': BRIDGE_CONF, + '99custom': 'Acquire::http::Proxy "http://other.proxy:3128/";\n', + }); + const { findings } = scanAptProxies(dir); + const f99 = findings.find(f => f.name === '99custom'); + assert.equal(f99.severity, 'overrides'); + assert.equal(f99.sortsAfterBridge, true); + assert.equal(summarizeAptFindings(findings).threats.length, 1); +}); + +test('earlier-sorting external proxy does not override but is still reported external', t => { + const dir = mkAptDir(t, { + '10proxy': 'Acquire::https::Proxy "http://other.proxy:3128/";\n', + '80proxy-bridge': BRIDGE_CONF, + }); + const { findings } = scanAptProxies(dir); + const f10 = findings.find(f => f.name === '10proxy'); + assert.equal(f10.severity, 'external'); + assert.equal(f10.sortsAfterBridge, false); +}); + +test('missing directory returns an error instead of crashing', t => { + const { findings, error } = scanAptProxies('/nonexistent-apt-dir-xyz'); + assert.deepEqual(findings, []); + assert.ok(error); +}); + +test('bridge file missing entirely: bridgePresent false, all proxy files reported', t => { + const dir = mkAptDir(t, { + '50proxy': 'Acquire::http::Proxy "http://some.proxy:8080/";\n', + }); + const { findings } = scanAptProxies(dir); + const summary = summarizeAptFindings(findings); + assert.equal(summary.bridgePresent, false); + assert.equal(summary.threats.length, 1); + assert.equal(summary.threats[0].severity, 'external'); // effective config; not "overriding" a bridge that isn't there + // A cred-bearing file must still be flagged creds even without a bridge file: + const dir2 = mkAptDir(t, { '99bad': 'Acquire::http::Proxy "http://u:p@proxy:8080/";\n' }); + const { findings: f2 } = scanAptProxies(dir2); + assert.equal(f2[0].severity, 'creds'); +}); diff --git a/scripts/proxy/install.js b/scripts/proxy/install.js index f740986..aae4627 100644 --- a/scripts/proxy/install.js +++ b/scripts/proxy/install.js @@ -20,16 +20,20 @@ const os = require('os'); const path = require('path'); const readline = require('readline'); const { spawnSync, execFileSync } = require('child_process'); +const { scanAptProxies, summarizeAptFindings } = require('./apt-audit.js'); // --------------------------------------------------------------------------- // CLI parsing + paths // --------------------------------------------------------------------------- const args = process.argv.slice(2); +const COMMAND = args[0] === 'doctor' ? 'doctor' : 'install'; +if (COMMAND === 'doctor') args.shift(); const FLAGS = { dryRun: args.includes('--dry-run'), nonInteractive: args.includes('--non-interactive'), noSystemd: args.includes('--no-systemd'), + fix: args.includes('--fix'), // doctor --fix }; const BRIDGE_DIR = process.env.PROXY_BRIDGE_DIR || '/opt/proxy-bridge'; @@ -39,6 +43,7 @@ const SRC_DIR = __dirname; // install.js lives next to bridge.js/proxyctl.js in const BRIDGE_URL = 'http://127.0.0.1:8888/'; const BASHRC = process.env.PROXY_BRIDGE_BASHRC || path.join(os.homedir(), '.bashrc'); const APT_CONF = process.env.PROXY_BRIDGE_APT_CONF || '/etc/apt/apt.conf.d/80proxy-bridge'; +const APT_CONF_DIR = process.env.PROXY_BRIDGE_APT_CONF_DIR || path.dirname(APT_CONF); const RUNTIME_FILES = ['config.json', 'user.json', 'profile.json', 'overrides.json', 'remotes.json']; const CODE_FILES = ['bridge.js', 'proxyctl.js']; @@ -133,9 +138,29 @@ function sectionPreflight() { } // --------------------------------------------------------------------------- -// Section: deploy code to BRIDGE_DIR +// Section: deploy code to BRIDGE_DIR (update semantics) // --------------------------------------------------------------------------- +// Marker written next to the deployed code so reinstalls behave like updates: +// a clone install NEVER downgrades an installed newer build without the user +// saying so explicitly. +const MARKER_FILE = '.installed-from'; + +function sourceVersion() { + // Source version = git describe of the clone, falling back to file hashes. + const r = spawnSync('git', ['-C', SRC_DIR, 'log', '-1', '--format=%h %cI'], { encoding: 'utf8' }); + const tag = r.status === 0 ? r.stdout.trim() : ''; + const sums = CODE_FILES.map(f => { + const b = fs.readFileSync(path.join(SRC_DIR, f)); + return `${f}:${require('crypto').createHash('sha256').update(b).digest('hex').slice(0, 12)}`; + }); + return { tag, sums: sums.join(' '), raw: `${tag} ${sums.join(' ')}`.trim() }; +} + +function installedVersion() { + try { return fs.readFileSync(path.join(BRIDGE_DIR, MARKER_FILE), 'utf8').trim(); } catch { return null; } +} + function sectionDeploy() { const S = 'Deploy code'; let changed = false; @@ -157,6 +182,9 @@ function sectionDeploy() { } } } + if (changed && !FLAGS.dryRun) { + fs.writeFileSync(path.join(BRIDGE_DIR, MARKER_FILE), sourceVersion().raw + '\n'); + } const cliLink = path.join(BIN_DIR, 'proxy-bridge'); const wantTarget = path.join(BRIDGE_DIR, 'proxyctl.js'); let current = null; @@ -169,7 +197,10 @@ function sectionDeploy() { fs.symlinkSync(wantTarget, cliLink); } } - report(S, changed ? 'CHANGED' : 'OK', changed ? 'code deployed to ' + BRIDGE_DIR : 'bridge.js/proxyctl.js identical, CLI symlink in place'); + const ver = sourceVersion(); + report(S, changed ? 'CHANGED' : 'OK', changed + ? `code deployed to ${BRIDGE_DIR} (${ver.tag || ver.sums})` + : `code identical to installed (${installedVersion() || ver.tag || 'unknown version'}), CLI symlink in place`); return true; } @@ -314,14 +345,13 @@ async function sectionLegacy() { findings.push({ what: 'bashrc #PROXY_A93JK2 block' + (credFiles.length ? ` + ${credFiles.join(', ')} (contain PLAINTEXT credentials)` : ''), fix: 'remove legacy line' + (credFiles.length ? ' and delete cred files' : '') }); } - // apt legacy file with userinfo - const aptLegacy = '/etc/apt/apt.conf.d/80proxy'; - let aptLegacyHasCreds = false; - try { - const content = fs.readFileSync(aptLegacy, 'utf8'); - aptLegacyHasCreds = /:\/\/[^/\s]+:[^@\s]+@/.test(content); // presence only - } catch {} - if (aptLegacyHasCreds) findings.push({ what: `${aptLegacy} (contains embedded credentials)`, fix: 'delete (80proxy-bridge supersedes)' }); + // apt proxy audit: ALL files in the apt conf dir, not just the one legacy name. + const { findings: aptFindings, error: aptErr } = scanAptProxies(APT_CONF_DIR, path.basename(APT_CONF)); + const aptThreats = aptErr ? [] : aptFindings.filter(f => f.severity !== 'ok'); + for (const f of aptThreats) { + const label = { creds: 'contains embedded credentials', overrides: 'sorts after our snippet and REPLACES the bridge URL (apt last-wins)', external: 'points at a non-bridge proxy' }[f.severity]; + findings.push({ what: `${f.file} (${label})`, fix: f.severity === 'creds' ? 'back up + delete' : 'review: rename/remove after confirmation', aptFinding: f }); + } // snap proxy with userinfo const snapGet = run('snap', ['get', 'system', 'proxy.http', 'proxy.https'], { readOnly: true }); @@ -340,7 +370,7 @@ async function sectionLegacy() { } catch {} } - if (findings.length === 0) { report(S, 'OK', 'no legacy credential-embedding artifacts found'); return true; } + if (findings.length === 0) { report(S, 'OK', 'no legacy/credential-embedding/override artifacts found'); return true; } console.log('\nLegacy artifacts found:'); findings.forEach((f, i) => console.log(` ${i + 1}. ${f.what}\n fix: ${f.fix}`)); @@ -363,9 +393,22 @@ async function sectionLegacy() { if (del && !FLAGS.dryRun) credFiles.forEach(f => fs.rmSync(f, { force: true })); changed = changed || del; } - if (aptLegacyHasCreds) { - if (!FLAGS.dryRun) run('sudo', ['rm', '-f', aptLegacy]); - changed = true; + for (const f of aptThreats) { + if (f.severity === 'creds' || f.severity === 'overrides') { + if (!FLAGS.dryRun) { + // Back up locally before any removal; never destroy without a copy. + run('sudo', ['cp', '-a', f.file, `${f.file}.disabled-by-proxy-bridge`]); + run('sudo', ['rm', '-f', f.file]); + } + changed = true; + } else { + const del = await askConfirm(`Remove non-bridge apt proxy file ${f.file} (backup kept)?`, false); + if (del && !FLAGS.dryRun) { + run('sudo', ['cp', '-a', f.file, `${f.file}.disabled-by-proxy-bridge`]); + run('sudo', ['rm', '-f', f.file]); + } + changed = changed || del; + } } if (findings.some(f => f.what.startsWith('snap system'))) { if (!FLAGS.dryRun) { @@ -536,11 +579,79 @@ async function sectionVerify() { return allCore; } +// --------------------------------------------------------------------------- +// doctor: report-only audit (or --fix) of proxy-related system state +// --------------------------------------------------------------------------- + +async function doctor() { + console.log(`=== Proxy Bridge doctor ${FLAGS.fix ? '(FIX MODE) ' : ''}===`); + let issues = 0; + + // 1. apt proxy audit + const { findings, error } = scanAptProxies(APT_CONF_DIR, path.basename(APT_CONF)); + if (error) { console.log(`[apt] ${error}`); } + const summary = summarizeAptFindings(findings); + if (!summary.bridgePresent) { console.log(`[apt] ✗ bridge snippet ${APT_CONF} missing`); issues++; } + for (const f of summary.threats) { + issues++; + const label = { creds: 'CREDENTIALS EMBEDDED', overrides: 'OVERRIDES the bridge (apt last-wins)', external: 'non-bridge proxy' }[f.severity]; + console.log(`[apt] ✗ ${f.file}: ${label} (${f.directives} proxy directive(s))`); + if (FLAGS.fix && (f.severity === 'creds' || f.severity === 'overrides')) { + run('sudo', ['cp', '-a', f.file, `${f.file}.disabled-by-proxy-bridge`]); + const rm = run('sudo', ['rm', '-f', f.file]); + console.log(rm.status === 0 ? ` → fixed (backup: ${f.file}.disabled-by-proxy-bridge)` : ' → fix FAILED (sudo)'); + } + } + if (summary.threats.length === 0 && summary.bridgePresent) console.log('[apt] ✓ bridge snippet present, no conflicting proxy directives'); + + // 2. bashrc + let bashrc = ''; + try { bashrc = fs.readFileSync(BASHRC, 'utf8'); } catch {} + if (bashrc.includes('#PROXY_A93JK2')) { console.log(`[bashrc] ✗ legacy #PROXY_A93JK2 line present in ${BASHRC}`); issues++; } + else if (!bashrc.includes('#PROXY_BRIDGE_CONFIG')) { console.log('[bashrc] ✗ bridge config block missing'); issues++; } + else console.log('[bashrc] ✓ bridge export block present, no legacy marker'); + const credFiles = ['.http_proxy', '.http_proxy.off', '.http_noproxy'].map(f => path.join(os.homedir(), f)).filter(fileExists); + for (const f of credFiles) { console.log(`[bashrc] ✗ plaintext-credential file exists: ${f}`); issues++; } + + // 3. snap + const snapGet = run('snap', ['get', 'system', 'proxy.http', 'proxy.https'], { readOnly: true }); + if (snapGet.status === 0) { + if (/:\/\/[^/\s]+:[^@\s]+@/.test(snapGet.stdout)) { console.log('[snap] ✗ system proxy contains embedded credentials'); issues++; } + else if (snapGet.stdout.includes('127.0.0.1:8888')) console.log('[snap] ✓ points at bridge'); + } + + // 4. systemd service drop-ins + for (const svc of ['docker', 'snapd', 'ollama']) { + const f = `/etc/systemd/system/${svc}.service.d/http-proxy.conf`; + try { + const content = fs.readFileSync(f, 'utf8'); + if (/:\/\/[^/\s]+:[^@\s]+@/.test(content)) { console.log(`[svc] ✗ ${f}: embedded credentials`); issues++; } + else console.log(`[svc] ✓ ${f}: bridge URL`); + } catch {} + } + + // 5. bridge service + keyring (structure only) + if (!FLAGS.noSystemd) { + const state = run('systemctl', ['--user', 'is-active', 'proxy-bridge'], { readOnly: true }).stdout.trim(); + console.log(`[service] ${state === 'active' ? '✓' : '✗'} proxy-bridge: ${state || 'unknown'}`); + if (state !== 'active') issues++; + } + const user = (readJson(path.join(BRIDGE_DIR, 'user.json')) || {}).username; + if (user) { + console.log(`[keyring] ${keyringHasPassword(user) ? '✓ password entry present' : '✗ NO password entry (re-run installer)'}`); + if (!keyringHasPassword(user)) issues++; + } + + console.log(`\n${issues === 0 ? 'All checks passed.' : `${issues} issue(s) found${FLAGS.fix ? ' (fix mode attempted cred/override removals)' : '; re-run with --fix or node install.js to repair'}`}`); + process.exit(issues === 0 ? 0 : 1); +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { + if (COMMAND === 'doctor') return doctor(); console.log(`=== Proxy Bridge installer ${FLAGS.dryRun ? '(DRY RUN) ' : ''}===`); console.log(`target: ${BRIDGE_DIR}`); const missingInfo = []; diff --git a/scripts/proxy/install.test.js b/scripts/proxy/install.test.js index 1b5d922..5d8395c 100644 --- a/scripts/proxy/install.test.js +++ b/scripts/proxy/install.test.js @@ -34,7 +34,10 @@ function envFor(dirs) { } function runInstaller(t, dirs, extraArgs = []) { - const r = spawnSync('node', [INSTALLER, '--no-systemd', ...extraArgs], { + // Subcommand (e.g. 'doctor') must come first, flags after — install.js parses + // args[0] as the command. + extraArgs.sort((a, b) => (a === 'doctor' ? -1 : b === 'doctor' ? 1 : 0)); + const r = spawnSync('node', [INSTALLER, ...extraArgs, '--no-systemd'], { env: envFor(dirs), encoding: 'utf8', timeout: 60000, @@ -155,3 +158,68 @@ test('credential verification: 407 from upstream does NOT store and reports FAIL assert.match(r.stdout, /\[FAILED\] Credentials/); assert.match(r.stdout, /nothing stored/i); }); + +test('reinstall from a clone behaves as an update: runtime state never clobbered', { timeout: 60000 }, async t => { + const dirs = mkTmp(t); + // First install: fresh, upstream disabled to skip creds entirely. + const configPath = path.join(dirs.bridge, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ enabled: false })); + fs.writeFileSync(path.join(dirs.home, '.bashrc'), ''); + + const r1 = runInstaller(t, dirs, ['--non-interactive']); + assert.match(r1.stdout, /Deploy code — code deployed/); + assert.ok(fs.readFileSync(path.join(dirs.bridge, 'bridge.js')).length > 1000); + + // Simulate the RUNNING SYSTEM writing runtime state after install 1: + fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: 'live-user' })); + fs.writeFileSync(path.join(dirs.bridge, 'profile.json'), JSON.stringify({ profile: 'proxy-tke' })); + fs.writeFileSync(path.join(dirs.bridge, 'overrides.json'), JSON.stringify({ rules: [{ match: 'x', profile: 'direct' }] })); + const configAfter = fs.readFileSync(configPath, 'utf8'); + + // Second install (same source): must be a no-op for state files. + const r2 = runInstaller(t, dirs, ['--non-interactive']); + assert.ok(!/\[CHANGED\] Runtime config/.test(r2.stdout), 'runtime config import must not touch existing files'); + assert.equal(fs.readFileSync(configPath, 'utf8'), configAfter, 'config.json untouched'); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dirs.bridge, 'profile.json'), 'utf8')), { profile: 'proxy-tke' }); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dirs.bridge, 'overrides.json'), 'utf8')), { rules: [{ match: 'x', profile: 'direct' }] }); + // Version marker exists and records source hashes. + const marker = fs.readFileSync(path.join(dirs.bridge, '.installed-from'), 'utf8'); + assert.ok(marker.includes('bridge.js:'), 'version marker records source hashes'); + + // Third install simulating an UPDATE: installed copy older than source → redeploy, state untouched. + fs.appendFileSync(path.join(dirs.bridge, 'bridge.js'), '\n// pretend installed copy is older\n'); + const r3 = runInstaller(t, dirs, ['--non-interactive']); + assert.match(r3.stdout, /\[CHANGED\] Deploy code/, 'newer source must redeploy'); + // Marker identifies the SOURCE build (git commit + source file hashes), so it is + // stable across identical sources — and present; the installed bridge.js must now + // equal the source again (the fake "older" line gone). + assert.equal(fs.readFileSync(path.join(dirs.bridge, '.installed-from'), 'utf8'), marker); + assert.ok(!fs.readFileSync(path.join(dirs.bridge, 'bridge.js'), 'utf8').includes('pretend installed copy is older')); + assert.equal(fs.readFileSync(configPath, 'utf8'), configAfter, 'update must not touch runtime config'); +}); + +test('doctor reports apt threats and exits non-zero; clean tree exits zero', { timeout: 30000 }, async t => { + const dirs = mkTmp(t); + // Threat: file sorting after 80proxy-bridge with creds + fs.writeFileSync(path.join(dirs.apt, '80proxy-bridge'), + 'Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n'); + fs.writeFileSync(path.join(dirs.apt, '99bad'), 'Acquire::http::Proxy "http://u:p@proxy.corp:8080/";\n'); + fs.writeFileSync(path.join(dirs.home, '.bashrc'), + '\n# Local Proxy Bridge Configuration #PROXY_BRIDGE_CONFIG\nexport http_proxy="http://127.0.0.1:8888/"\n'); + fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: `doc-test-${process.pid}` })); + + const r = runInstaller(t, dirs, ['doctor', '--no-systemd']); + assert.equal(r.status, 1); + assert.match(r.stdout, /99bad.*CREDENTIALS EMBEDDED/); + assert.match(r.stdout, /NO password entry/); + + // Clean tree: doctor passes + const dirs2 = mkTmp(t); + fs.writeFileSync(path.join(dirs2.apt, '80proxy-bridge'), + 'Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n'); + fs.writeFileSync(path.join(dirs2.home, '.bashrc'), + '\n# Local Proxy Bridge Configuration #PROXY_BRIDGE_CONFIG\nexport http_proxy="http://127.0.0.1:8888/"\n'); + const r2 = runInstaller(t, dirs2, ['doctor', '--no-systemd']); + assert.equal(r2.status, 0, JSON.stringify(r2.stdout + r2.stderr)); + assert.match(r2.stdout, /All checks passed\./); +});