# git-fix-finder

Audit local git history to identify commits that likely fixed vulnerabilities, infer the underlying bug from the diff, turn those patches into a reusable bug-fix reference set, and save the findings as a Markdown report. Use when asked to review all commits in a repository, reconstruct historical security fixes, explain which vulnerabilities were patched, separate true vuln fixes from ordinary bugfixes, or build a security-fix changelog from git evidence.

- **Kind:** skill
- **Source:** https://github.com/RASHMOR1/git-fix-finder
- **Page:** https://forefy.com/skills/471534ea-944f-400f-9d23-8f64eca64180
- **API (JSON + files):** https://forefy.com/api/asr/471534ea-944f-400f-9d23-8f64eca64180

---

## SKILL.md

---
name: git-fix-finder
description: Audit local git history to identify commits that likely fixed vulnerabilities, infer the underlying bug from the diff, turn those patches into a reusable bug-fix reference set, and save the findings as a Markdown report. Use when asked to review all commits in a repository, reconstruct historical security fixes, explain which vulnerabilities were patched, separate true vuln fixes from ordinary bugfixes, or build a security-fix changelog from git evidence.
---

# Git Fix Finder

## Overview

When this skill is invoked, always start by printing this banner before doing anything else:

```
+==================================================================================+
|                                                                                  |
|     ____ ___ _____     _____ _____  __   _____ ___ _   _ ____  _____ ____        |
|    / ___|_ _|_   _|   |  ___|_ _\ \/ /  |  ___|_ _| \ | |  _ \| ____|  _ \       |
|   | |  _ | |  | |     | |_   | | \  /   | |_   | ||  \| | | | |  _| | |_) |      |
|   | |_| || |  | |     |  _|  | | /  \   |  _|  | || |\  | |_| | |___|  _ <       |
|    \____|___| |_|     |_|   |___/_/\_\  |_|   |___|_| \_|____/|_____|_| \_\      |
|                                                                                  |
|   Mine git history for real vulnerability fixes.                                 |
|   Show what existed, why it mattered, and exactly how the patch closed it.       |
|                                                                                  |
+==================================================================================+
```

Use this skill to work backward from git history and explain what each fix commit actually changed. Treat commit subjects, PR titles, and issue labels as hints only; the real answer must come from the diff, touched call paths, and any regression tests added beside the fix.

## Workflow

1. Confirm the repository and scope.
2. Rank likely fix commits with `scripts/rank_fix_commits.py`.
3. Inspect each candidate with `git show` and the touched source files.
4. Classify the candidate as `CONFIRMED FIX`, `LIKELY FIX`, or `NOT A VULN FIX`.
5. Write the final report using the schema in `references/analysis-guide.md`.
6. Save the final report as a Markdown file in the inspected repository root.

## Step 1: Confirm Scope

- Verify the repo is local and has git metadata.
- Default to the full history. Narrow to a revset only if the user asks or the repo is too large for a first pass.
- Prefer evidence from the checked-out repo over GitHub issue text. This skill is meant to work even without network access.

Useful commands:

```bash
git rev-list --count --all
git log --oneline --decorate --no-merges -n 30
```

## Step 2: Rank Candidate Fix Commits

Run the bundled script first. It scores commits by message terms, touched file paths, diff patterns, and whether tests changed alongside critical code.

```bash
python3 .claude/skills/git-fix-finder/scripts/rank_fix_commits.py --repo . --limit 40
```

Useful variants:

- Restrict to a range: `--rev-range origin/main..HEAD`
- Keep merges: `--include-merges`
- Export machine-readable output: `--json`
- Remove the candidate cap: `--limit 0`
- Raise the noise floor: `--min-score 6`

Do not stop at the ranked list. The script is a triage tool, not the final answer.

## Step 3: Prove What the Diff Fixed

For each candidate:

- Inspect the patch:

  ```bash
  git show --stat --unified=0 <sha>
  ```

- Compare pre-fix and post-fix code when the invariant is subtle:

  ```bash
  git show <sha>^:path/to/file
  git show <sha>:path/to/file
  ```

- Read nearby tests added or updated in the same commit.
- Map the externally reachable path that exercised the old behavior.
- Identify the broken invariant before the patch and the new guard or accounting rule after the patch.

Questions to answer for every commit:

- What exact pre-fix behavior was possible?
- Which contract, function, state variable, or trust boundary was involved?
- Did the patch close an exploit path, tighten a standard-compliance edge case, or just clean up code?
- Is the impact security-relevant, correctness-only, or unclear without more assumptions?

## Step 4: Classify the Candidate

