Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions src/services/agent-volumes/hosts-file-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ jest.mock('../../logger', () => ({

const mockReaddirSync = jest.fn();
const mockStatSync = jest.fn();
const mockWriteFileSync = jest.fn();

jest.mock('fs', () => {
const actual = jest.requireActual<typeof import('fs')>('fs');
return {
...actual,
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => mockReaddirSync(...args),
statSync: (...args: Parameters<typeof actual.statSync>) => mockStatSync(...args),
writeFileSync: (...args: Parameters<typeof actual.writeFileSync>) => mockWriteFileSync(...args),
};
});

Expand Down Expand Up @@ -64,6 +66,9 @@ describe('generateHostsFileMount – localhostDetected branch', () => {
mockStatSync.mockImplementation((...args: Parameters<typeof actual.statSync>) =>
actual.statSync(...args)
);
mockWriteFileSync.mockImplementation((...args: Parameters<typeof actual.writeFileSync>) =>
actual.writeFileSync(...args)
);
});

afterEach(() => {
Expand Down Expand Up @@ -132,6 +137,9 @@ describe('pruneStaleChrootStageDirs – error handling', () => {
mockStatSync.mockImplementation((...args: Parameters<typeof actual.statSync>) =>
actual.statSync(...args)
);
mockWriteFileSync.mockImplementation((...args: Parameters<typeof actual.writeFileSync>) =>
actual.writeFileSync(...args)
);
});

afterEach(() => {
Expand Down Expand Up @@ -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<typeof actual.readdirSync>) =>
actual.readdirSync(...args)
);
mockStatSync.mockImplementation((...args: Parameters<typeof actual.statSync>) =>
actual.statSync(...args)
);
mockWriteFileSync.mockImplementation((...args: Parameters<typeof actual.writeFileSync>) =>
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<typeof actual.writeFileSync>) =>
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<typeof actual.writeFileSync>) =>
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<typeof actual.writeFileSync>) =>
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)');
});
});
38 changes: 37 additions & 1 deletion src/services/agent-volumes/hosts-file.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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`;
}
Expand Down
Loading