diff --git a/src/git.test.ts b/src/git.test.ts index 57b11d0..0287017 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -1,12 +1,17 @@ +import * as childProcess from "node:child_process"; import { execSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { findBaseSha } from "./base-sha"; +import { findAnchorAheadOfHead } from "./scan-base"; import { ConfigurationError } from "./provider"; import { assertGitAvailable, buildPathspecArgs, + commitExists, + verifyAncestorReachable, ensureCommitAvailable, extractBranchName, extractBranchNameFromMergeMessage, @@ -19,8 +24,14 @@ import { isAncestor, normalizePathspec, resolveFirstSyncBoundary, + resolveCommitRef, } from "./git"; +// Keep the real processes while allowing individual tests to observe calls. +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), +})); + describe("normalizePathspec", () => { it("should strip leading ./", () => { expect(normalizePathspec("./android/**")).toBe("android/**"); @@ -1326,3 +1337,109 @@ describe("countCommitsInRange", () => { } }); }); + +describe("Git input security", () => { + let cwd: string; + let head: string; + const git = (args: string[]) => + childProcess.execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }).trim(); + + beforeAll(() => { + cwd = mkdtempSync(join(tmpdir(), "linear-release-security-")); + git(["init"]); + git([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "Initial", + ]); + head = git(["rev-parse", "HEAD"]); + git(["remote", "add", "origin", "https://example.com/repo.git"]); + }); + + afterAll(() => rmSync(cwd, { recursive: true, force: true })); + + it.each(["$(touch MARKER)", "`touch MARKER`", "; touch MARKER; #", "| touch MARKER; #", "\ntouch MARKER\n#"])( + "never executes shell syntax: %s", + (syntax) => { + const checks = [ + (value: string) => commitExists(value, cwd), + (value: string) => isAncestor(value, head, cwd), + (value: string) => isAncestor(head, value, cwd), + (value: string) => getCommitParents(value, cwd), + (value: string) => getRemoteUrl(value, cwd), + ]; + for (const [index, check] of checks.entries()) { + const marker = `injected-${index}`; + check(head + syntax.replace("MARKER", marker)); + expect(existsSync(join(cwd, marker))).toBe(false); + } + }, + ); + + it("rejects poisoned release metadata in baseline selection and rollback checks", () => { + const marker = "release-injected"; + const candidates = [{ id: "test", name: "test", createdAt: "2026-01-01", commitSha: `$(touch ${marker})${head}` }]; + const deps = { + isAncestor: (a: string, b: string) => isAncestor(a, b, cwd), + verifyAncestorReachable: (a: string, b: string) => verifyAncestorReachable(a, b, cwd), + }; + expect(findBaseSha(candidates, head, deps)).toEqual({ kind: "fallback" }); + expect(findAnchorAheadOfHead(candidates, head, deps)).toBeUndefined(); + expect(existsSync(join(cwd, marker))).toBe(false); + }); + + it.each(["", "HEAD", "--help", "abcdef", "a".repeat(41), "$(touch injected)", "abcdefg"])( + "rejects invalid SHAs before starting Git or fetching: %s", + (invalid) => { + const exec = vi.spyOn(childProcess, "execSync"); + const execFile = vi.spyOn(childProcess, "execFileSync"); + try { + expect(commitExists(invalid, cwd)).toBe(false); + expect(getCommitParents(invalid, cwd)).toEqual([]); + expect(isAncestor(invalid, head, cwd)).toBe(false); + expect(isAncestor(head, invalid, cwd)).toBe(false); + expect(verifyAncestorReachable(invalid, head, cwd)).toBe(false); + expect(verifyAncestorReachable(head, invalid, cwd)).toBe(false); + // Identical malformed inputs must not pass the equality shortcut. + expect(verifyAncestorReachable(invalid, invalid, cwd)).toBe(false); + expect(() => ensureCommitAvailable(invalid, cwd)).toThrow("Invalid commit SHA format"); + expect(exec).not.toHaveBeenCalled(); + expect(execFile).not.toHaveBeenCalled(); + } finally { + vi.restoreAllMocks(); + } + }, + ); + + it("does not interpret a base ref as a fetch option", () => { + const marker = "ref-injected"; + git(["remote", "set-url", "origin", cwd]); + try { + expect(() => resolveCommitRef(`--upload-pack=touch ${marker}; git-upload-pack`, cwd)).toThrow( + "Could not resolve", + ); + expect(existsSync(join(cwd, marker))).toBe(false); + expect(resolveCommitRef("HEAD", cwd)).toBe(head); + } finally { + git(["remote", "set-url", "origin", "https://example.com/repo.git"]); + } + }); + + it("preserves valid full and abbreviated SHAs and normal remote lookup", () => { + for (const sha of [head, head.slice(0, 7)]) { + expect(commitExists(sha, cwd)).toBe(true); + expect(isAncestor(sha, head, cwd)).toBe(true); + expect(verifyAncestorReachable(sha, head, cwd)).toBe(true); + expect(() => ensureCommitAvailable(sha, cwd)).not.toThrow(); + } + expect(getRemoteUrl("origin", cwd)).toBe("https://example.com/repo.git"); + expect(commitExists("0".repeat(40), cwd)).toBe(false); + }); +}); diff --git a/src/git.ts b/src/git.ts index 086c83b..3e1c738 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,8 +1,10 @@ -import { execFileSync, execSync, spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { ConfigurationError } from "./provider"; import type { CommitContext, GitInfo } from "./types"; import { error as logError, verbose, warn } from "./log"; +const SHA_PATTERN = /^[0-9a-f]{7,40}$/i; + /** Preserves a leading "!" while cleaning the path for use as a git pathspec. */ export function normalizePathspec(pattern: string): string { const trimmed = pattern.trim(); @@ -58,7 +60,7 @@ export function buildPathspecArgs(includePaths: string[] | null): string[] { */ export function assertGitAvailable(cwd: string = process.cwd()): void { try { - execSync("git --version", { + execFileSync("git", ["--version"], { cwd, stdio: ["ignore", "ignore", "pipe"], }); @@ -69,7 +71,7 @@ export function assertGitAvailable(cwd: string = process.cwd()): void { } try { - execSync("git rev-parse --is-inside-work-tree", { + execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: ["ignore", "ignore", "pipe"], }); @@ -84,7 +86,7 @@ export function getCurrentGitInfo(cwd: string = process.cwd()): GitInfo { let message: string | null = null; try { - branch = execSync("git rev-parse --abbrev-ref HEAD", { + branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", @@ -94,7 +96,7 @@ export function getCurrentGitInfo(cwd: string = process.cwd()): GitInfo { } catch {} try { - commit = execSync("git rev-parse HEAD", { + commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", @@ -102,7 +104,7 @@ export function getCurrentGitInfo(cwd: string = process.cwd()): GitInfo { } catch {} try { - message = execSync("git log -1 --pretty=%B", { + message = execFileSync("git", ["log", "-1", "--pretty=%B"], { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", @@ -165,8 +167,11 @@ export function resolveFirstSyncBoundary(currentSha: string, cwd: string = proce * parents aren't in the local repo. Merges have 2+ entries. */ export function getCommitParents(sha: string, cwd: string = process.cwd()): string[] { + if (!SHA_PATTERN.test(sha)) { + return []; + } try { - const out = execSync(`git log -1 --format=%P ${sha}`, { + const out = execFileSync("git", ["log", "-1", "--format=%P", sha], { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", @@ -196,8 +201,11 @@ export function countCommitsInRange(fromSha: string, toSha: string, cwd: string } export function commitExists(sha: string, cwd: string = process.cwd()): boolean { + if (!SHA_PATTERN.test(sha)) { + return false; + } try { - execSync(`git cat-file -e ${sha}^{commit}`, { + execFileSync("git", ["cat-file", "-e", `${sha}^{commit}`], { cwd, stdio: ["ignore", "ignore", "ignore"], }); @@ -220,8 +228,11 @@ export function commitExists(sha: string, cwd: string = process.cwd()): boolean * `verifyAncestorReachable`, which deepens and retries on shallow cutoffs. */ export function isAncestor(sha: string, headSha: string, cwd: string = process.cwd()): boolean { + if (!SHA_PATTERN.test(sha) || !SHA_PATTERN.test(headSha)) { + return false; + } try { - execSync(`git merge-base --is-ancestor ${sha} ${headSha}`, { + execFileSync("git", ["merge-base", "--is-ancestor", sha, headSha], { cwd, stdio: ["ignore", "ignore", "ignore"], }); @@ -234,7 +245,7 @@ export function isAncestor(sha: string, headSha: string, cwd: string = process.c /** Returns true if the repository at `cwd` is a shallow clone, false otherwise. */ export function isShallowRepository(cwd: string = process.cwd()): boolean { try { - const out = execSync("git rev-parse --is-shallow-repository", { + const out = execFileSync("git", ["rev-parse", "--is-shallow-repository"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], @@ -246,16 +257,16 @@ export function isShallowRepository(cwd: string = process.cwd()): boolean { } const DEEPEN_STRATEGIES = [ - { command: "git fetch --deepen=200 origin", label: "Deepening by 200 commits" }, - { command: "git fetch --deepen=500 origin", label: "Deepening by 500 commits" }, - { command: "git fetch --unshallow origin", label: "Fetching full history" }, + { args: ["fetch", "--deepen=200", "origin"], label: "Deepening by 200 commits" }, + { args: ["fetch", "--deepen=500", "origin"], label: "Deepening by 500 commits" }, + { args: ["fetch", "--unshallow", "origin"], label: "Fetching full history" }, ]; function deepenUntil(cwd: string, check: () => boolean): boolean { - for (const { command, label } of DEEPEN_STRATEGIES) { + for (const { args, label } of DEEPEN_STRATEGIES) { verbose(label); try { - execSync(command, { cwd, stdio: ["ignore", "ignore", "pipe"], timeout: 30_000 }); + execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"], timeout: 30_000 }); } catch (e) { const reason = e instanceof Error ? e.message : String(e); verbose(`Strategy "${label}" failed: ${reason}`); @@ -280,6 +291,9 @@ function deepenUntil(cwd: string, check: () => boolean): boolean { * by deepening and retrying. */ export function verifyAncestorReachable(sha: string, headSha: string, cwd: string = process.cwd()): boolean { + if (!SHA_PATTERN.test(sha) || !SHA_PATTERN.test(headSha)) { + return false; + } if (sha === headSha) { return true; } @@ -303,8 +317,6 @@ export function verifyAncestorReachable(sha: string, headSha: string, cwd: strin return false; } -const SHA_PATTERN = /^[0-9a-f]{7,40}$/i; - /** * Resolves a git ref, tag, or SHA to a full commit SHA. * @@ -315,7 +327,7 @@ const SHA_PATTERN = /^[0-9a-f]{7,40}$/i; */ export function resolveCommitRef(ref: string, cwd: string = process.cwd()): string { const resolve = (target: string = ref) => - execFileSync("git", ["rev-parse", "--verify", `${target}^{commit}`], { + execFileSync("git", ["rev-parse", "--verify", "--end-of-options", `${target}^{commit}`], { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", @@ -330,7 +342,7 @@ export function resolveCommitRef(ref: string, cwd: string = process.cwd()): stri } try { verbose(`Ref "${ref}" not in local history; fetching from origin`); - execFileSync("git", ["fetch", "origin", ref], { + execFileSync("git", ["fetch", "--", "origin", ref], { cwd, stdio: ["ignore", "ignore", "ignore"], timeout: 30_000, @@ -421,6 +433,9 @@ export async function getCommitContext(sha: string, cwd: string = process.cwd()) * Throws if the commit cannot be made available (e.g., not on the current branch). */ export function ensureCommitAvailable(sha: string, cwd: string = process.cwd()): void { + if (!SHA_PATTERN.test(sha)) { + throw new Error(`Invalid commit SHA format "${sha}"`); + } if (commitExists(sha, cwd)) { return; } @@ -604,7 +619,7 @@ export async function getCommitContextsBetweenShas( export function getRemoteUrl(remote: string = "origin", cwd: string = process.cwd()): string | null { try { - return execSync(`git remote get-url ${remote}`, { + return execFileSync("git", ["remote", "get-url", "--", remote], { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8",