Use these labels:

- `CONFIRMED FIX`: The diff clearly closes a reachable bad state, exploit path, or protocol-invariant break.
- `LIKELY FIX`: The patch strongly suggests a vulnerability, but exploitability or severity still depends on assumptions.
- `NOT A VULN FIX`: The commit is operational, cosmetic, event-only, test-only, refactor-only, or a non-security bugfix.

State explicitly when a conclusion is an inference. Do not overstate severity from the word `fix` alone.

## Step 5: Write the Final Report

Use the report schema in `references/analysis-guide.md`.

Save the completed report to `<repo_root>/git-fix-finder-report.md` unless the user requested a different filename. If you are inspecting a different repo through a symlink or explicit path, save the report in that inspected repo, not in the skill repo.

Required output properties:

- Write the same findings to a Markdown artifact on disk.
- Include commit hash, date, subject, and touched files.
- Summarize the vulnerability in plain language.
- Explain pre-fix behavior and the post-fix change.
- Include concise `before` and `after` code snippets with file references for each non-rejected finding.
- Cite the exact source/test evidence used.
- Separate confirmed findings from rejected false positives.
- Call out residual risk when the fix commit still looks incomplete.

In the final assistant response:

- Tell the user where the Markdown file was written.
- Keep the chat response short and treat the file as the primary deliverable.

## Evidence Standard

- Never rely on commit message wording by itself.
- Prefer direct code evidence over GitHub metadata.
- Use short parent-vs-fixed code excerpts to show the invariant that changed.
- Show the call chain or user-reachable path whenever possible.
- Distinguish security impact from observability or tooling fixes.
- If the patch changes tests, explain whether the new test proves exploitability or only documents expected behavior.

## Resources

- `scripts/rank_fix_commits.py`: Rank likely vulnerability-fix commits before manual review.
- `references/analysis-guide.md`: Output schema, triage rubric, and worked examples from this repo.

## references

```

```

## references/analysis-guide.md

# Commit Fix Analysis Guide

## Output Schema

Save the completed report to `<repo_root>/git-fix-finder-report.md` unless the user asked for a different filename.

Start the file with:

```markdown
# Git Fix Finder Report

- Repository: `<repo path>`
- Scope: `<revset or full history>`
- Generated: `<YYYY-MM-DD>`
```

Use one summary table and then short writeups for the non-rejected candidates.

Table columns:

`sha | date | classification | vulnerability | contracts/functions | confidence`

Per-commit writeup:

```markdown
### <short sha> <subject>

- Classification: `CONFIRMED FIX | LIKELY FIX | NOT A VULN FIX`
- Vulnerability: <plain-language bug description>
- Pre-fix behavior: <what the old code allowed>
- Fix mechanism: <what the patch changed>
- Reachability: <entrypoint or call chain, or explain why reachability is inferred>
- Code snippets:
  - Before: short excerpt from the parent revision that shows the vulnerable logic, with a file reference
  - After: short excerpt from the fixed revision that shows the guard/accounting change, with a file reference
- Evidence:
  - Commit: `<sha>`
  - Files: `path:line` references from the fixed tree or the parent tree
  - Tests: note whether the commit adds a regression test
- Confidence: `high | medium | low`
- Open questions: <only if needed>
```

Snippet rules:

- Keep each excerpt short and focused on the changed invariant, not the whole function.
- Prefer one `before` and one `after` snippet per vulnerability statement.
- Label snippets with the tree they came from, for example `parent` and `fixed`.
- Always pair each snippet with a `path:line` reference so the reader can inspect the full function.

Prefer one vulnerability statement per commit. If a single commit fixes multiple unrelated issues, split them.

End the report with a rejected-candidates section when applicable:

```markdown
## Rejected Candidates

- `<sha>` `<subject>`: short reason it is not a vulnerability fix
```

## Triage Rubric

Positive signals:

- Commit message mentions `fix`, `patch`, `security`, `exploit`, `permit`, `oracle`, `overflow`, `reentrancy`, `malleability`, `liquidation`, `issuance`, or similar terms.
- Diff adds or tightens guards such as `require`, access control, signature validation, safe transfer wrappers, arithmetic bounds, or accounting updates.
- Critical state machines or pool/accounting contracts change.
- A regression test lands in the same commit and documents a previously failing sequence.

False-positive signals:

- Only docs, scripts, release metadata, or CI files change.
- The diff is only event emission cleanup, logging, comments, or dead variable removal.
- The change is a version bump or broad refactor without a narrowed security invariant.

Review habits:

