Skip to content

Commit cd53588

Browse files
Mossakaclaude
andauthored
test: expand credential hiding tests to all 14 protected paths (#1163)
* test: expand credential hiding tests to cover all 14 protected paths Add 3 new integration tests covering all 11 untested credential paths: SSH keys (4), AWS creds/config, Kube config, Azure creds, GCloud creds, Cargo creds, Composer auth. Tests verify 0 bytes at both direct home and /host chroot paths. Uses robust patterns (if -f, || true, extractCommandOutput) consistent with existing tests. Fixes #761 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): create dummy credential files before testing /dev/null mounts On CI runners, credential files like ~/.ssh/id_rsa don't exist, so AWF skips the /dev/null mount and the tests find 0 files instead of 11. Create dummy files in beforeAll and clean up in afterAll. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): use atomic wx flag to avoid TOCTOU race in credential setup Replace existsSync+writeFileSync with writeFileSync({flag:'wx'}) to eliminate the file-system-race CodeQL alert in the test beforeAll hook. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): use [ -e ] instead of [ -f ] for /dev/null-mounted files /dev/null-mounted credential files are character special devices, not regular files. [ -f ] returns false for them, causing wc -c to produce no output. Use [ -e ] (exists) to correctly detect these mounts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): verify /host paths are inaccessible in chroot mode AWF always runs in chroot mode (chroot /host), so /host$HOME/... paths don't exist inside the container. Changed the test from expecting 0-byte files at /host paths to verifying those paths are inaccessible, which is the correct security assertion for chroot mode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bca0834 commit cd53588

File tree

1 file changed

+132
-0
lines changed

1 file changed

+132
-0
lines changed

tests/integration/credential-hiding.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,138 @@ describe('Credential Hiding Security', () => {
227227
}, 120000);
228228
});
229229

