8000 Harden file writes and git operations against symlink/option injection by devill · Pull Request #48 · habit-hooks/habit-hooks · GitHub
[go: up one dir, main page]

Skip to content
Closed
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
8000
Diff view
5 changes: 3 additions & 2 deletions src/baseline/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { safeWriteFileSync } from '../safe-fs.js';

export const BASELINE_FILENAME = '.habit-hooks-baseline.json';
export const BASELINE_VERSION = 2;
Expand Down Expand Up @@ -131,7 +132,7 @@ export function saveBaseline(cwd: string, baseline: BaselineFile): void {
files: sortedEntries(baseline.files),
};
const serialized = `${JSON.stringify(ordered, null, 2)}\n`;
writeFileSync(baselinePath(cwd), serialized);
safeWriteFileSync(baselinePath(cwd), serialized);
}

export function baselineExists(cwd: string): boolean {
Expand Down
2 changes: 1 addition & 1 deletion src/checks/eslint-wrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function isConfigError(result: ShellResult, parsed: EslintFileResult[] | null):
}

function messageToViolation(filePath: string, m: EslintMessage & { ruleId: string }): Violation {
const smell = ESLINT_SMELL_MAP[m.ruleId] ?? m.ruleId;
const smell = Object.hasOwn(ESLINT_SMELL_MAP, m.ruleId) ? ESLINT_SMELL_MAP[m.ruleId] : m.ruleId;
const title = lookupPrompt(smell)?.title ?? m.ruleId;
const message = `${title}: ${m.message}`;
const source = `eslint:${m.ruleId}`;
Expand Down
2 changes: 1 addition & 1 deletion src/checks/knip-wrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ interface IssueContext {
}

function knipSmell(issueType: string): string {
return KNIP_SMELL_MAP[issueType] ?? issueType;
return Object.hasOwn(KNIP_SMELL_MAP, issueType) ? KNIP_SMELL_MAP[issueType] : issueType;
}

interface BuildViolationArgs {
Expand Down
5 changes: 3 additions & 2 deletions src/cli/init/apply-recommendations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { safeWriteFileSync } from '../../safe-fs.js';
import { detectToolStates, toolsForLanguage, type ToolName, type ToolState } from './detect.js';
import { installCommandsFor } from './install-commands.js';
import { describeKey, JSCPD_RECOMMENDATION } from './recommendations.js';
Expand Down Expand Up @@ -59,7 +60,7 @@ function mergeJscpdConfig(ctx: Ctx): void {
const missing = JSCPD_RECOMMENDATION.missingKeys(ctx.cwd);
const config = readJscpd(path);
if (missing.length === 0 || config === null) return;
writeFileSync(path, `${JSON.stringify(withAddedKeys(config, missing), null, 2)}\n`);
safeWriteFileSync(path, `${JSON.stringify(withAddedKeys(config, missing), null, 2)}\n`);
ctx.lines.out.push(` Updated .jscpd.json (added: ${missing.join(', ')})\n`);
}

Expand Down
6 changes: 3 additions & 3 deletions src/cli/init/git-hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { safeWriteFileSync } from '../../safe-fs.js';
import { detectPackageManager, runScriptCommand } from './install-commands.js';
import type { Language } from '../../config/schema.js';

Expand Down Expand Up @@ -36,8 +37,7 @@ function nativeHookPath(cwd: string): string {
}

function writeHook(path: string, body: string): HookResult {
writeFileSync(path, body);
chmodSync(path, 0o755);
safeWriteFileSync(path, body, { mode: 0o755 });
return { action: 'installed', path };
}

Expand Down
5 changes: 3 additions & 2 deletions src/cli/init/package-scripts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { safeWriteFileSync } from '../../safe-fs.js';

const HABIT_HOOKS_SCRIPT = 'habit-hooks';
const HABIT_HOOKS_COMMAND = 'habit-hooks';
Expand All @@ -25,7 +26,7 @@ function readPackageJson(cwd: string): { path: string; data: PackageJson } | nul
}

function writePackageJson(path: string, data: PackageJson): void {
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
safeWriteFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
}

function ensureScripts(data: PackageJson): Record<string, string> {
Expand Down
5 changes: 3 additions & 2 deletions src/cli/init/scaffold-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, writeFileSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { safeWriteFileSync } from '../../safe-fs.js';
import type { Language } from '../../config/schema.js';

const CONFIG_FILENAMES = [
Expand Down Expand Up @@ -68,7 +69,7 @@ export function scaffoldFile(args: ScaffoldFileArgs): ScaffoldResult {
const existing = findExisting(cwd, candidates);
if (existing !== null) return { path: existing, created: false };
const path = join(cwd, defaultName);
writeFileSync(path, template);
safeWriteFileSync(path, template);
return { path, created: true };
}

Expand Down
5 changes: 3 additions & 2 deletions src/cli/init/skill.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
import { existsSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { safeCopyFileSync } from '../../safe-fs.js';

const SKILL_NAMES = ['habit-hooks-review', 'habit-hooks-prompting'] as const;
const SKILL_FILE = 'SKILL.md';
Expand Down Expand Up @@ -43,7 +44,7 @@ function installSkill(name: string): SkillResult {
const target = targetPath(name);
if (existsSync(target)) return { name, action: 'conflict', source, target };
mkdirSync(targetDir(name), { recursive: true });
copyFileSync(source, target);
safeCopyFileSync(source, target);
return { name, action: 'installed', source, target };
}

Expand Down
14 changes: 14 additions & 0 deletions src/detect/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,18 @@ describe('detectTool', () => {
expect(result).not.toBeNull();
expect(result?.configPath).toBeNull();
});

it('rejects a package.json#bin path that escapes the package dir (no RCE)', () => {
const pkgDir = join(cwd, 'node_modules', 'eslint');
mkdirSync(pkgDir, { recursive: true });
writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name: 'eslint', bin: { eslint: '../../evil.js' } }));
writeFileSync(join(cwd, 'evil.js'), 'console.log("pwned")\n');
writeFileSync(join(cwd, 'eslint.config.js'), 'export default [];\n');

const result = detectTool(cwd, 'eslint');

// bin is out of bounds and there is no .bin fallback, so the tool is undetected
// rather than resolving to the attacker-planted file.
expect(result).toBeNull();
});
});
17 changes: 14 additions & 3 deletions src/detect/tool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { isAbsolute, join, relative } from 'node:path';