- Compare the parent revision to the fixed revision before naming the bug.
- If the patch is subtle, inspect the touched invariant and downstream assumptions, not only the inserted line.
- A fix commit can still leave residual risk or introduce a new bug. Do not assume "post-fix" means "safe."

## Worked Examples From This Repo

### Likely fix: `2a1fe27` `fix: fix as per SECFIN2-1 [#12]`

Why it ranks high:

- Touches `contracts/DebtToken.sol` permit-style signature recovery.
- Replaces raw `ecrecover` with OpenZeppelin-style `ECDSA.recover`.
- Adds lower-half-`s` and valid-`v` checks in the bundled library.

Likely vulnerability statement:

- Signature malleability or invalid-signature acceptance in the debt token permit flow.

Why the label is `LIKELY FIX` by default:

- The diff clearly hardens signature validation.
- Exploitability still depends on the surrounding nonce/deadline logic, so inspect the full permit path before upgrading it to `CONFIRMED FIX`.

### False positive: `c819e45` `fix: fix as per SECFIN2-2 [#12]`

Why it should usually be rejected:

- The diff only adds missing `emit` keywords and removes an unused local variable.
- Missing events can matter for monitoring and integrations, but this patch does not obviously close a protocol exploit path.

Default classification:

- `NOT A VULN FIX`, unless another contract or off-chain safety system relied on those events as a hard security control.

### Important caution: `ac88166` `fix: fix protocol token allocation issue [#35]`

Why it is still worth deep review:

- It changes reward issuance accounting in `contracts/ProtocolToken/CommunityIssuance.sol` and rewires the funding flow.
- It adds tests, so it looks like a canonical "bug fix" commit.

Why this is a useful example for the skill:

- A commit can fix one issue while leaving another accounting bug or business-logic edge case behind.
- When a patch changes global accounting rules, always restate the old invariant and the new invariant in plain language and test both.

## scripts

```

```

## scripts/rank_fix_commits.py