230+
describe('All 14 Credential Paths Coverage', () => {
231+
// These tests cover the 11 credential paths not tested by Tests 1-4 above.
232+
// Each path is hidden via /dev/null mount and should return empty content.
233+
234+
const untestedPaths = [
235+
{ name: 'SSH id_rsa', path: '.ssh/id_rsa' },
236+
{ name: 'SSH id_ed25519', path: '.ssh/id_ed25519' },
237+
{ name: 'SSH id_ecdsa', path: '.ssh/id_ecdsa' },
238+
{ name: 'SSH id_dsa', path: '.ssh/id_dsa' },
239+
{ name: 'AWS credentials', path: '.aws/credentials' },
240+
{ name: 'AWS config', path: '.aws/config' },
241+
{ name: 'Kube config', path: '.kube/config' },
242+
{ name: 'Azure credentials', path: '.azure/credentials' },
243+
{ name: 'GCloud credentials.db', path: '.config/gcloud/credentials.db' },
244+
{ name: 'Cargo credentials', path: '.cargo/credentials' },
245+
{ name: 'Composer auth.json', path: '.composer/auth.json' },
246+
];
247+
248+
// Track files we create so we only clean up what we added
249+
const createdFiles: string[] = [];
250+
const createdDirs: string[] = [];
251+
252+
beforeAll(() => {
253+
// Create dummy credential files on the host so AWF will mount /dev/null over them.
254+
// Without these files existing, AWF skips the /dev/null mount and the files
255+
// simply don't exist inside the container.
256+
const homeDir = os.homedir();
257+
for (const p of untestedPaths) {
258+
const fullPath = `${homeDir}/${p.path}`;
259+
const dir = fullPath.substring(0, fullPath.lastIndexOf('/'));
260+
fs.mkdirSync(dir, { recursive: true });
261+
if (!createdDirs.includes(dir)) {
262+
createdDirs.push(dir);
263+
}
264+
try {
265+
// Use 'wx' flag: atomic create-if-not-exists (avoids TOCTOU race)
266+
fs.writeFileSync(fullPath, 'DUMMY_SECRET_VALUE', { flag: 'wx' });
267+
createdFiles.push(fullPath);
268+
} catch (err: unknown) {
269+
// EEXIST means file already exists, which is fine
270+
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code !== 'EEXIST') {
271+
throw err;
272+
}
273+
}
274+
}
275+
});
276+
277+
afterAll(() => {
278+
// Clean up only the files/dirs we created
279+
for (const f of createdFiles) {
280+
try { fs.unlinkSync(f); } catch { /* ignore */ }
281+
}
282+
// Remove dirs in reverse order (deepest first)
283+
for (const d of createdDirs.reverse()) {
284+
try { fs.rmdirSync(d); } catch { /* ignore if not empty */ }
285+
}
286+
});
287+
288+
test('All untested credential files are hidden at direct home path (0 bytes)', async () => {
289+
const homeDir = os.homedir();
290+
const paths = untestedPaths.map(p => `${homeDir}/${p.path}`).join(' ');
291+
292+
// Check all credential files in a single container run for efficiency.
293+
// wc -c reports byte count; /dev/null-mounted files should be 0 bytes.
294+
// Use '|| true' to prevent failures when files don't exist
295+
// Use [ -e ] instead of [ -f ] because /dev/null-mounted files are
296+
// character special devices, not regular files
297+
const result = await runner.runWithSudo(
298+
`sh -c 'for f in ${paths}; do if [ -e "$f" ]; then wc -c "$f"; fi; done 2>&1 || true'`,
299+
{
300+
allowDomains: ['github.com'],
301+
logLevel: 'debug',
302+
timeout: 60000,
303+
}
304+
);
305+
306+
expect(result).toSucceed();
307+
const cleanOutput = extractCommandOutput(result.stdout);
308+
const lines = cleanOutput.split('\n').filter(l => l.match(/^\s*\d+/));
309+
// Each file should be 0 bytes (hidden via /dev/null)
310+
lines.forEach(line => {
311+
const size = parseInt(line.trim().split(/\s+/)[0]);
312+
expect(size).toBe(0);
313+
});
314+
// Verify we checked all 11 files
315+
expect(lines.length).toBe(untestedPaths.length);
316+
}, 120000);
317+
318+
test('All untested credential files are inaccessible at /host path (chroot prevents access)', async () => {
319+
const homeDir = os.homedir();
320+
const paths = untestedPaths.map(p => `/host${homeDir}/${p.path}`).join(' ');
321+
322+
// AWF always runs in chroot mode (chroot /host), so /host$HOME/... paths
323+
// don't exist inside the container — they're already inside the chroot.
324+
// This verifies that credentials can't be exfiltrated via /host prefix paths.
325+
const result = await runner.runWithSudo(
326+
`sh -c 'count=0; for f in ${paths}; do if [ -e "$f" ]; then count=$((count+1)); fi; done; echo "accessible: $count"'`,
327+
{
328+
allowDomains: ['github.com'],
329+
logLevel: 'debug',
330+
timeout: 60000,
331+
}
332+
);
333+
334+
expect(result).toSucceed();
335+
const cleanOutput = extractCommandOutput(result.stdout);
336+
// No files should be accessible at /host paths inside chroot
337+
expect(cleanOutput).toContain('accessible: 0');
338+
}, 120000);
339+
340+
test('cat on each untested credential file returns empty content', async () => {
341+
const homeDir = os.homedir();
342+
const paths = untestedPaths.map(p => `${homeDir}/${p.path}`).join(' ');
343+
344+
// cat all files and concatenate output - should be empty
345+
// Use [ -e ] instead of [ -f ] because /dev/null-mounted files are
346+
// character special devices, not regular files
347+
const result = await runner.runWithSudo(
348+
`sh -c 'for f in ${paths}; do if [ -e "$f" ]; then cat "$f"; fi; done 2>&1 || true'`,
349+
{
350+
allowDomains: ['github.com'],
351+
logLevel: 'debug',
352+
timeout: 60000,
353+
}
354+
);
355+
356+
expect(result).toSucceed();
357+
// All content should be empty (no credential data leaked)
358+
const cleanOutput = extractCommandOutput(result.stdout).trim();
359+
expect(cleanOutput).toBe('');
360+
}, 120000);
361+
});
230362

231363
describe('Security Verification', () => {
232364
test('Test 12: Simulated exfiltration attack gets empty data', async () => {

0 commit comments

Comments
 (0)