Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
93 changes: 93 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,88 @@ 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
expect(mount).toMatch(/chroot-hosts:\/host\/etc\/hosts:ro$/);
const hostsPath = mount.split(':')[0];
expect(hostsPath).toBe(path.join(tmpDir, 'chroot-hosts'));
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('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:');
});
});
35 changes: 34 additions & 1 deletion src/services/agent-volumes/hosts-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,40 @@
}
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 (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: write hosts file directly in hostsRootDir (config.workDir is
// already unique per run, so a subdirectory is not required for isolation).
const fallbackPath = path.join(hostsRootDir, 'chroot-hosts');
fs.writeFileSync(fallbackPath, hostsContent, { mode: 0o644 });
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return `${fallbackPath}:/host/etc/hosts:ro`;
}
throw err;
}

return `${chrootHostsPath}:/host/etc/hosts:ro`;
}
Expand Down
Loading