loosekeys 0.1.0__tar.gz
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.
- loosekeys-0.1.0/.gitignore +6 -0
- loosekeys-0.1.0/LICENSE +21 -0
- loosekeys-0.1.0/PKG-INFO +115 -0
- loosekeys-0.1.0/README.md +92 -0
- loosekeys-0.1.0/pyproject.toml +44 -0
- loosekeys-0.1.0/src/loosekeys/__init__.py +2 -0
- loosekeys-0.1.0/src/loosekeys/__main__.py +3 -0
- loosekeys-0.1.0/src/loosekeys/cli.py +65 -0
- loosekeys-0.1.0/src/loosekeys/detectors.py +155 -0
- loosekeys-0.1.0/src/loosekeys/fix.py +56 -0
- loosekeys-0.1.0/src/loosekeys/locations.py +198 -0
- loosekeys-0.1.0/src/loosekeys/report.py +138 -0
- loosekeys-0.1.0/src/loosekeys/scan.py +217 -0
- loosekeys-0.1.0/tests/fakehome.py +92 -0
- loosekeys-0.1.0/tests/test_loosekeys.py +157 -0
loosekeys-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arka Khorchidian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
loosekeys-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: loosekeys
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find API keys and tokens sitting in plain text on your machine: shell history, .env files, AI tool configs. Masked output, nothing leaves your computer.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ArkaKhorchidian/loosekeys
|
|
6
|
+
Project-URL: Issues, https://github.com/ArkaKhorchidian/loosekeys/issues
|
|
7
|
+
Author: Arka Khorchidian
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api-keys,cli,credentials,devsecops,dotenv,mcp,secret-scanning,secrets,security,shell-history
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: rich>=13
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
<h1 align="center">loosekeys</h1>
|
|
25
|
+
|
|
26
|
+
<p align="center"><b>Find the API keys sitting in plain text on your laptop.</b><br>
|
|
27
|
+
Shell history, forgotten <code>.env</code> files, AI tool and MCP configs, CLI credential stores. One command, nothing leaves your machine.</p>
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
<a href="https://pypi.org/project/loosekeys/"><img src="https://img.shields.io/pypi/v/loosekeys.svg" alt="PyPI"></a>
|
|
31
|
+
<a href="https://github.com/ArkaKhorchidian/loosekeys/actions/workflows/ci.yml"><img src="https://github.com/ArkaKhorchidian/loosekeys/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
32
|
+
<img src="https://img.shields.io/pypi/pyversions/loosekeys.svg" alt="Python">
|
|
33
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a>
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uvx loosekeys # or: pipx run loosekeys
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
<p align="center">
|
|
41
|
+
<img src="docs/demo.gif" alt="loosekeys scanning a home directory and finding API keys in shell history, .env files and MCP configs" width="760">
|
|
42
|
+
<br><sub>Demo run against a sandboxed home directory filled with fake keys.</sub>
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
Repo scanners like gitleaks and trufflehog guard what you *commit*. But most leaked keys never
|
|
46
|
+
get near a commit. They're the `export OPENAI_API_KEY=sk-...` you typed into your terminal
|
|
47
|
+
eight months ago and your shell has saved ever since; the `.env` in a side project you forgot
|
|
48
|
+
existed; the GitHub token you pasted into an MCP server config. loosekeys looks where those
|
|
49
|
+
end up.
|
|
50
|
+
|
|
51
|
+
## What it checks
|
|
52
|
+
|
|
53
|
+
| Where | Why it matters |
|
|
54
|
+
|---|---|
|
|
55
|
+
| **Shell & REPL history** (zsh, bash, fish, PowerShell, Python, Node, psql, MySQL, ...) | Every key you ever typed or pasted, saved in plain text, forever |
|
|
56
|
+
| **Shell config** (`.zshrc`, `.bashrc`, `.profile`, fish, PowerShell profile) | Exported keys are readable by every program you run, including every npm `postinstall` script |
|
|
57
|
+
| **AI tools & MCP servers** (Claude Code & Desktop, Cursor, Windsurf, VS Code, Continue, aider, Gemini CLI, Codex, Zed, llm) | Keys pasted into JSON config files |
|
|
58
|
+
| **Project files** (`.env*`, `secrets.toml`, `*.tfvars`, service-account JSON, docker-compose, ...) across your home folder | Flags files **committed to git** and projects you haven't touched in 6+ months |
|
|
59
|
+
| **CLI credential stores** (`~/.aws`, `.npmrc`, `.pypirc`, `.netrc`, Docker, kubectl, gcloud, gh, Cargo, ...) | Where tools expect logins to live, but still plain text |
|
|
60
|
+
| **SSH keys** | Private keys with no passphrase |
|
|
61
|
+
|
|
62
|
+
It recognizes 30+ token formats precisely: OpenAI, Anthropic, GitHub, GitLab, AWS, Google, Stripe,
|
|
63
|
+
Slack, Hugging Face, npm, PyPI, Groq, xAI, OpenRouter, Supabase, DigitalOcean, Twilio, SendGrid,
|
|
64
|
+
Telegram, database URLs with passwords, private keys, and more. It also catches `SOMETHING_API_KEY=...`
|
|
65
|
+
style assignments while ignoring placeholders like `your_key_here`, `${VAR}` and `changeme`.
|
|
66
|
+
|
|
67
|
+
## Clean up
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
loosekeys --redact-history
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This replaces every secret in your history files with `[REDACTED]`:
|
|
74
|
+
- every other byte stays untouched, including zsh's history encoding
|
|
75
|
+
- it keeps a backup first (`.zsh_history.loosekeys.bak`, owner-only permissions)
|
|
76
|
+
- it writes atomically
|
|
77
|
+
|
|
78
|
+
Close other terminal sessions first: open shells hold their history in memory and can write it back.
|
|
79
|
+
|
|
80
|
+
Redacting doesn't make a leaked key safe. The report links to each provider's page for **rotating**
|
|
81
|
+
the keys that ended up in history, shell config or git, and tells you what to do about the rest.
|
|
82
|
+
|
|
83
|
+
## Privacy
|
|
84
|
+
|
|
85
|
+
- **Nothing leaves your machine.** loosekeys makes no network requests, ever.
|
|
86
|
+
- **Secrets are never printed.** Values show as `sk-proj••••••Gi`, in the terminal and in `--json`
|
|
87
|
+
alike. The test suite asserts no full secret appears in any output.
|
|
88
|
+
- **It's read-only** unless you pass `--redact-history`.
|
|
89
|
+
|
|
90
|
+
## Options
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
loosekeys ~/code ~/work # search these folders for project files instead of your whole home
|
|
94
|
+
loosekeys --no-projects # only check the well-known locations (fast)
|
|
95
|
+
loosekeys --json # machine-readable, still masked; exit code 1 if anything is found
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Install
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pipx install loosekeys # or: uv tool install loosekeys / pip install loosekeys
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Python 3.9+ on macOS, Linux and Windows. Zero config. The only dependency is
|
|
105
|
+
[rich](https://github.com/Textualize/rich).
|
|
106
|
+
|
|
107
|
+
## Contributing
|
|
108
|
+
|
|
109
|
+
New token formats and new tool config locations are the most useful contributions. Add the
|
|
110
|
+
pattern in `detectors.py`, add a fake example in `tests/fakehome.py` (built at runtime, never
|
|
111
|
+
committed as a literal), and run `pytest`.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<h1 align="center">loosekeys</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center"><b>Find the API keys sitting in plain text on your laptop.</b><br>
|
|
4
|
+
Shell history, forgotten <code>.env</code> files, AI tool and MCP configs, CLI credential stores. One command, nothing leaves your machine.</p>
|
|
5
|
+
|
|
6
|
+
<p align="center">
|
|
7
|
+
<a href="https://pypi.org/project/loosekeys/"><img src="https://img.shields.io/pypi/v/loosekeys.svg" alt="PyPI"></a>
|
|
8
|
+
<a href="https://github.com/ArkaKhorchidian/loosekeys/actions/workflows/ci.yml"><img src="https://github.com/ArkaKhorchidian/loosekeys/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
9
|
+
<img src="https://img.shields.io/pypi/pyversions/loosekeys.svg" alt="Python">
|
|
10
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a>
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uvx loosekeys # or: pipx run loosekeys
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img src="docs/demo.gif" alt="loosekeys scanning a home directory and finding API keys in shell history, .env files and MCP configs" width="760">
|
|
19
|
+
<br><sub>Demo run against a sandboxed home directory filled with fake keys.</sub>
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
Repo scanners like gitleaks and trufflehog guard what you *commit*. But most leaked keys never
|
|
23
|
+
get near a commit. They're the `export OPENAI_API_KEY=sk-...` you typed into your terminal
|
|
24
|
+
eight months ago and your shell has saved ever since; the `.env` in a side project you forgot
|
|
25
|
+
existed; the GitHub token you pasted into an MCP server config. loosekeys looks where those
|
|
26
|
+
end up.
|
|
27
|
+
|
|
28
|
+
## What it checks
|
|
29
|
+
|
|
30
|
+
| Where | Why it matters |
|
|
31
|
+
|---|---|
|
|
32
|
+
| **Shell & REPL history** (zsh, bash, fish, PowerShell, Python, Node, psql, MySQL, ...) | Every key you ever typed or pasted, saved in plain text, forever |
|
|
33
|
+
| **Shell config** (`.zshrc`, `.bashrc`, `.profile`, fish, PowerShell profile) | Exported keys are readable by every program you run, including every npm `postinstall` script |
|
|
34
|
+
| **AI tools & MCP servers** (Claude Code & Desktop, Cursor, Windsurf, VS Code, Continue, aider, Gemini CLI, Codex, Zed, llm) | Keys pasted into JSON config files |
|
|
35
|
+
| **Project files** (`.env*`, `secrets.toml`, `*.tfvars`, service-account JSON, docker-compose, ...) across your home folder | Flags files **committed to git** and projects you haven't touched in 6+ months |
|
|
36
|
+
| **CLI credential stores** (`~/.aws`, `.npmrc`, `.pypirc`, `.netrc`, Docker, kubectl, gcloud, gh, Cargo, ...) | Where tools expect logins to live, but still plain text |
|
|
37
|
+
| **SSH keys** | Private keys with no passphrase |
|
|
38
|
+
|
|
39
|
+
It recognizes 30+ token formats precisely: OpenAI, Anthropic, GitHub, GitLab, AWS, Google, Stripe,
|
|
40
|
+
Slack, Hugging Face, npm, PyPI, Groq, xAI, OpenRouter, Supabase, DigitalOcean, Twilio, SendGrid,
|
|
41
|
+
Telegram, database URLs with passwords, private keys, and more. It also catches `SOMETHING_API_KEY=...`
|
|
42
|
+
style assignments while ignoring placeholders like `your_key_here`, `${VAR}` and `changeme`.
|
|
43
|
+
|
|
44
|
+
## Clean up
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
loosekeys --redact-history
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This replaces every secret in your history files with `[REDACTED]`:
|
|
51
|
+
- every other byte stays untouched, including zsh's history encoding
|
|
52
|
+
- it keeps a backup first (`.zsh_history.loosekeys.bak`, owner-only permissions)
|
|
53
|
+
- it writes atomically
|
|
54
|
+
|
|
55
|
+
Close other terminal sessions first: open shells hold their history in memory and can write it back.
|
|
56
|
+
|
|
57
|
+
Redacting doesn't make a leaked key safe. The report links to each provider's page for **rotating**
|
|
58
|
+
the keys that ended up in history, shell config or git, and tells you what to do about the rest.
|
|
59
|
+
|
|
60
|
+
## Privacy
|
|
61
|
+
|
|
62
|
+
- **Nothing leaves your machine.** loosekeys makes no network requests, ever.
|
|
63
|
+
- **Secrets are never printed.** Values show as `sk-proj••••••Gi`, in the terminal and in `--json`
|
|
64
|
+
alike. The test suite asserts no full secret appears in any output.
|
|
65
|
+
- **It's read-only** unless you pass `--redact-history`.
|
|
66
|
+
|
|
67
|
+
## Options
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
loosekeys ~/code ~/work # search these folders for project files instead of your whole home
|
|
71
|
+
loosekeys --no-projects # only check the well-known locations (fast)
|
|
72
|
+
loosekeys --json # machine-readable, still masked; exit code 1 if anything is found
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pipx install loosekeys # or: uv tool install loosekeys / pip install loosekeys
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Python 3.9+ on macOS, Linux and Windows. Zero config. The only dependency is
|
|
82
|
+
[rich](https://github.com/Textualize/rich).
|
|
83
|
+
|
|
84
|
+
## Contributing
|
|
85
|
+
|
|
86
|
+
New token formats and new tool config locations are the most useful contributions. Add the
|
|
87
|
+
pattern in `detectors.py`, add a fake example in `tests/fakehome.py` (built at runtime, never
|
|
88
|
+
committed as a literal), and run `pytest`.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "loosekeys"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Find API keys and tokens sitting in plain text on your machine: shell history, .env files, AI tool configs. Masked output, nothing leaves your computer."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Arka Khorchidian" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = ["rich>=13"]
|
|
15
|
+
keywords = ["security", "secrets", "api-keys", "credentials", "secret-scanning", "dotenv", "shell-history", "mcp", "devsecops", "cli"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Security",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
test = ["pytest>=7"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
loosekeys = "loosekeys.cli:run"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/ArkaKhorchidian/loosekeys"
|
|
34
|
+
Issues = "https://github.com/ArkaKhorchidian/loosekeys/issues"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/loosekeys"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
exclude = ["docs", ".github"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
pythonpath = ["tests"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""loosekeys command line."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .fix import redact_histories
|
|
13
|
+
from .locations import default_roots, fixed_locations, project_files, ssh_keys
|
|
14
|
+
from .report import render, short
|
|
15
|
+
from .scan import scan
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv=None) -> int:
|
|
19
|
+
p = argparse.ArgumentParser(
|
|
20
|
+
prog="loosekeys",
|
|
21
|
+
description="Find API keys and tokens sitting in plain text on your machine. Values are masked "
|
|
22
|
+
"and nothing leaves your computer.")
|
|
23
|
+
p.add_argument("paths", nargs="*", type=Path, help="folders to search for project files (default: your home)")
|
|
24
|
+
p.add_argument("--redact-history", action="store_true",
|
|
25
|
+
help="replace secrets in shell/REPL history files with [REDACTED] (backups are kept)")
|
|
26
|
+
p.add_argument("--json", action="store_true", help="machine-readable output (values stay masked)")
|
|
27
|
+
p.add_argument("--no-projects", action="store_true", help="skip the .env / project-file search")
|
|
28
|
+
p.add_argument("--depth", type=int, default=6, help="how deep to search for project files (default 6)")
|
|
29
|
+
p.add_argument("--version", action="version", version=f"loosekeys {__version__}")
|
|
30
|
+
a = p.parse_args(argv)
|
|
31
|
+
|
|
32
|
+
console = Console(stderr=a.json)
|
|
33
|
+
locations = fixed_locations() + ssh_keys()
|
|
34
|
+
if not a.no_projects:
|
|
35
|
+
with console.status("[dim]looking through your projects…[/]", spinner="dots"):
|
|
36
|
+
locations += list(project_files(a.paths or default_roots(), max_depth=a.depth))
|
|
37
|
+
|
|
38
|
+
if a.redact_history:
|
|
39
|
+
done = redact_histories(locations)
|
|
40
|
+
if not done:
|
|
41
|
+
console.print("No secrets found in your history files.")
|
|
42
|
+
for path, n, backup in done:
|
|
43
|
+
console.print(f"[bold green]✓[/] {short(path)}: redacted {n} secret{'s' if n > 1 else ''} "
|
|
44
|
+
f"[dim](backup: {short(backup)}; delete it once you're happy)[/]")
|
|
45
|
+
if done:
|
|
46
|
+
console.print("[dim]Already-open shells keep their old history in memory: close them, or the "
|
|
47
|
+
"secrets may be written back on exit. Redacting doesn't revoke anything: rotate the keys.[/]")
|
|
48
|
+
|
|
49
|
+
with console.status("[dim]scanning…[/]", spinner="dots"):
|
|
50
|
+
report = scan(locations)
|
|
51
|
+
|
|
52
|
+
if a.json:
|
|
53
|
+
print(json.dumps({"score": report.score, "counts": report.counts(), "scanned_files": report.scanned_files,
|
|
54
|
+
"findings": [f.to_dict() for f in report.findings]}, indent=2))
|
|
55
|
+
else:
|
|
56
|
+
render(report, console)
|
|
57
|
+
return 1 if report.findings else 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run() -> None:
|
|
61
|
+
sys.exit(main())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
run()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""What a secret looks like.
|
|
2
|
+
|
|
3
|
+
Provider-specific patterns first (precise: a match is almost certainly real), then a
|
|
4
|
+
conservative generic rule that only fires on `NAME=value` assignments whose name says
|
|
5
|
+
it's a secret and whose value doesn't look like a placeholder.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Iterator
|
|
13
|
+
|
|
14
|
+
CRITICAL, HIGH, MEDIUM, LOW = "critical", "high", "medium", "low"
|
|
15
|
+
SEVERITY_ORDER = {CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Detector:
|
|
20
|
+
id: str
|
|
21
|
+
name: str
|
|
22
|
+
severity: str
|
|
23
|
+
pattern: re.Pattern
|
|
24
|
+
group: int = 0 # capture group holding the secret itself
|
|
25
|
+
rotate: str = "" # where to revoke/rotate it
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _d(id, name, severity, regex, group=0, rotate="", flags=0):
|
|
29
|
+
return Detector(id, name, severity, re.compile(regex, flags), group, rotate)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DETECTORS: list[Detector] = [
|
|
33
|
+
# --- private keys ---------------------------------------------------------
|
|
34
|
+
_d("private-key", "Private key", CRITICAL,
|
|
35
|
+
r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----"),
|
|
36
|
+
# --- AI providers -------------------------------------------------------------
|
|
37
|
+
_d("anthropic", "Anthropic API key", HIGH, r"\bsk-ant-(?:api03|admin01)-[A-Za-z0-9_\-]{32,}",
|
|
38
|
+
rotate="https://console.anthropic.com/settings/keys"),
|
|
39
|
+
_d("openrouter", "OpenRouter API key", HIGH, r"\bsk-or-v1-[a-f0-9]{64}\b",
|
|
40
|
+
rotate="https://openrouter.ai/settings/keys"),
|
|
41
|
+
_d("openai", "OpenAI API key", HIGH,
|
|
42
|
+
r"\bsk-(?:proj-|svcacct-|admin-)[A-Za-z0-9_\-]{40,}|\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b",
|
|
43
|
+
rotate="https://platform.openai.com/api-keys"),
|
|
44
|
+
_d("groq", "Groq API key", HIGH, r"\bgsk_[A-Za-z0-9]{52}\b", rotate="https://console.groq.com/keys"),
|
|
45
|
+
_d("xai", "xAI API key", HIGH, r"\bxai-[A-Za-z0-9]{70,90}\b", rotate="https://console.x.ai"),
|
|
46
|
+
_d("perplexity", "Perplexity API key", HIGH, r"\bpplx-[A-Za-z0-9]{48}\b"),
|
|
47
|
+
_d("huggingface", "Hugging Face token", HIGH, r"\bhf_[A-Za-z0-9]{34,40}\b",
|
|
48
|
+
rotate="https://huggingface.co/settings/tokens"),
|
|
49
|
+
_d("replicate", "Replicate API token", HIGH, r"\br8_[A-Za-z0-9]{37}\b"),
|
|
50
|
+
# --- code hosting & package registries ----------------------------------------
|
|
51
|
+
_d("github", "GitHub token", CRITICAL, r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b",
|
|
52
|
+
rotate="https://github.com/settings/tokens"),
|
|
53
|
+
_d("gitlab", "GitLab token", CRITICAL, r"\bglpat-[A-Za-z0-9_\-]{20,}\b"),
|
|
54
|
+
_d("npm", "npm token", CRITICAL, r"\bnpm_[A-Za-z0-9]{36}\b", rotate="https://www.npmjs.com/settings/~/tokens"),
|
|
55
|
+
_d("pypi", "PyPI token", CRITICAL, r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_\-]{50,}",
|
|
56
|
+
rotate="https://pypi.org/manage/account/token/"),
|
|
57
|
+
# --- cloud ----------------------------------------------------------------------
|
|
58
|
+
_d("aws-key-id", "AWS access key ID", HIGH, r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b",
|
|
59
|
+
rotate="https://console.aws.amazon.com/iam/home#/security_credentials"),
|
|
60
|
+
_d("aws-secret", "AWS secret access key", CRITICAL,
|
|
61
|
+
r"(?i)aws_?secret_?access_?key[\"']?\s*[=:]\s*[\"']?([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])", group=1,
|
|
62
|
+
rotate="https://console.aws.amazon.com/iam/home#/security_credentials"),
|
|
63
|
+
_d("google-api", "Google API key", HIGH, r"\bAIza[0-9A-Za-z_\-]{35}(?![0-9A-Za-z_\-])",
|
|
64
|
+
rotate="https://console.cloud.google.com/apis/credentials"),
|
|
65
|
+
_d("google-refresh", "Google OAuth refresh token", HIGH, r"\b1//0[A-Za-z0-9_\-]{30,}"),
|
|
66
|
+
_d("digitalocean", "DigitalOcean token", CRITICAL, r"\bdo[po]_v1_[a-f0-9]{64}\b"),
|
|
67
|
+
_d("supabase", "Supabase secret", CRITICAL, r"\bsbp_[a-f0-9]{40}\b|\bsb_secret_[A-Za-z0-9_\-]{20,}"),
|
|
68
|
+
# --- payments & messaging -------------------------------------------------------
|
|
69
|
+
_d("stripe-live", "Stripe live secret key", CRITICAL, r"\b[sr]k_live_[A-Za-z0-9]{24,}\b",
|
|
70
|
+
rotate="https://dashboard.stripe.com/apikeys"),
|
|
71
|
+
_d("stripe-test", "Stripe test key", LOW, r"\b[sr]k_test_[A-Za-z0-9]{24,}\b"),
|
|
72
|
+
_d("slack-token", "Slack token", HIGH, r"\bxox[baprs]-[A-Za-z0-9-]{10,}"),
|
|
73
|
+
_d("slack-webhook", "Slack webhook", MEDIUM, r"https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]{20,}"),
|
|
74
|
+
_d("discord-webhook", "Discord webhook", MEDIUM,
|
|
75
|
+
r"https://(?:ptb\.|canary\.)?discord(?:app)?\.com/api/webhooks/\d+/[A-Za-z0-9_\-]{40,}"),
|
|
76
|
+
_d("telegram", "Telegram bot token", HIGH, r"\b\d{8,10}:AA[A-Za-z0-9_\-]{33}\b"),
|
|
77
|
+
_d("sendgrid", "SendGrid API key", HIGH, r"\bSG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}\b"),
|
|
78
|
+
_d("twilio", "Twilio API key", HIGH, r"\bSK[0-9a-f]{32}\b"),
|
|
79
|
+
_d("mailgun", "Mailgun API key", HIGH, r"\bkey-[a-f0-9]{32}\b"),
|
|
80
|
+
_d("linear", "Linear API key", MEDIUM, r"\blin_api_[A-Za-z0-9]{40}\b"),
|
|
81
|
+
_d("notion", "Notion token", MEDIUM, r"\b(?:secret_|ntn_)[A-Za-z0-9]{43,}\b"),
|
|
82
|
+
_d("shopify", "Shopify token", HIGH, r"\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b"),
|
|
83
|
+
# --- connection strings -----------------------------------------------------------
|
|
84
|
+
_d("db-url", "Database URL with password", HIGH,
|
|
85
|
+
r"\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?)://[^:\s/@'\"]+:([^@\s/'\"]{4,})@[^\s'\"]+",
|
|
86
|
+
group=1),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
GENERIC = _d(
|
|
90
|
+
"generic", "Secret assignment", MEDIUM,
|
|
91
|
+
r"(?i)(?:^|[\s;'\"{,]|export\s+)((?:[A-Z][A-Z0-9_]*)?(?:API_?KEY|SECRET(?:_?KEY)?|_TOKEN|AUTH_?TOKEN|ACCESS_?KEY|PASSWORD|PASSWD|PRIVATE_?KEY))"
|
|
92
|
+
r"[\"']?\s*[=:]\s*[\"']?([^\s\"'#,}()\[\];]{12,})(?![(\[.])", group=2)
|
|
93
|
+
|
|
94
|
+
_PLACEHOLDER = re.compile(
|
|
95
|
+
r"^(?:x{4,}|\*{3,}|\.{3,}|<.*>|\{\{.*\}\}|\$.*|%.*%|your[_-].*|.*example.*|.*placeholder.*|change[_-]?me.*|"
|
|
96
|
+
r"dummy.*|fake.*|test.*|sample.*|todo|none|null|true|false|redacted.*|\[redacted\].*|secret|password)$", re.I)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def entropy(s: str) -> float:
|
|
100
|
+
if not s:
|
|
101
|
+
return 0.0
|
|
102
|
+
counts = {c: s.count(c) for c in set(s)}
|
|
103
|
+
return -sum(n / len(s) * math.log2(n / len(s)) for n in counts.values())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def looks_like_placeholder(value: str) -> bool:
|
|
107
|
+
v = value.strip().strip("\"'")
|
|
108
|
+
if _PLACEHOLDER.match(v) or v.startswith(("/", "~", "./", "http://localhost", "file:")):
|
|
109
|
+
return True
|
|
110
|
+
if len(set(v)) <= 3 or entropy(v) < 3.0:
|
|
111
|
+
return True
|
|
112
|
+
has_alpha, has_digit = any(c.isalpha() for c in v), any(c.isdigit() for c in v)
|
|
113
|
+
return not (has_alpha and has_digit)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def mask(secret: str) -> str:
|
|
117
|
+
"""Show just enough to recognise a key, never enough to use it."""
|
|
118
|
+
if len(secret) <= 8:
|
|
119
|
+
return "•" * 6
|
|
120
|
+
head = 7 if secret.startswith(("sk-", "ghp_", "gho_", "hf_", "npm_", "AKIA", "AIza", "xox", "glpat")) else 4
|
|
121
|
+
head = min(head, len(secret) // 3)
|
|
122
|
+
return secret[:head] + "•" * 6 + secret[-2:]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class Match:
|
|
127
|
+
detector: Detector
|
|
128
|
+
secret: str
|
|
129
|
+
start: int
|
|
130
|
+
end: int
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def find(text: str, generic: bool = True) -> Iterator[Match]:
|
|
134
|
+
"""All non-overlapping secrets in `text`, specific detectors taking precedence."""
|
|
135
|
+
taken: list[tuple[int, int]] = []
|
|
136
|
+
|
|
137
|
+
def free(a, b):
|
|
138
|
+
return all(b <= s or a >= e for s, e in taken)
|
|
139
|
+
|
|
140
|
+
for det in DETECTORS:
|
|
141
|
+
for m in det.pattern.finditer(text):
|
|
142
|
+
a, b = m.span(det.group)
|
|
143
|
+
secret = m.group(det.group)
|
|
144
|
+
if det.id == "db-url" and looks_like_placeholder(secret):
|
|
145
|
+
continue
|
|
146
|
+
if free(a, b):
|
|
147
|
+
taken.append((a, b))
|
|
148
|
+
yield Match(det, secret, a, b)
|
|
149
|
+
if generic:
|
|
150
|
+
for m in GENERIC.pattern.finditer(text):
|
|
151
|
+
a, b = m.span(2)
|
|
152
|
+
secret = m.group(2)
|
|
153
|
+
if free(a, b) and not looks_like_placeholder(secret):
|
|
154
|
+
taken.append((a, b))
|
|
155
|
+
yield Match(GENERIC, secret, a, b)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Redact secrets from history files, with a backup first."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import detectors as D
|
|
11
|
+
from .locations import HISTORY, Location
|
|
12
|
+
|
|
13
|
+
REDACTED = "[REDACTED]"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def redact_text(text: str) -> tuple[str, int]:
|
|
17
|
+
"""Replace every secret in `text`; returns (new_text, number_replaced)."""
|
|
18
|
+
matches = sorted(D.find(text), key=lambda m: m.start, reverse=True)
|
|
19
|
+
for m in matches:
|
|
20
|
+
text = text[:m.start] + REDACTED + text[m.end:]
|
|
21
|
+
return text, len(matches)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def redact_file(path: Path) -> tuple[int, Path | None]:
|
|
25
|
+
"""Redact in place. Writes `<file>.loosekeys.bak` (owner-only) first. Returns (count, backup)."""
|
|
26
|
+
raw = path.read_bytes()
|
|
27
|
+
text = raw.decode("latin-1") # byte-exact round trip (zsh "metafies" non-ASCII bytes)
|
|
28
|
+
new, n = redact_text(text)
|
|
29
|
+
if n == 0:
|
|
30
|
+
return 0, None
|
|
31
|
+
backup = path.with_name(path.name + ".loosekeys.bak")
|
|
32
|
+
shutil.copy2(path, backup)
|
|
33
|
+
os.chmod(backup, stat.S_IRUSR | stat.S_IWUSR)
|
|
34
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
35
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
36
|
+
try:
|
|
37
|
+
with os.fdopen(fd, "wb") as fh:
|
|
38
|
+
fh.write(new.encode("latin-1"))
|
|
39
|
+
os.chmod(tmp, mode)
|
|
40
|
+
os.replace(tmp, path) # atomic: the history file is never half-written
|
|
41
|
+
except BaseException:
|
|
42
|
+
if os.path.exists(tmp):
|
|
43
|
+
os.unlink(tmp)
|
|
44
|
+
raise
|
|
45
|
+
return n, backup
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def redact_histories(locations: list[Location]) -> list[tuple[Path, int, Path]]:
|
|
49
|
+
done = []
|
|
50
|
+
for loc in locations:
|
|
51
|
+
if loc.kind != HISTORY:
|
|
52
|
+
continue
|
|
53
|
+
n, backup = redact_file(loc.path)
|
|
54
|
+
if n:
|
|
55
|
+
done.append((loc.path, n, backup))
|
|
56
|
+
return done
|