mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 18:44:30 -07:00
feat(sandbox): implement secret visibility lockdown for env files (#23712)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
|
||||
import type { SandboxRequest } from '../../services/sandboxManager.js';
|
||||
import fs from 'node:fs';
|
||||
import * as shellUtils from '../../utils/shell-utils.js';
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||
@@ -20,17 +21,40 @@ vi.mock('node:fs', async () => {
|
||||
realpathSync: vi.fn((p) => p.toString()),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats),
|
||||
mkdirSync: vi.fn(),
|
||||
mkdtempSync: vi.fn((prefix: string) => prefix + 'mocked'),
|
||||
openSync: vi.fn(),
|
||||
closeSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(() => []),
|
||||
chmodSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
},
|
||||
existsSync: vi.fn(() => true),
|
||||
realpathSync: vi.fn((p) => p.toString()),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats),
|
||||
mkdirSync: vi.fn(),
|
||||
mkdtempSync: vi.fn((prefix: string) => prefix + 'mocked'),
|
||||
openSync: vi.fn(),
|
||||
closeSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(() => []),
|
||||
chmodSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../utils/shell-utils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
spawnAsync: vi.fn(() =>
|
||||
Promise.resolve({ status: 0, stdout: Buffer.from('') }),
|
||||
),
|
||||
initializeShellParsers: vi.fn(),
|
||||
isStrictlyApproved: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -452,4 +476,44 @@ describe('LinuxSandboxManager', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks .env and .env.* files in the workspace root', async () => {
|
||||
vi.mocked(shellUtils.spawnAsync).mockImplementation((cmd, args) => {
|
||||
if (cmd === 'find' && args?.[0] === workspace) {
|
||||
// Assert that find is NOT excluding dotfiles
|
||||
expect(args).not.toContain('-not');
|
||||
expect(args).toContain('-prune');
|
||||
|
||||
return Promise.resolve({
|
||||
status: 0,
|
||||
stdout: Buffer.from(
|
||||
`${workspace}/.env\0${workspace}/.env.local\0${workspace}/.env.test\0`,
|
||||
),
|
||||
} as unknown as ReturnType<typeof shellUtils.spawnAsync>);
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 0,
|
||||
stdout: Buffer.from(''),
|
||||
} as unknown as ReturnType<typeof shellUtils.spawnAsync>);
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const bindsIndex = bwrapArgs.indexOf('--seccomp');
|
||||
const binds = bwrapArgs.slice(0, bindsIndex);
|
||||
|
||||
expect(binds).toContain(`${workspace}/.env`);
|
||||
expect(binds).toContain(`${workspace}/.env.local`);
|
||||
expect(binds).toContain(`${workspace}/.env.test`);
|
||||
|
||||
// Verify they are bound to a mask file
|
||||
const envIndex = binds.indexOf(`${workspace}/.env`);
|
||||
expect(binds[envIndex - 2]).toBe('--bind');
|
||||
expect(binds[envIndex - 1]).toMatch(/gemini-cli-mask-file-.*mocked\/mask/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { join, dirname, normalize } from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
@@ -15,12 +14,15 @@ import {
|
||||
type SandboxedCommand,
|
||||
type SandboxPermissions,
|
||||
GOVERNANCE_FILES,
|
||||
getSecretFileFindArgs,
|
||||
sanitizePaths,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
||||
import {
|
||||
isStrictlyApproved,
|
||||
@@ -32,6 +34,10 @@ import {
|
||||
resolveGitWorktreePaths,
|
||||
isErrnoException,
|
||||
} from '../utils/fsUtils.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
|
||||
let cachedBpfPath: string | undefined;
|
||||
|
||||
@@ -85,9 +91,20 @@ function getSeccompBpfPath(): string {
|
||||
buf.writeUInt32LE(inst.k, offset + 4);
|
||||
}
|
||||
|
||||
const bpfPath = join(os.tmpdir(), `gemini-cli-seccomp-${process.pid}.bpf`);
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-seccomp-'));
|
||||
const bpfPath = join(tempDir, 'seccomp.bpf');
|
||||
fs.writeFileSync(bpfPath, buf);
|
||||
cachedBpfPath = bpfPath;
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
return bpfPath;
|
||||
}
|
||||
|
||||
@@ -110,11 +127,6 @@ function touch(filePath: string, isDirectory: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
|
||||
*/
|
||||
@@ -130,6 +142,8 @@ export interface LinuxSandboxOptions extends GlobalSandboxOptions {
|
||||
}
|
||||
|
||||
export class LinuxSandboxManager implements SandboxManager {
|
||||
private static maskFilePath: string | undefined;
|
||||
|
||||
constructor(private readonly options: LinuxSandboxOptions) {}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
@@ -140,6 +154,31 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
|
||||
private getMaskFilePath(): string {
|
||||
if (
|
||||
LinuxSandboxManager.maskFilePath &&
|
||||
fs.existsSync(LinuxSandboxManager.maskFilePath)
|
||||
) {
|
||||
return LinuxSandboxManager.maskFilePath;
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-mask-file-'));
|
||||
const maskPath = join(tempDir, 'mask');
|
||||
fs.writeFileSync(maskPath, '');
|
||||
fs.chmodSync(maskPath, 0);
|
||||
LinuxSandboxManager.maskFilePath = maskPath;
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
return maskPath;
|
||||
}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
||||
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
||||
@@ -319,6 +358,11 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Mask secret files (.env, .env.*)
|
||||
bwrapArgs.push(
|
||||
...(await this.getSecretFilesArgs(req.policy?.allowedPaths)),
|
||||
);
|
||||
|
||||
const bpfPath = getSeccompBpfPath();
|
||||
|
||||
bwrapArgs.push('--seccomp', '9');
|
||||
@@ -339,4 +383,68 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
cwd: req.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates bubblewrap arguments to mask secret files.
|
||||
*/
|
||||
private async getSecretFilesArgs(allowedPaths?: string[]): Promise<string[]> {
|
||||
const args: string[] = [];
|
||||
const maskPath = this.getMaskFilePath();
|
||||
const paths = sanitizePaths(allowedPaths) || [];
|
||||
const searchDirs = new Set([this.options.workspace, ...paths]);
|
||||
const findPatterns = getSecretFileFindArgs();
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
try {
|
||||
// Use the native 'find' command for performance and to catch nested secrets.
|
||||
// We limit depth to 3 to keep it fast while covering common nested structures.
|
||||
// We use -prune to skip heavy directories efficiently while matching dotfiles.
|
||||
const findResult = await spawnAsync('find', [
|
||||
dir,
|
||||
'-maxdepth',
|
||||
'3',
|
||||
'-type',
|
||||
'd',
|
||||
'(',
|
||||
'-name',
|
||||
'.git',
|
||||
'-o',
|
||||
'-name',
|
||||
'node_modules',
|
||||
'-o',
|
||||
'-name',
|
||||
'.venv',
|
||||
'-o',
|
||||
'-name',
|
||||
'__pycache__',
|
||||
'-o',
|
||||
'-name',
|
||||
'dist',
|
||||
'-o',
|
||||
'-name',
|
||||
'build',
|
||||
')',
|
||||
'-prune',
|
||||
'-o',
|
||||
'-type',
|
||||
'f',
|
||||
...findPatterns,
|
||||
'-print0',
|
||||
]);
|
||||
|
||||
const files = findResult.stdout.toString().split('\0');
|
||||
for (const file of files) {
|
||||
if (file.trim()) {
|
||||
args.push('--bind', maskPath, file.trim());
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.log(
|
||||
`LinuxSandboxManager: Failed to find or mask secret files in ${dir}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user