export type ToolName = 'eslint' | 'knip' | 'jscpd' | 'ruff' | 'deptry';

Expand Down Expand Up @@ -32,14 +32,25 @@ function readJson(path: string): Record<string, unknown> | null {
}
}

// The bin path comes from an attacker-controllable node_modules/<tool>/package.json.
// A value like "../../evil.js" would otherwise escape the package and let
// `habit-hooks` spawn an arbitrary committed file (RCE on clone + run). Reject any
// resolved path that lands outside the tool's own package directory.
function containedBin(packageDir: string, binValue: string): string | null {
const resolved = join(packageDir, binValue);
const rel = relative(packageDir, resolved);
if (rel.startsWith('..') || isAbsolute(rel)) return null;
return resolved;
}

function resolveBinFromPackageJson(packageDir: string, name: string): string | null {
const pkg = readJson(join(packageDir, 'package.json'));
if (pkg === null) return null;
const bin = pkg.bin;
if (typeof bin === 'string') return join(packageDir, bin);
if (typeof bin === 'string') return containedBin(packageDir, bin);
if (bin !== null && typeof bin === 'object' && !Array.isArray(bin)) {
const entry = (bin as Record<string, unknown>)[name];
if (typeof entry === 'string') return join(packageDir, entry);
if (typeof entry === 'string') return containedBin(packageDir, entry);
}
return null;
}
Expand Down
17 changes: 17 additions & 0 deletions src/git/scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getLastNCommitsChanges,
getMergeBase,
getUncommittedFiles,
UnsafeRefError,
} from './scope.js';
import { createGitRepo, type GitRepo } from '../../tests/helpers/git.js';

