diff --git a/src/services/agent-volumes/hosts-file-branches.test.ts b/src/services/agent-volumes/hosts-file-branches.test.ts index 004abfc5a..503bd7f55 100644 --- a/src/services/agent-volumes/hosts-file-branches.test.ts +++ b/src/services/agent-volumes/hosts-file-branches.test.ts @@ -22,6 +22,7 @@ jest.mock('../../logger', () => ({ const mockReaddirSync = jest.fn(); const mockStatSync = jest.fn(); +const mockWriteFileSync = jest.fn(); jest.mock('fs', () => { const actual = jest.requireActual('fs'); @@ -29,6 +30,7 @@ jest.mock('fs', () => { ...actual, readdirSync: (...args: Parameters) => mockReaddirSync(...args), statSync: (...args: Parameters) => mockStatSync(...args), + writeFileSync: (...args: Parameters) => mockWriteFileSync(...args), }; }); @@ -64,6 +66,9 @@ describe('generateHostsFileMount – localhostDetected branch', () => { mockStatSync.mockImplementation((...args: Parameters) => actual.statSync(...args) ); + mockWriteFileSync.mockImplementation((...args: Parameters) => + actual.writeFileSync(...args) + ); }); afterEach(() => { @@ -132,6 +137,9 @@ describe('pruneStaleChrootStageDirs – error handling', () => { mockStatSync.mockImplementation((...args: Parameters) => actual.statSync(...args) ); + mockWriteFileSync.mockImplementation((...args: Parameters) => + actual.writeFileSync(...args) + ); }); afterEach(() => { @@ -173,3 +181,137 @@ describe('pruneStaleChrootStageDirs – error handling', () => { expect(mockStatSync).toHaveBeenCalled(); }); }); + +describe('generateHostsFileMount – EACCES writeFileSync fallback', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = actual.mkdtempSync(path.join(os.tmpdir(), 'awf-eacces-')); + jest.clearAllMocks(); + mockReaddirSync.mockImplementation((...args: Parameters) => + actual.readdirSync(...args) + ); + mockStatSync.mockImplementation((...args: Parameters) => + actual.statSync(...args) + ); + mockWriteFileSync.mockImplementation((...args: Parameters) => + actual.writeFileSync(...args) + ); + }); + + afterEach(() => { + actual.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('falls back to writing hosts file directly in hostsRootDir on EACCES', () => { + const eaccesError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + + // First writeFileSync call (inside mkdtemp'd dir) throws EACCES; + // second call (fallback to hostsRootDir) succeeds. + mockWriteFileSync + .mockImplementationOnce(() => { throw eaccesError; }) + .mockImplementation((...args: Parameters) => + actual.writeFileSync(...args) + ); + + const config = makeConfig({ + workDir: tmpDir, + allowedDomains: [], + }); + + const mount = generateHostsFileMount(config); + + // Should return the fallback path (mkdtempSync in os.tmpdir) + expect(mount).toMatch(/awf-chroot-[A-Za-z0-9]+\/hosts:\/host\/etc\/hosts:ro$/); + const hostsPath = mount.split(':')[0]; + expect(actual.existsSync(hostsPath)).toBe(true); + expect(mockWriteFileSync).toHaveBeenCalledTimes(2); + }); + + it('re-throws non-EACCES write errors', () => { + const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + mockWriteFileSync.mockImplementationOnce(() => { throw enoentError; }); + + const config = makeConfig({ + workDir: tmpDir, + allowedDomains: [], + }); + + expect(() => generateHostsFileMount(config)).toThrow('ENOENT'); + }); + + it('re-throws EACCES errors in staging mode (shared hostsRootDir – no safe fallback)', () => { + // Use a /tmp-prefixed path so shouldUseDockerHostStaging() returns true, + // meaning hostsRootDir is a shared staging directory. + const stagingTmpDir = actual.mkdtempSync(path.join('/tmp', 'awf-staging-eacces-')); + try { + const eaccesError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + mockWriteFileSync.mockImplementationOnce(() => { throw eaccesError; }); + + const config = makeConfig({ + workDir: stagingTmpDir, + allowedDomains: [], + dockerHostPathPrefix: stagingTmpDir, // triggers useDockerHostStaging = true + }); + + // EACCES must propagate – no fallback when hostsRootDir is shared + expect(() => generateHostsFileMount(config)).toThrow('EACCES'); + // The fallback writeFileSync must NOT have been called + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + } finally { + actual.rmSync(stagingTmpDir, { recursive: true, force: true }); + } + }); + + it('emits diagnostic warning with uid/gid and stat info on EACCES fallback', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { logger } = require('../../logger'); + const eaccesError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + + mockWriteFileSync + .mockImplementationOnce(() => { throw eaccesError; }) + .mockImplementation((...args: Parameters) => + actual.writeFileSync(...args) + ); + + const config = makeConfig({ + workDir: tmpDir, + allowedDomains: [], + }); + + generateHostsFileMount(config); + + expect(logger.warn).toHaveBeenCalledTimes(1); + const warnMsg: string = logger.warn.mock.calls[0][0]; + expect(warnMsg).toContain('EACCES writing chroot hosts file'); + expect(warnMsg).toContain('Falling back'); + expect(warnMsg).toContain('chrootHostsDir:'); + }); + + it('reports "(cannot stat)" when statSync fails during EACCES diagnostics', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { logger } = require('../../logger'); + const eaccesError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + + // writeFileSync: first call EACCES, second call (fallback) succeeds + mockWriteFileSync + .mockImplementationOnce(() => { throw eaccesError; }) + .mockImplementation((...args: Parameters) => + actual.writeFileSync(...args) + ); + + // statSync: always throw during diagnostics (covers the catch blocks) + mockStatSync.mockImplementation(() => { throw new Error('stat failed'); }); + + const config = makeConfig({ + workDir: tmpDir, + allowedDomains: [], + }); + + const mount = generateHostsFileMount(config); + expect(mount).toMatch(/awf-chroot-[A-Za-z0-9]+\/hosts:\/host\/etc\/hosts:ro$/); + + const warnMsg: string = logger.warn.mock.calls[0][0]; + expect(warnMsg).toContain('(cannot stat)'); + }); +}); diff --git a/src/services/agent-volumes/hosts-file.ts b/src/services/agent-volumes/hosts-file.ts index 5a4483cea..5b31ea213 100644 --- a/src/services/agent-volumes/hosts-file.ts +++ b/src/services/agent-volumes/hosts-file.ts @@ -1,4 +1,5 @@ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import execa from 'execa'; import { logger } from '../../logger'; @@ -69,7 +70,42 @@ export function generateHostsFileMount(config: WrapperConfig): string { } const chrootHostsDir = fs.mkdtempSync(path.join(hostsRootDir, 'chroot-')); const chrootHostsPath = path.join(chrootHostsDir, 'hosts'); - fs.writeFileSync(chrootHostsPath, hostsContent, { mode: 0o644 }); + + try { + fs.writeFileSync(chrootHostsPath, hostsContent, { mode: 0o644 }); + } catch (err: unknown) { + if (!useDockerHostStaging && err && typeof err === 'object' && 'code' in err && err.code === 'EACCES') { + // Emit diagnostics so we can trace the root cause (runner environment, AppArmor, etc.) + const uid = process.getuid?.() ?? '?'; + const gid = process.getgid?.() ?? '?'; + let dirStat = '(cannot stat)'; + try { + const st = fs.statSync(chrootHostsDir); + dirStat = `uid=${st.uid} gid=${st.gid} mode=${(st.mode & 0o7777).toString(8)}`; + } catch { /* best effort */ } + let parentStat = '(cannot stat)'; + try { + const st = fs.statSync(hostsRootDir); + parentStat = `uid=${st.uid} gid=${st.gid} mode=${(st.mode & 0o7777).toString(8)}`; + } catch { /* best effort */ } + logger.warn( + `EACCES writing chroot hosts file (process uid=${uid} gid=${gid}):\n` + + ` target: ${chrootHostsPath}\n` + + ` chrootHostsDir: ${chrootHostsDir} [${dirStat}]\n` + + ` hostsRootDir: ${hostsRootDir} [${parentStat}]\n` + + ` Falling back to writing hosts file directly in hostsRootDir.` + ); + + // Fallback: create a fresh temp directory via mkdtempSync (which CodeQL + // recognizes as a secure temp-file pattern) at the OS tmpdir level, + // bypassing whatever is blocking writes inside the workDir subdirectory. + const fallbackDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-chroot-')); + const fallbackPath = path.join(fallbackDir, 'hosts'); + fs.writeFileSync(fallbackPath, hostsContent, { mode: 0o644 }); + return `${fallbackPath}:/host/etc/hosts:ro`; + } + throw err; + } return `${chrootHostsPath}:/host/etc/hosts:ro`; }