```python
#!/usr/bin/env python3
"""Rank commits that look like security or vulnerability fixes."""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from dataclasses import asdict, dataclass
from pathlib import Path


MESSAGE_RULES = (
    (re.compile(r"\b(security|vulnerability|vuln|exploit|attack)\b", re.IGNORECASE), 8, "message uses explicit security language"),
    (re.compile(r"\b(reentran|overflow|underflow|malleab|oracle|liquidat|redeem|permit|signature|ecdsa)\w*", re.IGNORECASE), 5, "message names a security-sensitive concept"),
    (re.compile(r"\b(fix|patch|mitigat|hard(en|ing)|protect)\w*", re.IGNORECASE), 2, "message says the commit is a fix"),
    (re.compile(r"\b(issue|bug|regression)\b", re.IGNORECASE), 1, "message references a bug or issue"),
)

NEGATIVE_MESSAGE_RULES = (
    (re.compile(r"^\s*docs?:", re.IGNORECASE), -4, "message looks like a docs-only change"),
    (re.compile(r"^\s*chore:", re.IGNORECASE), -3, "message looks like maintenance work"),
    (re.compile(r"^\s*ci:", re.IGNORECASE), -3, "message looks like CI-only work"),
    (re.compile(r"^\s*test:", re.IGNORECASE), -3, "message looks like test-only maintenance"),
    (re.compile(r"^\s*feat:", re.IGNORECASE), -2, "message looks like a feature addition"),
    (re.compile(r"\b(readme|typo|format|lint|rename)\b", re.IGNORECASE), -2, "message points to low-risk cleanup"),
    (re.compile(r"\bchange\s+[A-Za-z0-9_]+\s+to\s+[A-Za-z0-9_]+\b", re.IGNORECASE), -8, "message looks like a broad rename or migration"),
)

PATH_RULES = (
    (re.compile(r"(^|/)(contracts|src|protocol|programs|pallets)/", re.IGNORECASE), 2, "touches core source files"),
    (re.compile(r"(^|/)(proxy|upgrade|govern|owner|access|auth|oracle|price|issuance|staking|vault|pool|bridge|token)\b", re.IGNORECASE), 3, "touches a security-sensitive surface"),
    (re.compile(r"(^|/)(test|tests|spec|fuzz|audit)s?/", re.IGNORECASE), 1, "updates tests or audit material"),
)

CODE_RULES = (
    (re.compile(r"\b(require|revert|assert)\s*\(", re.IGNORECASE), 2, "diff changes runtime checks"),
    (re.compile(r"\b(onlyOwner|onlyRole|nonReentrant|safeTransfer|safeTransferFrom)\b", re.IGNORECASE), 3, "diff changes security primitives"),
    (re.compile(r"\b(ecrecover|recover|permit|signature|approve|allowance)\b", re.IGNORECASE), 3, "diff changes signature or approval handling"),
    (re.compile(r"\b(totalSupply|balanceOf|debt|coll|reward|issuance|snapshot|price|oracle)\b", re.IGNORECASE), 2, "diff changes accounting or oracle terms"),
)

SOURCE_SUFFIXES = (".sol", ".vy", ".rs", ".go", ".cairo", ".move")
DOC_SUFFIXES = (".md", ".rst", ".txt")
TEST_HINTS = ("test/", "tests/", "spec/", "fuzz", ".t.sol", ".spec.", "__tests__")


@dataclass
class RankedCommit:
    sha: str
    short_sha: str
    date: str
    author: str
    subject: str
    score: int
    band: str
    reasons: list[str]
    files: list[str]
    source_files: list[str]
    test_files: list[str]
    added_lines: int
    deleted_lines: int


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--repo", default=".", help="Path to the git repository")
    parser.add_argument(
        "--limit",
        type=int,
        default=25,
        help="Maximum number of ranked commits to print; use 0 for no limit",
    )
    parser.add_argument("--min-score", type=int, default=4, help="Minimum score required to print a commit")
    parser.add_argument(
        "--rev-range",
        default="--all",
        help="Revision set to scan, for example --all or origin/main..HEAD",
    )
    parser.add_argument("--include-merges", action="store_true", help="Include merge commits")
    parser.add_argument("--json", action="store_true", help="Emit JSON instead of text")
    return parser.parse_args()


def run_git(repo: Path, *args: str) -> str:
    cmd = ["git", "-C", str(repo), *args]
    completed = subprocess.run(cmd, check=True, capture_output=True, text=True)
    return completed.stdout


def list_commits(repo: Path, rev_range: str, include_merges: bool) -> list[str]:
    args = ["rev-list"]
    if not include_merges:
        args.append("--no-merges")
    args.append(rev_range)
    output = run_git(repo, *args)
    return [line for line in output.splitlines() if line.strip()]


def unique_reasons(reasons: list[str]) -> list[str]:
    ordered: list[str] = []
    seen = set()
    for reason in reasons:
        if reason not in seen:
            ordered.append(reason)
            seen.add(reason)
    return ordered


def is_source_file(path: str) -> bool:
    if path.startswith("contracts/TestContracts/"):
        return False
    return path.endswith(SOURCE_SUFFIXES) or path.startswith(("contracts/", "src/"))


def is_doc_file(path: str) -> bool:
    name = path.rsplit("/", 1)[-1]
    return path.endswith(DOC_SUFFIXES) or name.upper().startswith("README")


def is_test_file(path: str) -> bool:
    lowered = path.lower()
    return lowered.startswith("contracts/testcontracts/") or any(hint in lowered for hint in TEST_HINTS)


def score_rules(text: str, rules: tuple[tuple[re.Pattern[str], int, str], ...]) -> tuple[int, list[str]]:
    score = 0
    reasons: list[str] = []
    for pattern, weight, reason in rules:
        if pattern.search(text):
            score += weight
            reasons.append(reason)
    return score, reasons


def parse_numstat(repo: Path, sha: str) -> tuple[int, int]:
    output = run_git(repo, "diff-tree", "--root", "--no-commit-id", "--numstat", "-r", sha)
    added = 0
    deleted = 0
    for line in output.splitlines():
        parts = line.split("\t")
        if len(parts) < 3:
            continue
        try:
            added += int(parts[0])
            deleted += int(parts[1])
        except ValueError:
            continue
    return added, deleted


def band_for_score(score: int) -> str:
    if score >= 12:
        return "high"
    if score >= 7:
        return "medium"
    if score >= 4:
        return "low"
    return "noise"


def load_metadata(repo: Path, sha: str) -> tuple[str, str, str, str, str]:
    raw = run_git(repo, "show", "--quiet", "--format=%H%x00%h%x00%ad%x00%an%x00%s", "--date=short", sha)
    parts = raw.rstrip("\n").split("\x00")
    if len(parts) != 5:
        raise ValueError(f"unexpected metadata format for commit {sha}")
    return tuple(parts)  # type: ignore[return-value]


def load_body(repo: Path, sha: str) -> str:
    return run_git(repo, "show", "--quiet", "--format=%b", sha).strip()


def load_files(repo: Path, sha: str) -> list[str]:
    output = run_git(repo, "diff-tree", "--root", "--no-commit-id", "--name-only", "-r", sha)
    return [line for line in output.splitlines() if line.strip()]


def load_diff(repo: Path, sha: str) -> str:
    return run_git(repo, "show", "--format=", "--unified=0", "--no-ext-diff", sha)


def analyze_commit(repo: Path, sha: str) -> RankedCommit:
    full_sha, short_sha, date, author, subject = load_metadata(repo, sha)
    body = load_body(repo, sha)
    files = load_files(repo, sha)
    added_lines, deleted_lines = parse_numstat(repo, sha)
    diff_text = load_diff(repo, sha)

    score = 0
    reasons: list[str] = []

    message_score, message_reasons = score_rules(f"{subject}\n{body}", MESSAGE_RULES)
    score += message_score
    reasons.extend(message_reasons)

    negative_score, negative_reasons = score_rules(subject, NEGATIVE_MESSAGE_RULES)
    score += negative_score
    reasons.extend(negative_reasons)

    source_files = [path for path in files if is_source_file(path)]
    test_files = [path for path in files if is_test_file(path)]
    doc_files = [path for path in files if is_doc_file(path)]

    if source_files:
        score += min(6, len(source_files) * 2)
        reasons.append("touches source code rather than only metadata")

    if source_files and len(files) <= 12:
        score += 2
        reasons.append("diff is focused enough for manual vulnerability review")

    if test_files and source_files:
        score += 2
        reasons.append("adds or updates tests beside source changes")

    if files and len(doc_files) == len(files):
        score -= 6
        reasons.append("touches only documentation files")

    if test_files and len(test_files) == len(files):
        score -= 2
        reasons.append("touches only tests")

    matched_path_reasons = set()
    for path in files:
        for pattern, weight, reason in PATH_RULES:
            if pattern.search(path) and reason not in matched_path_reasons:
                score += weight
                reasons.append(reason)
                matched_path_reasons.add(reason)

    diff_score, diff_reasons = score_rules(diff_text, CODE_RULES)
    score += diff_score
    reasons.extend(diff_reasons)

    if len(files) > 25:
        score -= 4
        reasons.append("broad multi-file diff often indicates migration or refactor noise")

    if len(files) > 60:
        score -= 4
        reasons.append("very large file count makes the commit less likely to be a single focused fix")

    if added_lines + deleted_lines > 2000:
        score -= 4
        reasons.append("very large churn often reflects sweeping changes rather than a targeted fix")

    if added_lines + deleted_lines <= 4:
        score -= 1
        reasons.append("very small diff")

    return RankedCommit(
        sha=full_sha,
        short_sha=short_sha,
        date=date,
        author=author,
        subject=subject,
        score=score,
        band=band_for_score(score),
        reasons=unique_reasons(reasons),
        files=files,
        source_files=source_files,
        test_files=test_files,
        added_lines=added_lines,
        deleted_lines=deleted_lines,
    )


def emit_text(commits: list[RankedCommit], scanned_count: int, repo: Path) -> None:
    print(f"Scanned {scanned_count} commits in {repo}")
    print(f"Ranked {len(commits)} candidate commits")
    print()
    for commit in commits:
        print(f"{commit.short_sha}  score={commit.score}  band={commit.band}  {commit.date}  {commit.subject}")
        print(f"  Author: {commit.author}")
        print(f"  Reasons: {', '.join(commit.reasons)}")
        print(f"  Files: {', '.join(commit.files[:8])}")
        if len(commit.files) > 8:
            print(f"  Files+: {len(commit.files) - 8} more")
        print(f"  Churn: +{commit.added_lines} / -{commit.deleted_lines}")
        print(f"  Review: git show --stat --unified=0 {commit.sha}")
        print()


def main() -> int:
    args = parse_args()
    repo = Path(args.repo).resolve()

    try:
        run_git(repo, "rev-parse", "--show-toplevel")
    except subprocess.CalledProcessError:
        print(f"{repo} is not a git repository", file=sys.stderr)
        return 2

    commits = list_commits(repo, args.rev_range, args.include_merges)
    ranked = [analyze_commit(repo, sha) for sha in commits]
    ranked.sort(key=lambda item: (item.score, item.date, item.sha), reverse=True)
    ranked = [item for item in ranked if item.score >= args.min_score]
    if args.limit > 0:
        ranked = ranked[: args.limit]

    if args.json:
        payload = {
            "repo": str(repo),
            "scanned_commits": len(commits),
            "candidates": [asdict(item) for item in ranked],
        }
        print(json.dumps(payload, indent=2))
    else:
        emit_text(ranked, len(commits), repo)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