Expand Down Expand Up @@ -77,6 +78,22 @@ describe('git/scope', () => {
});
});

describe('option-injection guard', () => {
it('rejects a hash that looks like a git option instead of running diff', () => {
repo.writeFile('a.ts', 'export const a = 1;\n');
repo.commitAll('first');

expect(() => getChangedVsCommit(repo.cwd, '--output=/tmp/hh-pwn')).toThrow(UnsafeRefError);
});

it('rejects a merge-base ref that looks like a git option', () => {
repo.writeFile('a.ts', 'export const a = 1;\n');
repo.commitAll('first');

expect(() => getMergeBase(repo.cwd, '--output=/tmp/hh-pwn')).toThrow(UnsafeRefError);
});
});

describe('getMergeBase and getChangedVsBranch', () => {
it('finds the merge base against a branch and lists changed files', () => {
repo.writeFile('shared.ts', 'export const s = 1;\n');
Expand Down
20 changes: 18 additions & 2 deletions src/git/scope.ts
8
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import { resolve } from 'node:path';
import { gitExec } from './exec.js';

export class UnsafeRefError extends Error {
constructor(ref: string) {
super(`refusing git revision that looks like an option: ${ref}`);
this.name = 'UnsafeRefError';
}
}

// A revision that begins with `-` (from a committed config's branchBase or a
// `--since` value) would be parsed by git as an option, not a commit — e.g.
// `git diff --name-only --output=<file> HEAD` writes an arbitrary file. Reject
// such refs before they reach git.
function safeRef(ref: string): string {
if (ref.startsWith('-')) throw new UnsafeRefError(ref);
return ref;
}

function parseLines(stdout: string): string[] {
return stdout
.split('\n')
Expand Down Expand Up @@ -35,7 +51,7 @@ export function getUncommittedFiles(cwd: string): string[] {
}

export function getChangedVsCommit(cwd: string, hash: string): string[] {
const stdout = gitExec(['diff', '--name-only', hash, 'HEAD'], cwd);
const stdout = gitExec(['diff', '--name-only', safeRef(hash), 'HEAD', '--'], cwd);
return toAbsolute(cwd, parseLines(stdout));
}

Expand All @@ -44,7 +60,7 @@ export function getLastNCommitsChanges(cwd: string, n: number): string[] {
}

export function getMergeBase(cwd: string, base: string): string {
return gitExec(['merge-base', 'HEAD', base], cwd).trim();
return gitExec(['merge-base', 'HEAD', safeRef(base)], cwd).trim();
}

export function getChangedVsBranch(cwd: string, base: string): string[] {
Expand Down
5 changes: 1 addition & 4 deletions src/prompts/loader.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { resolvePackagedDir } from './packaged-dir.js';

function slugify(ruleId: string): string {
return ruleId.replace(/[:/]/g, '-').replace(/@/g, '');
}
import { slugify } from './slug.js';

function tryRead(path: string): string | null {
return existsSync(path) ? readFileSync(path, 'utf8').trimEnd() : null;
Expand Down
5 changes: 1 addition & 4 deletions src/prompts/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { join } from 'node:path';
import type { CoachingPrompt, Severity } from '../types.js';
import { defaultRules } from '../config/defaults.js';
import { resolvePackagedDir } from './packaged-dir.js';
import { slugify } from './slug.js';

interface RuleSeed {
id: string;
Expand All @@ -19,10 +20,6 @@ const supplementalSeeds: RuleSeed[] = [
},
];

function slugify(ruleId: string): string {
return ruleId.replace(/[:/]/g, '-').replace(/@/g, '');
}

function buildPrompt(seed: RuleSeed, packagedDir: string): CoachingPrompt {
return {
id: seed.id,
Expand Down
15 changes: 15 additions & 0 deletions src/prompts/slug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { slugify } from './slug.js';

describe('slugify', () => {
it('maps rule ids to filename stems (drops @, replaces :/)', () => {
expect(slugify('eslint:max-params')).toBe('eslint-max-params');
expect(slugify('@typescript-eslint/no-explicit-any')).toBe('typescript-eslint-no-explicit-any');
});

it('neutralizes path separators so an id cannot traverse out of a dir', () => {
expect(slugify('../../etc/passwd')).toBe('..-..-etc-passwd');
expect(slugify('..\\..\\windows')).toBe('..-..-windows');
expect(slugify('a/b\\c')).toBe('a-b-c');
});
});
8 changes: 8 additions & 0 deletions src/prompts/slug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Turn a rule/smell id into a prompt filename stem. Replacing both path
// separators (`/` and `\`) means a crafted id can never traverse out of the
// prompts directory when joined to it — `../../x` becomes `..-..-x`. Unknown
// smells don't reach the file lookup today, but this keeps the mapping safe if
// that ever changes.
export function slugify(ruleId: string): string {
return ruleId.replace(/[:/\\]/g, '-').replace(/@/g, '');
}
45 changes: 45 additions & 0 deletions src/safe-fs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { safeWriteFileSync, SymlinkWriteError } from './safe-fs.js';

describe('safeWriteFileSync', () => {
let dir: string;
let outside: string;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'hh-safefs-'));
outside = mkdtempSync(join(tmpdir(), 'hh-outside-'));
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});

it('writes a regular file and applies the requested mode', () => {
const path = join(dir, 'hook');
safeWriteFileSync(path, 'body', { mode: 0o755 });
expect(readFileSync(path, 'utf8')).toBe('body');
expect(statSync(path).mode & 0o777).toBe(0o755);
});

it('refuses to write through a dangling symlink (no escape outside the dir)', () => {
const target = join(outside, 'planted');
symlinkSync(target, join(dir, 'config'));

expect(() => safeWriteFileSync(join(dir, 'config'), 'x')).toThrow(SymlinkWriteError);
expect(existsSync(target)).toBe(false);
});

it('refuses to write through a symlink to an existing outside file', () => {
const target = join(outside, 'real');
safeWriteFileSync(target, 'original');
symlinkSync(target, join(dir, 'config'));

expect(() => safeWriteFileSync(join(dir, 'config'), 'tampered')).toThrow(SymlinkWriteError);
expect(readFileSync(target, 'utf8')).toBe('original');
expect(lstatSync(join(dir, 'config')).isSymbolicLink()).toBe(true);
});
});
44 changes: 44 additions & 0 deletions src/safe-fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { closeSync, constants, fchmodSync, openSync, readFileSync, writeSync } from 'node:fs';

// File writes that follow symlinks let a malicious working tree redirect them
// outside the project root. A dangling symlink at the target (target absent)
// slips past an `existsSync` guard, and the write then lands wherever the link
// points — including, for the git hook, an executable. Opening with O_NOFOLLOW
// makes the open fail (ELOOP) when the final path component is a symlink, which
// closes the hole without a TOCTOU window.

export class SymlinkWriteError extends Error {
constructor(path: string) {
super(`refusing to write through symlink: ${path}`);
this.name = 'SymlinkWriteError';
}
}

interface SafeWriteOptions {
mode?: number; // applied to the opened fd (does not follow the link)
}

const NOFOLLOW_WRITE = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW;

function openNoFollow(path: string): number {
try {
return openSync(path, NOFOLLOW_WRITE, 0o666);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ELOOP') throw new SymlinkWriteError(path);
throw err;
}
}

export function safeWriteFileSync(path: string, data: string, options: SafeWriteOptions = {}): void {
const fd = openNoFollow(path);
try {
writeSync(fd, data);
if (options.mode !== undefined) fchmodSync(fd, options.mode);
} finally {
closeSync(fd);
}
}

export function safeCopyFileSync(source: string, target: string, options: SafeWriteOptions = {}): void {
safeWriteFileSync(target, readFileSync(source, 'utf8'), options);
}
Loading
0