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.
@@ -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
+ ![demo](https://raw.githubusercontent.com/Vasishta03/secret-scanner/main/demo.svg)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ secrets = scanner.cli:cli
@@ -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()