mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-09 15:02:30 +02:00
fix(config-protection): match protected filenames case-insensitively (#2543)
On a case-insensitive filesystem (macOS APFS/HFS+, Windows NTFS) a write to `.ESLINTRC.JS` lands on the exact same inode as `.eslintrc.js`, but the guard looked the basename up in PROTECTED_FILES with a case-sensitive `Set.has`. Every entry in that Set is lowercase, so any case-variant path missed the branch entirely and returned exit 0 — a single Write silently overwrote a live config while the hook reported success. Reproduced on macOS APFS: `.eslintrc.js` and `.ESLINTRC.JS` share one inode, yet the hook returned exit 2 for the former and exit 0 for the latter, and the uppercase write replaced the real config's contents. This is a one-step bypass of the whole guard and needs no shell access, unlike the known delete-then-recreate route. Fix: also test `basename.toLowerCase()`. All 32 PROTECTED_FILES entries are already lowercase, so the fallback is exact. On a genuinely case-sensitive filesystem this costs at most a false positive on a distinct file whose name differs from a protected one by case alone. Behaviour deliberately unchanged: first-time creation is still allowed (the bootstrap affordance), non-config paths still pass through, and the existing lstat/ENOENT fail-closed semantics are untouched. Test: adds a case-variant case that asserts exit 2. It guards itself with an inode comparison and skips on case-sensitive filesystems rather than asserting something untrue there. Verified in both directions — it FAILS against the unpatched hook (`Got 0; 0 !== 2`) and passes with the fix. Suite: 9/9.
This commit is contained in:
parent
96789caaf9
commit
e7b3ba07bb
2 changed files with 54 additions and 5 deletions
|
|
@ -94,7 +94,14 @@ function run(inputOrRaw, options = {}) {
|
||||||
if (!filePath) return { exitCode: 0 };
|
if (!filePath) return { exitCode: 0 };
|
||||||
|
|
||||||
const basename = path.basename(filePath);
|
const basename = path.basename(filePath);
|
||||||
if (PROTECTED_FILES.has(basename)) {
|
// Match case-insensitively. Every PROTECTED_FILES entry is lowercase, and on
|
||||||
|
// case-insensitive filesystems (macOS APFS/HFS+, Windows NTFS) a write to
|
||||||
|
// `.ESLINTRC.JS` lands on the very same inode as `.eslintrc.js`. A
|
||||||
|
// case-sensitive Set lookup therefore let a single case-variant Write
|
||||||
|
// silently overwrite the real config while the guard returned exit 0.
|
||||||
|
// On genuinely case-sensitive filesystems this only costs a false positive
|
||||||
|
// on a distinct file that differs from a protected name by case alone.
|
||||||
|
if (PROTECTED_FILES.has(basename) || PROTECTED_FILES.has(basename.toLowerCase())) {
|
||||||
// Allow first-time creation — there's no existing config to weaken.
|
// Allow first-time creation — there's no existing config to weaken.
|
||||||
// The hook's purpose is blocking modifications; writing a brand-new
|
// The hook's purpose is blocking modifications; writing a brand-new
|
||||||
// config file in a project that has none is a legitimate bootstrap
|
// config file in a project that has none is a legitimate bootstrap
|
||||||
|
|
|
||||||
|
|
@ -234,10 +234,52 @@ function runTests() {
|
||||||
const result = runHook(input);
|
const result = runHook(input);
|
||||||
assert.strictEqual(result.code, 2, `Expected exit 2 for dangling symlink, got ${result.code}; stderr: ${result.stderr}`);
|
assert.strictEqual(result.code, 2, `Expected exit 2 for dangling symlink, got ${result.code}; stderr: ${result.stderr}`);
|
||||||
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
||||||
assert.ok(
|
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`);
|
||||||
result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'),
|
} finally {
|
||||||
`Expected block message, got: ${result.stderr}`
|
try {
|
||||||
);
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('blocks case-variant writes that resolve to an existing protected config', () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
||||||
|
try {
|
||||||
|
const realPath = path.join(tmpDir, '.eslintrc.js');
|
||||||
|
const variantPath = path.join(tmpDir, '.ESLINTRC.JS');
|
||||||
|
fs.writeFileSync(realPath, 'module.exports = { rules: { "no-explicit-any": "error" } };');
|
||||||
|
|
||||||
|
// Only meaningful on a case-insensitive filesystem (macOS APFS/HFS+,
|
||||||
|
// Windows NTFS), where the uppercase path is the SAME inode. On a
|
||||||
|
// case-sensitive filesystem the variant is a genuinely different file
|
||||||
|
// and the write is harmless, so skip rather than assert.
|
||||||
|
let sameFile = false;
|
||||||
|
try {
|
||||||
|
sameFile = fs.lstatSync(variantPath).ino === fs.lstatSync(realPath).ino;
|
||||||
|
} catch {
|
||||||
|
sameFile = false;
|
||||||
|
}
|
||||||
|
if (!sameFile) {
|
||||||
|
console.log(' (skipped: case-sensitive filesystem)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = runHook({
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: {
|
||||||
|
file_path: variantPath,
|
||||||
|
content: 'module.exports = { rules: {} }; // WEAKENED'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 2, `Case-variant write must be blocked: it overwrites ${path.basename(realPath)} on this filesystem. Got ${result.code}; stderr: ${result.stderr}`);
|
||||||
|
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue