leakscan 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leakscan-0.2.0.dist-info/METADATA +205 -0
- leakscan-0.2.0.dist-info/RECORD +16 -0
- leakscan-0.2.0.dist-info/WHEEL +5 -0
- leakscan-0.2.0.dist-info/entry_points.txt +2 -0
- leakscan-0.2.0.dist-info/top_level.txt +1 -0
- scanner/__init__.py +0 -0
- scanner/baseline.py +23 -0
- scanner/cli.py +346 -0
- scanner/engine.py +239 -0
- scanner/entropy.py +58 -0
- scanner/github/__init__.py +0 -0
- scanner/github/fetcher.py +162 -0
- scanner/ignorefile.py +68 -0
- scanner/patterns.py +121 -0
- scanner/reporter.py +287 -0
- scanner/verifier.py +140 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: leakscan
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: CLI tool to detect leaked secrets and API keys in codebases and public GitHub repos — including git history
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: click>=8.1
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
Requires-Dist: requests>=2.31
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# secret-scanner
|
|
16
|
+
|
|
17
|
+
A CLI tool that scans local codebases and public GitHub repos for leaked API keys and secrets — including secrets deleted from code but still alive in git history.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
**Detection**
|
|
24
|
+
- 55+ regex patterns: AWS, GitHub, GitLab, Stripe, OpenAI, Anthropic, Slack, Twilio, Discord, Telegram, npm, PyPI, Shopify, DigitalOcean, Dropbox, Notion, Linear, Terraform, Vault, New Relic, Mapbox, Square, Mailchimp, and more
|
|
25
|
+
- Shannon entropy detection for unquoted values (`.env`, YAML, INI files)
|
|
26
|
+
- Inline suppression: `# nosec`, `# gitleaks:allow`, `# secretscanner:allow`
|
|
27
|
+
- AWS Access Key ID pattern anchored to real prefixes (`AKIA`, `AGPA`, `AROA`, etc.) — no false positives on random uppercase strings
|
|
28
|
+
|
|
29
|
+
**Verification**
|
|
30
|
+
- `--verify` makes live API calls to check if found secrets are still active
|
|
31
|
+
- Supports: GitHub, GitLab, Stripe, OpenAI, Anthropic, HuggingFace, SendGrid, Slack, npm, Replicate
|
|
32
|
+
|
|
33
|
+
**Scanning scope**
|
|
34
|
+
- Local file trees (parallel, 8 threads)
|
|
35
|
+
- Git history: commits, any branch, with `--since DATE` for date-bounded scans
|
|
36
|
+
- GitHub profile: all public repos for a user or org
|
|
37
|
+
- GitHub repo by URL: `secrets scan https://github.com/owner/repo`
|
|
38
|
+
- GitHub Gists: `--include-gists`
|
|
39
|
+
|
|
40
|
+
**CI/CD integration**
|
|
41
|
+
- Exit code `1` on any CRITICAL or HIGH finding
|
|
42
|
+
- SARIF output for GitHub Security tab / GitLab SAST
|
|
43
|
+
- Baseline mode: save known findings, only alert on new secrets
|
|
44
|
+
- Pre-commit hook with automatic baseline support
|
|
45
|
+
- `.secretignore` with full `**` glob support
|
|
46
|
+
|
|
47
|
+
**Output formats**
|
|
48
|
+
- Terminal (rich table with severity colors)
|
|
49
|
+
- JSON (with fingerprints and verification status)
|
|
50
|
+
- CSV
|
|
51
|
+
- SARIF 2.1.0
|
|
52
|
+
- Markdown disclosure report
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install leakscan
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or from source:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git clone https://github.com/Vasishta03/secret-scanner
|
|
64
|
+
cd secret-scanner
|
|
65
|
+
pip install -e .
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
**Basic local scan**
|
|
71
|
+
```bash
|
|
72
|
+
secrets scan ./myproject
|
|
73
|
+
secrets scan . --severity HIGH --no-entropy
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Scan git history** (catches deleted secrets)
|
|
77
|
+
```bash
|
|
78
|
+
secrets scan . --history
|
|
79
|
+
secrets scan . --history --depth 500 --since 2023-01-01
|
|
80
|
+
secrets scan . --history --branch main
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Verify secrets are live**
|
|
84
|
+
```bash
|
|
85
|
+
secrets scan . --verify
|
|
86
|
+
secrets scan . --verify --severity HIGH
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Scan a public GitHub repo by URL**
|
|
90
|
+
```bash
|
|
91
|
+
secrets scan https://github.com/owner/repo
|
|
92
|
+
secrets scan https://github.com/owner/repo --history --verify
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Scan a GitHub user's repos and gists**
|
|
96
|
+
```bash
|
|
97
|
+
secrets scan --github username
|
|
98
|
+
secrets scan --github username --include-gists
|
|
99
|
+
secrets scan --github username --history --token $GITHUB_TOKEN
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Baseline mode** (CI-friendly: only alert on new findings)
|
|
103
|
+
```bash
|
|
104
|
+
secrets scan . --save-baseline .secrets.baseline
|
|
105
|
+
secrets scan . --baseline .secrets.baseline
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Output formats**
|
|
109
|
+
```bash
|
|
110
|
+
secrets scan . --format json --output results.json
|
|
111
|
+
secrets scan . --format csv --output findings.csv
|
|
112
|
+
secrets scan . --format sarif --output results.sarif
|
|
113
|
+
secrets scan --github username --format disclosure --output report.md
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Redact secrets in output** (safe for shared logs)
|
|
117
|
+
```bash
|
|
118
|
+
secrets scan . --redact
|
|
119
|
+
secrets scan . --format json --redact --output safe-results.json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Install as pre-commit hook
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
cd your-git-repo
|
|
126
|
+
secrets install-hook
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The hook uses `.secrets.baseline` automatically if present, suppressing already-known findings.
|
|
130
|
+
|
|
131
|
+
To suppress a specific line: add `# nosec` or `# gitleaks:allow` to the line.
|
|
132
|
+
|
|
133
|
+
## .secretignore
|
|
134
|
+
|
|
135
|
+
Create `.secretignore` in your project root to exclude paths:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
tests/fixtures/**
|
|
139
|
+
vendor/**
|
|
140
|
+
*.example
|
|
141
|
+
docs/
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Supports full `**` glob syntax (like `.gitignore`).
|
|
145
|
+
|
|
146
|
+
## GitHub Actions
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
- name: Scan for secrets
|
|
150
|
+
run: secrets scan . --severity HIGH --no-entropy --format sarif --output results.sarif
|
|
151
|
+
|
|
152
|
+
- name: Upload SARIF
|
|
153
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
154
|
+
with:
|
|
155
|
+
sarif_file: results.sarif
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Severity levels
|
|
159
|
+
|
|
160
|
+
| Level | Examples |
|
|
161
|
+
|----------|----------|
|
|
162
|
+
| CRITICAL | Private keys (RSA/EC/PGP/OpenSSH/PKCS#8), AWS credentials, Azure storage keys |
|
|
163
|
+
| HIGH | GitHub/GitLab tokens, Stripe live keys, OpenAI/Anthropic keys, Slack tokens, npm/PyPI tokens, Telegram/Discord bots |
|
|
164
|
+
| MEDIUM | Generic API keys, hardcoded passwords, JWT tokens, database URLs, Stripe test keys |
|
|
165
|
+
| LOW | High-entropy strings (possible unknown secrets) |
|
|
166
|
+
|
|
167
|
+
## Why history scanning matters
|
|
168
|
+
|
|
169
|
+
Deleting a secret from your latest commit does **not** remove it from git history. Anyone who clones your repo can run `git log -p` and recover it. Most scanners miss this completely.
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# Find secrets that were committed at any point in the last year
|
|
173
|
+
secrets scan . --history --depth 1000 --since 2024-01-01
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Architecture
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
scanner/
|
|
180
|
+
cli.py entry point (click)
|
|
181
|
+
engine.py file walker, parallel scanner, git history, shared kernel
|
|
182
|
+
patterns.py 55+ regex patterns
|
|
183
|
+
entropy.py Shannon entropy scorer (quoted + unquoted values)
|
|
184
|
+
verifier.py live API verification (10 services)
|
|
185
|
+
baseline.py save/load/compare baseline fingerprints
|
|
186
|
+
reporter.py terminal/JSON/CSV/SARIF/disclosure output
|
|
187
|
+
ignorefile.py .secretignore parser with ** glob support
|
|
188
|
+
github/
|
|
189
|
+
fetcher.py GitHub API client: repos, gists, commit history
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Contributing
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
git clone https://github.com/Vasishta03/secret-scanner
|
|
196
|
+
cd secret-scanner
|
|
197
|
+
pip install -e ".[dev]"
|
|
198
|
+
pytest tests/
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
To add a pattern: edit `scanner/patterns.py` and add a test in `tests/test_scanner.py`.
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
scanner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
scanner/baseline.py,sha256=Ypwg6zfxfhqIzJPZpBJXUbsLHj26zhdqVmK2cySWugI,633
|
|
3
|
+
scanner/cli.py,sha256=i0xKd8EhLDMTFUHXnzcqY4LJPY_dqw8qSONkOow7bGU,13695
|
|
4
|
+
scanner/engine.py,sha256=TpPMFDMYFEuqBMUmTF9GKfMfdrrm1Qe_mSM7T4W-hME,7698
|
|
5
|
+
scanner/entropy.py,sha256=1S3qedcKaUe98Bs9pfh7D7QYogE03yLuBUUgdcnqQ1o,1677
|
|
6
|
+
scanner/ignorefile.py,sha256=Yx-ck0-sDRM6b9iz4BXYSm1gTmrnXE3TsP6bQLx4CiI,1835
|
|
7
|
+
scanner/patterns.py,sha256=_ZKAE39vaKgmIWqpz3tqdG0TwMY5ChrF_DAmK5eHnio,8238
|
|
8
|
+
scanner/reporter.py,sha256=Ib5cS2vc1Ixmgiz9eZR-n5q634AmlojCj_qLcL7Knb4,9723
|
|
9
|
+
scanner/verifier.py,sha256=_oYnWu7eJg9UhZgbs8O0b0r6Wchk16g4keYHzaYSel0,4970
|
|
10
|
+
scanner/github/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
scanner/github/fetcher.py,sha256=AAmPx_db-KW723FUjQr5onsQjfs2-ZuSEBs886lhv_c,5533
|
|
12
|
+
leakscan-0.2.0.dist-info/METADATA,sha256=SmLDsat4rDS2VgWWWomYsNkPgHyBrMu3khReBcmkFfo,6002
|
|
13
|
+
leakscan-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
leakscan-0.2.0.dist-info/entry_points.txt,sha256=SDkcVK4MShAmFzM1h1faF3dI9_i6QHn7577M63XMMQU,44
|
|
15
|
+
leakscan-0.2.0.dist-info/top_level.txt,sha256=uVdh7ZIJC9rjZQex7pba18nBuRRxFhjkFu4vHPiWTcw,8
|
|
16
|
+
leakscan-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scanner
|
scanner/__init__.py
ADDED
|
File without changes
|
scanner/baseline.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from scanner.engine import Finding, ScanResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def save(result: ScanResult, path: Path) -> None:
|
|
10
|
+
data = {
|
|
11
|
+
"version": 1,
|
|
12
|
+
"fingerprints": [f.fingerprint() for f in result.findings],
|
|
13
|
+
}
|
|
14
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load(path: Path) -> set[str]:
|
|
18
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
19
|
+
return set(data.get("fingerprints", []))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def filter_new(findings: list[Finding], baseline: set[str]) -> list[Finding]:
|
|
23
|
+
return [f for f in findings if f.fingerprint() not in baseline]
|
scanner/cli.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
13
|
+
|
|
14
|
+
from scanner.engine import (
|
|
15
|
+
ScanResult,
|
|
16
|
+
scan_content_string,
|
|
17
|
+
scan_git_history,
|
|
18
|
+
scan_patch_string,
|
|
19
|
+
scan_path,
|
|
20
|
+
)
|
|
21
|
+
from scanner.github.fetcher import GitHubFetcher, RateLimitError
|
|
22
|
+
from scanner import reporter
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
err_console = Console(stderr=True)
|
|
26
|
+
|
|
27
|
+
_GITHUB_URL_RE = re.compile(
|
|
28
|
+
r"^(?:https?://)?github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_github_url(value: str) -> tuple[str, str] | None:
|
|
33
|
+
m = _GITHUB_URL_RE.match(value.strip())
|
|
34
|
+
return (m.group(1), m.group(2)) if m else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group()
|
|
38
|
+
def cli():
|
|
39
|
+
"""Secret Scanner — detect leaked API keys and tokens in code."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@cli.command()
|
|
44
|
+
@click.argument("path", default=".", required=False)
|
|
45
|
+
@click.option("--github", "-g", default=None, metavar="USERNAME",
|
|
46
|
+
help="Scan all public repos for a GitHub user/org.")
|
|
47
|
+
@click.option("--repo", "-r", default=None, metavar="REPO",
|
|
48
|
+
help="Limit GitHub scan to a specific repo name.")
|
|
49
|
+
@click.option("--token", "-t", default=None, envvar="GITHUB_TOKEN",
|
|
50
|
+
metavar="TOKEN", help="GitHub personal access token (or set GITHUB_TOKEN).")
|
|
51
|
+
@click.option("--format", "-f", "output_format",
|
|
52
|
+
type=click.Choice(["terminal", "json", "csv", "sarif", "disclosure"]),
|
|
53
|
+
default="terminal", show_default=True)
|
|
54
|
+
@click.option("--output", "-o", default=None, metavar="FILE")
|
|
55
|
+
@click.option("--entropy-threshold", default=4.5, show_default=True)
|
|
56
|
+
@click.option("--no-entropy", is_flag=True, default=False)
|
|
57
|
+
@click.option("--severity", "-s",
|
|
58
|
+
type=click.Choice(["CRITICAL", "HIGH", "MEDIUM", "LOW"]),
|
|
59
|
+
default=None)
|
|
60
|
+
@click.option("--history", is_flag=True, default=False,
|
|
61
|
+
help="Also scan git history for secrets in past commits.")
|
|
62
|
+
@click.option("--depth", default=None, type=int, metavar="N",
|
|
63
|
+
help="Commits to scan with --history (default: 100 local, 50 GitHub).")
|
|
64
|
+
@click.option("--since", default=None, metavar="DATE",
|
|
65
|
+
help="Limit history scan to commits after DATE (e.g. 2024-01-01).")
|
|
66
|
+
@click.option("--branch", default=None, metavar="BRANCH",
|
|
67
|
+
help="Limit history scan to a specific branch (local only).")
|
|
68
|
+
@click.option("--verify", is_flag=True, default=False,
|
|
69
|
+
help="Verify whether found secrets are still live via API calls.")
|
|
70
|
+
@click.option("--baseline", default=None, metavar="FILE",
|
|
71
|
+
help="Filter out findings already in this baseline file.")
|
|
72
|
+
@click.option("--save-baseline", default=None, metavar="FILE",
|
|
73
|
+
help="Save current findings as a baseline for future runs.")
|
|
74
|
+
@click.option("--redact", is_flag=True, default=False,
|
|
75
|
+
help="Redact secret values in all output (show first/last 4 chars only).")
|
|
76
|
+
@click.option("--include-gists", is_flag=True, default=False,
|
|
77
|
+
help="Include GitHub Gists in --github scans.")
|
|
78
|
+
def scan(
|
|
79
|
+
path: str,
|
|
80
|
+
github: Optional[str],
|
|
81
|
+
repo: Optional[str],
|
|
82
|
+
token: Optional[str],
|
|
83
|
+
output_format: str,
|
|
84
|
+
output: Optional[str],
|
|
85
|
+
entropy_threshold: float,
|
|
86
|
+
no_entropy: bool,
|
|
87
|
+
severity: Optional[str],
|
|
88
|
+
history: bool,
|
|
89
|
+
depth: Optional[int],
|
|
90
|
+
since: Optional[str],
|
|
91
|
+
branch: Optional[str],
|
|
92
|
+
verify: bool,
|
|
93
|
+
baseline: Optional[str],
|
|
94
|
+
save_baseline: Optional[str],
|
|
95
|
+
redact: bool,
|
|
96
|
+
include_gists: bool,
|
|
97
|
+
):
|
|
98
|
+
"""Scan a local PATH, GitHub repo URL, or GitHub profile for leaked secrets.
|
|
99
|
+
|
|
100
|
+
\b
|
|
101
|
+
Examples:
|
|
102
|
+
secrets scan ./myproject
|
|
103
|
+
secrets scan . --history --since 2024-01-01
|
|
104
|
+
secrets scan https://github.com/owner/repo --verify
|
|
105
|
+
secrets scan --github username --include-gists
|
|
106
|
+
secrets scan . --baseline .secrets.baseline
|
|
107
|
+
"""
|
|
108
|
+
gh_owner: Optional[str] = None
|
|
109
|
+
gh_repo_filter: Optional[str] = None
|
|
110
|
+
|
|
111
|
+
if github:
|
|
112
|
+
gh_owner = github
|
|
113
|
+
gh_repo_filter = repo
|
|
114
|
+
else:
|
|
115
|
+
url_match = _parse_github_url(path)
|
|
116
|
+
if url_match:
|
|
117
|
+
gh_owner, gh_repo_filter = url_match
|
|
118
|
+
|
|
119
|
+
if gh_owner:
|
|
120
|
+
result = _scan_github(
|
|
121
|
+
username=gh_owner,
|
|
122
|
+
repo_filter=gh_repo_filter,
|
|
123
|
+
token=token,
|
|
124
|
+
entropy_threshold=entropy_threshold,
|
|
125
|
+
no_entropy=no_entropy,
|
|
126
|
+
history=history,
|
|
127
|
+
depth=depth or 50,
|
|
128
|
+
include_gists=include_gists,
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
target = Path(path).resolve()
|
|
132
|
+
if not target.exists():
|
|
133
|
+
err_console.print(f"[red]Path not found:[/red] {target}")
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
|
|
136
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
|
137
|
+
console=err_console, transient=True) as progress:
|
|
138
|
+
progress.add_task(f"Scanning {target}...", total=None)
|
|
139
|
+
result = scan_path(target, entropy_threshold, no_entropy)
|
|
140
|
+
|
|
141
|
+
if history:
|
|
142
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
|
143
|
+
console=err_console, transient=True) as progress:
|
|
144
|
+
progress.add_task("Scanning git history...", total=None)
|
|
145
|
+
hist = scan_git_history(
|
|
146
|
+
target, entropy_threshold, no_entropy,
|
|
147
|
+
depth=depth or 100, since=since, branch=branch,
|
|
148
|
+
)
|
|
149
|
+
result.findings.extend(hist.findings)
|
|
150
|
+
result.commits_scanned = hist.commits_scanned
|
|
151
|
+
result.errors.extend(hist.errors)
|
|
152
|
+
|
|
153
|
+
if baseline:
|
|
154
|
+
bpath = Path(baseline)
|
|
155
|
+
if bpath.exists():
|
|
156
|
+
from scanner.baseline import load, filter_new
|
|
157
|
+
known = load(bpath)
|
|
158
|
+
before = len(result.findings)
|
|
159
|
+
result.findings = filter_new(result.findings, known)
|
|
160
|
+
suppressed = before - len(result.findings)
|
|
161
|
+
if suppressed:
|
|
162
|
+
err_console.print(f"[dim]Suppressed {suppressed} baseline finding(s).[/dim]")
|
|
163
|
+
else:
|
|
164
|
+
err_console.print(f"[yellow]Baseline file not found:[/yellow] {bpath}")
|
|
165
|
+
|
|
166
|
+
if severity:
|
|
167
|
+
order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
168
|
+
threshold = order[severity]
|
|
169
|
+
result.findings = [f for f in result.findings if order.get(f.severity, 9) <= threshold]
|
|
170
|
+
|
|
171
|
+
if verify and result.findings:
|
|
172
|
+
_verify_findings(result)
|
|
173
|
+
|
|
174
|
+
if save_baseline:
|
|
175
|
+
from scanner.baseline import save
|
|
176
|
+
save(result, Path(save_baseline))
|
|
177
|
+
err_console.print(f"[green]Baseline saved to:[/green] {save_baseline}")
|
|
178
|
+
|
|
179
|
+
if output_format == "terminal":
|
|
180
|
+
reporter.print_terminal(result, redact=redact)
|
|
181
|
+
elif output_format == "json":
|
|
182
|
+
_write(reporter.to_json(result, redact=redact), output)
|
|
183
|
+
elif output_format == "csv":
|
|
184
|
+
_write(reporter.to_csv(result, redact=redact), output)
|
|
185
|
+
elif output_format == "sarif":
|
|
186
|
+
_write(reporter.to_sarif(result), output)
|
|
187
|
+
elif output_format == "disclosure":
|
|
188
|
+
target_name = gh_owner or github
|
|
189
|
+
if not target_name:
|
|
190
|
+
err_console.print("[red]--format disclosure requires --github <username> or a GitHub URL[/red]")
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
_write(reporter.to_disclosure_report(target_name, result, redact=redact), output)
|
|
193
|
+
|
|
194
|
+
_exit_code(result)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _verify_findings(result: ScanResult) -> None:
|
|
198
|
+
from scanner.verifier import verify, can_verify
|
|
199
|
+
verifiable = [f for f in result.findings if can_verify(f)]
|
|
200
|
+
if not verifiable:
|
|
201
|
+
return
|
|
202
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
|
203
|
+
console=err_console, transient=True) as progress:
|
|
204
|
+
task = progress.add_task(f"Verifying {len(verifiable)} secret(s)...", total=len(verifiable))
|
|
205
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
206
|
+
futures = {executor.submit(verify, f): f for f in verifiable}
|
|
207
|
+
for future in as_completed(futures):
|
|
208
|
+
f = futures[future]
|
|
209
|
+
try:
|
|
210
|
+
f.verified = future.result()
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
progress.advance(task)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _scan_github(
|
|
217
|
+
username: str,
|
|
218
|
+
repo_filter: Optional[str],
|
|
219
|
+
token: Optional[str],
|
|
220
|
+
entropy_threshold: float,
|
|
221
|
+
no_entropy: bool,
|
|
222
|
+
history: bool = False,
|
|
223
|
+
depth: int = 50,
|
|
224
|
+
include_gists: bool = False,
|
|
225
|
+
) -> ScanResult:
|
|
226
|
+
fetcher = GitHubFetcher(token=token)
|
|
227
|
+
result = ScanResult()
|
|
228
|
+
|
|
229
|
+
if history and not token:
|
|
230
|
+
err_console.print("[yellow]Warning: --history without --token hits GitHub rate limits fast.[/yellow]")
|
|
231
|
+
|
|
232
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
|
233
|
+
console=err_console, transient=True) as progress:
|
|
234
|
+
task = progress.add_task(f"Fetching repos for {username}...", total=None)
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
repos = [f"{username}/{repo_filter}"] if repo_filter else fetcher.list_repos(username)
|
|
238
|
+
except RateLimitError as e:
|
|
239
|
+
err_console.print(f"[red]Rate limited.[/red] Retry in {e.wait_seconds}s")
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
except Exception as e:
|
|
242
|
+
err_console.print(f"[red]GitHub API error:[/red] {e}")
|
|
243
|
+
sys.exit(1)
|
|
244
|
+
|
|
245
|
+
progress.update(task, description=f"Scanning {len(repos)} repos...")
|
|
246
|
+
|
|
247
|
+
for repo_name in repos:
|
|
248
|
+
progress.update(task, description=f"Scanning {repo_name}...")
|
|
249
|
+
try:
|
|
250
|
+
for repo_file in fetcher.iter_repo_files(repo_name):
|
|
251
|
+
result.findings.extend(scan_content_string(
|
|
252
|
+
content=repo_file.content,
|
|
253
|
+
filepath=f"{repo_file.repo}/{repo_file.path}",
|
|
254
|
+
entropy_threshold=entropy_threshold,
|
|
255
|
+
no_entropy=no_entropy,
|
|
256
|
+
))
|
|
257
|
+
result.files_scanned += 1
|
|
258
|
+
except RateLimitError as e:
|
|
259
|
+
err_console.print(f"\n[yellow]Rate limited. Waiting {e.wait_seconds}s...[/yellow]")
|
|
260
|
+
time.sleep(e.wait_seconds + 2)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
result.errors.append(f"{repo_name}: {e}")
|
|
263
|
+
result.files_skipped += 1
|
|
264
|
+
|
|
265
|
+
if history:
|
|
266
|
+
seen: set[tuple] = set()
|
|
267
|
+
progress.update(task, description=f"Scanning {repo_name} history...")
|
|
268
|
+
try:
|
|
269
|
+
for sha, filepath, patch in fetcher.iter_commit_diffs(repo_name, depth):
|
|
270
|
+
for f in scan_patch_string(patch, filepath, sha, entropy_threshold, no_entropy):
|
|
271
|
+
key = (f.matched_value, f.secret_type)
|
|
272
|
+
if key not in seen:
|
|
273
|
+
seen.add(key)
|
|
274
|
+
result.findings.append(f)
|
|
275
|
+
except RateLimitError as e:
|
|
276
|
+
err_console.print(f"\n[yellow]Rate limited on history. Waiting {e.wait_seconds}s...[/yellow]")
|
|
277
|
+
time.sleep(e.wait_seconds + 2)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
result.errors.append(f"{repo_name} history: {e}")
|
|
280
|
+
|
|
281
|
+
if include_gists:
|
|
282
|
+
progress.update(task, description=f"Scanning gists for {username}...")
|
|
283
|
+
try:
|
|
284
|
+
gists = fetcher.list_gists(username)
|
|
285
|
+
for gist_id in gists:
|
|
286
|
+
progress.update(task, description=f"Scanning gist:{gist_id[:8]}...")
|
|
287
|
+
try:
|
|
288
|
+
for gist_file in fetcher.iter_gist_files(gist_id):
|
|
289
|
+
result.findings.extend(scan_content_string(
|
|
290
|
+
content=gist_file.content,
|
|
291
|
+
filepath=f"{gist_file.repo}/{gist_file.path}",
|
|
292
|
+
entropy_threshold=entropy_threshold,
|
|
293
|
+
no_entropy=no_entropy,
|
|
294
|
+
))
|
|
295
|
+
result.files_scanned += 1
|
|
296
|
+
except Exception as e:
|
|
297
|
+
result.errors.append(f"gist:{gist_id}: {e}")
|
|
298
|
+
except RateLimitError as e:
|
|
299
|
+
err_console.print(f"\n[yellow]Rate limited on gists. Waiting {e.wait_seconds}s...[/yellow]")
|
|
300
|
+
except Exception as e:
|
|
301
|
+
result.errors.append(f"gists: {e}")
|
|
302
|
+
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _write(content: str, filepath: Optional[str]) -> None:
|
|
307
|
+
if filepath:
|
|
308
|
+
Path(filepath).write_text(content, encoding="utf-8")
|
|
309
|
+
err_console.print(f"[green]Output written to:[/green] {filepath}")
|
|
310
|
+
else:
|
|
311
|
+
click.echo(content)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _exit_code(result: ScanResult) -> None:
|
|
315
|
+
has_critical = any(f.severity in ("CRITICAL", "HIGH") for f in result.findings)
|
|
316
|
+
sys.exit(1 if has_critical else 0)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@cli.command(name="install-hook")
|
|
320
|
+
def install_hook():
|
|
321
|
+
"""Install a pre-commit git hook in the current repo."""
|
|
322
|
+
hook_path = Path(".git/hooks/pre-commit")
|
|
323
|
+
if not Path(".git").exists():
|
|
324
|
+
err_console.print("[red]Not a git repository.[/red]")
|
|
325
|
+
sys.exit(1)
|
|
326
|
+
|
|
327
|
+
hook_path.write_text("""#!/bin/sh
|
|
328
|
+
BASELINE_ARG=""
|
|
329
|
+
if [ -f .secrets.baseline ]; then
|
|
330
|
+
BASELINE_ARG="--baseline .secrets.baseline"
|
|
331
|
+
fi
|
|
332
|
+
secrets scan . --severity HIGH --format terminal $BASELINE_ARG
|
|
333
|
+
if [ $? -ne 0 ]; then
|
|
334
|
+
echo ""
|
|
335
|
+
echo "Commit blocked: secrets detected."
|
|
336
|
+
echo "Run 'secrets scan .' for full details."
|
|
337
|
+
echo "To suppress a known false positive, add '# nosec' to the line."
|
|
338
|
+
exit 1
|
|
339
|
+
fi
|
|
340
|
+
""")
|
|
341
|
+
hook_path.chmod(0o755)
|
|
342
|
+
console.print("[green]Pre-commit hook installed at .git/hooks/pre-commit[/green]")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
if __name__ == "__main__":
|
|
346
|
+
cli()
|