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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .pytest_cache/
6
+ *.loosekeys.bak
@@ -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.
@@ -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,2 @@
1
+ """loosekeys: find API keys and tokens sitting in plain text on your machine."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import run
2
+
3
+ run()
@@ -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